mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-08 03:32:27 +09:00
Compare commits
29
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d346eb0e88 | ||
|
|
7c5fa2180a | ||
|
|
c748c8af4e | ||
|
|
ba59a13149 | ||
|
|
3f4160b138 | ||
|
|
5f4295e399 | ||
|
|
4e730832c7 | ||
|
|
d7c00d4556 | ||
|
|
154b65afe5 | ||
|
|
fcc2a3f64b | ||
|
|
80456861ac | ||
|
|
0c4f016ba2 | ||
|
|
cc5545c333 | ||
|
|
6e28092cdc | ||
|
|
8b6910d505 | ||
|
|
d87e31efaf | ||
|
|
6618a6baf4 | ||
|
|
22b8f5454b | ||
|
|
801bd3f106 | ||
|
|
e26c8c870d | ||
|
|
f4e545e090 | ||
|
|
2006fc0d8d | ||
|
|
d456048e69 | ||
|
|
fd35b4da24 | ||
|
|
283d179b05 | ||
|
|
3093e7334b | ||
|
|
02c82f0038 | ||
|
|
e1eecc3b6c | ||
|
|
bf3d73a5f7 |
@@ -50,6 +50,8 @@
|
||||
|
||||
/app-dist-output/
|
||||
/artifacts/
|
||||
/desktop-shared-assets/
|
||||
/desktop-modules/
|
||||
/s3_payload/
|
||||
/upload_staging/
|
||||
|
||||
|
||||
@@ -1,24 +1,24 @@
|
||||
# Contributing to Fluxer
|
||||
|
||||
This policy applies to all issues, discussions, commits and pull requests.
|
||||
This policy applies to all commits and pull requests.
|
||||
|
||||
## Scope
|
||||
|
||||
To prevent spam, only approved contributors may submit pull requests.
|
||||
|
||||
To request approval, comment on an existing issue and ask to implement it. For work that extends beyond a defect fix, open a [discussion](https://github.com/orgs/fluxerapp/discussions) first.
|
||||
To request approval, comment on the [feedback.fluxer.com](https://feedback.fluxer.com) post you want to implement and ask to work on it. For work that extends beyond a defect fix, post a feature request there first.
|
||||
|
||||
Every pull request must:
|
||||
|
||||
- Target the repository's default branch.
|
||||
- Include a closing reference for each repository issue it resolves.
|
||||
- Link each feedback.fluxer.com post it resolves.
|
||||
- Receive approval from a maintainer before it is merged.
|
||||
|
||||
Place each closing reference on a separate line:
|
||||
Place each link on a separate line:
|
||||
|
||||
```text
|
||||
Closes #123
|
||||
Closes #456
|
||||
Resolves https://feedback.fluxer.com/p/123
|
||||
Resolves https://feedback.fluxer.com/p/456
|
||||
```
|
||||
|
||||
## Authorship
|
||||
@@ -78,11 +78,11 @@ Complete every section of the pull request template. Clearly describe:
|
||||
|
||||
## Reports and other contributions
|
||||
|
||||
Use the [bug report form](https://github.com/fluxerapp/fluxer/issues/new?template=bug-report.yaml) to report reproducible defects.
|
||||
Report bugs and request features at [feedback.fluxer.com](https://feedback.fluxer.com).
|
||||
|
||||
Report security vulnerabilities privately through the channels specified in the [security policy](https://github.com/fluxerapp/fluxer/blob/main/.github/SECURITY.md). Do not report vulnerabilities in public issues or discussions.
|
||||
Report security vulnerabilities privately through [fluxer.app/security](https://fluxer.app/security). Never post them publicly.
|
||||
|
||||
Use [discussions](https://github.com/orgs/fluxerapp/discussions) for feature proposals and self-hosting questions.
|
||||
Read the [operator documentation](https://fluxer.dev) for self-hosting questions.
|
||||
|
||||
Submit translations through [Weblate](https://weblate.fluxer.tools), not through pull requests.
|
||||
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
# yaml-language-server: $schema=https://www.schemastore.org/github-discussion.json
|
||||
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Search existing discussions before posting a feature proposal.
|
||||
|
||||
Report vulnerabilities through the [private form](https://github.com/fluxerapp/fluxer/security/advisories/new) or <[email protected]>.
|
||||
|
||||
- type: textarea
|
||||
id: problem
|
||||
attributes:
|
||||
label: Current problem
|
||||
description: State what you are trying to do and what prevents it.
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: proposal
|
||||
attributes:
|
||||
label: Proposed change
|
||||
description: State the expected behaviour.
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: notes
|
||||
attributes:
|
||||
label: Additional information
|
||||
description: Optional. Include constraints, trade-offs, related discussions, screenshots or mockups.
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: checkboxes
|
||||
id: checks
|
||||
attributes:
|
||||
label: Acknowledgements
|
||||
options:
|
||||
- label: I searched existing discussions.
|
||||
required: true
|
||||
@@ -1,83 +0,0 @@
|
||||
# yaml-language-server: $schema=https://www.schemastore.org/github-issue-forms.json
|
||||
name: Bug report
|
||||
description: Report a reproducible defect in Fluxer.
|
||||
type: Bug
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Search [open and closed issues](https://github.com/fluxerapp/fluxer/issues?q=is%3Aissue) before filing a report.
|
||||
|
||||
Report vulnerabilities through the [private form](https://github.com/fluxerapp/fluxer/security/advisories/new) or <[email protected]>. Send account and billing requests to <[email protected]>.
|
||||
|
||||
- type: textarea
|
||||
id: summary
|
||||
attributes:
|
||||
label: Observed behaviour
|
||||
description: State what happened and what you expected.
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: steps
|
||||
attributes:
|
||||
label: Reproduction steps
|
||||
description: Give numbered steps starting from a fresh app or session.
|
||||
placeholder: |
|
||||
1. Go to ...
|
||||
2. Select ...
|
||||
3. Observe ...
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
id: build
|
||||
attributes:
|
||||
label: Build information
|
||||
description: >-
|
||||
Open User Settings, scroll to the bottom of the left sidebar, and select
|
||||
the build information. Fluxer copies it to the clipboard.
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
id: surface
|
||||
attributes:
|
||||
label: Affected surface
|
||||
multiple: true
|
||||
options:
|
||||
- Desktop app
|
||||
- Web app
|
||||
- Voice, video, or Go Live
|
||||
- Self-hosted instance
|
||||
- HTTP API or Gateway
|
||||
- Documentation site
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
id: instance
|
||||
attributes:
|
||||
label: Instance
|
||||
description: For a self-hosted instance, include the release tag and database backend.
|
||||
placeholder: fluxer.app
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: textarea
|
||||
id: evidence
|
||||
attributes:
|
||||
label: Evidence
|
||||
description: Attach relevant logs, screenshots or recordings. Remove tokens, keys, private messages and other personal data. Configuration files may contain secrets.
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: checkboxes
|
||||
id: checks
|
||||
attributes:
|
||||
label: Acknowledgements
|
||||
options:
|
||||
- label: I searched open and closed issues.
|
||||
required: true
|
||||
- label: I removed secrets and unrelated personal data from the report.
|
||||
required: true
|
||||
@@ -1,18 +0,0 @@
|
||||
# yaml-language-server: $schema=https://www.schemastore.org/github-issue-config.json
|
||||
blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name: Mobile client bugs
|
||||
url: https://github.com/fluxerapp/flutter_client#bug-reporting
|
||||
about: Read the reporting instructions for the Fluxer mobile client.
|
||||
- name: Account and billing support
|
||||
url: https://fluxer.app/help
|
||||
about: Find account help and support contact details.
|
||||
- name: Feature proposals
|
||||
url: https://github.com/orgs/fluxerapp/discussions
|
||||
about: Propose a feature in a discussion.
|
||||
- name: Translations
|
||||
url: https://weblate.fluxer.tools
|
||||
about: Improve an existing locale or start a new one.
|
||||
- name: Self-hosting support
|
||||
url: https://fluxer.dev
|
||||
about: Read the operator documentation, then open a discussion if the problem remains.
|
||||
@@ -1,44 +0,0 @@
|
||||
# yaml-language-server: $schema=https://www.schemastore.org/github-issue-forms.json
|
||||
name: Documentation
|
||||
description: Report incorrect, missing or unclear documentation.
|
||||
type: Task
|
||||
labels:
|
||||
- docs
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
This form covers <https://fluxer.dev> and operator documentation.
|
||||
|
||||
- type: textarea
|
||||
id: issue
|
||||
attributes:
|
||||
label: Documentation defect
|
||||
description: State what the page says and what is correct. For missing content, state what information you needed.
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
id: location
|
||||
attributes:
|
||||
label: Location
|
||||
description: Provide the page URL or file path and heading.
|
||||
placeholder: https://fluxer.dev/gateway/overview/
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: textarea
|
||||
id: suggestion
|
||||
attributes:
|
||||
label: Proposed wording
|
||||
description: Optional.
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: checkboxes
|
||||
id: checks
|
||||
attributes:
|
||||
label: Acknowledgements
|
||||
options:
|
||||
- label: I searched open and closed issues.
|
||||
required: true
|
||||
+2
-2
@@ -1,7 +1,7 @@
|
||||
# Security policy
|
||||
|
||||
Do not report a vulnerability in an issue, pull request, or discussion.
|
||||
Do not report a vulnerability in a pull request, on feedback.fluxer.com, in a Fluxer community, or in a direct message to staff.
|
||||
|
||||
Submit a report through [GitHub private vulnerability reporting](https://github.com/fluxerapp/fluxer/security/advisories/new) or email <security@fluxer.com>. Include the affected component, impact, reproduction steps, and supporting evidence. Remove unrelated personal data and secrets.
|
||||
Submit a report through <https://fluxer.app/security> or email <security@fluxer.com>. Include the affected component, impact, reproduction steps, and supporting evidence. Remove unrelated personal data and secrets.
|
||||
|
||||
The programme scope, testing rules, safe harbour, disclosure process, and reward terms are published at <https://fluxer.app/security>. That page is authoritative.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
Closes #
|
||||
Resolves https://feedback.fluxer.com/p/
|
||||
|
||||
<!-- Repeat this line for each resolved issue, up to 20. Remove the placeholder only if no issue is resolved and the approval gate does not apply. -->
|
||||
<!-- Repeat this line for each feedback.fluxer.com post this resolves, up to 20. Remove the placeholder only if no post is resolved and the approval gate does not apply. -->
|
||||
|
||||
## Summary
|
||||
|
||||
|
||||
@@ -154,6 +154,7 @@ jobs:
|
||||
BUILD_VERSION=${{ needs.meta.outputs.build_version }}
|
||||
SOURCE_SHA=${{ github.sha }}
|
||||
SOURCE_DATE=${{ steps.source.outputs.date }}
|
||||
FLUXER_SELF_HOSTED=true
|
||||
APP_ASSETS_REF=ghcr.io/${{ env.GHCR_OWNER }}/fluxer-app-proxy-self-hosted:${{ needs.meta.outputs.build_version }}-assets
|
||||
APP_ASSETS_PLATFORM=linux/amd64
|
||||
cache-from: type=registry,ref=ghcr.io/${{ env.GHCR_OWNER }}/fluxer-app-proxy-self-hosted:buildcache-${{ matrix.platform }}
|
||||
|
||||
@@ -200,6 +200,7 @@ jobs:
|
||||
tags: ghcr.io/${{ env.GHCR_OWNER }}/fluxer-app-proxy:${{ needs.meta.outputs.build_version }}-arm64
|
||||
build-args: |
|
||||
BUILD_VERSION=${{ needs.meta.outputs.build_version }}
|
||||
PUBLIC_ASSET_BASE_URL=https://fluxerstatic.com
|
||||
SOURCE_SHA=${{ github.sha }}
|
||||
SOURCE_DATE=${{ steps.source.outputs.date }}
|
||||
APP_ASSETS_REF=ghcr.io/${{ env.GHCR_OWNER }}/fluxer-app-proxy:${{ needs.meta.outputs.build_version }}-assets
|
||||
|
||||
@@ -103,11 +103,150 @@ jobs:
|
||||
--step set_matrix
|
||||
--skip-targets "${{ inputs.skip_targets }}"
|
||||
|
||||
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
|
||||
|
||||
build:
|
||||
name: Build ${{ matrix.platform }} (${{ matrix.arch }})
|
||||
needs:
|
||||
- meta
|
||||
- matrix
|
||||
- shared_assets
|
||||
runs-on: ${{ matrix.os }}
|
||||
environment: desktop-releases
|
||||
timeout-minutes: 180
|
||||
@@ -130,6 +269,7 @@ jobs:
|
||||
SOURCE_SHA: ${{ needs.meta.outputs.source_sha }}
|
||||
DESKTOP_PLATFORM: ${{ matrix.platform }}
|
||||
DESKTOP_ARCH: ${{ matrix.arch }}
|
||||
FLUXER_MODULES: "1"
|
||||
PLATFORM: ${{ matrix.platform }}
|
||||
ARCH: ${{ matrix.arch }}
|
||||
ELECTRON_ARCH: ${{ matrix.electron_arch }}
|
||||
@@ -276,6 +416,17 @@ jobs:
|
||||
cargo run --locked --quiet --manifest-path ${{ github.workspace }}/_ci/tools/ci/Cargo.toml -- build-desktop
|
||||
--step set_build_channel
|
||||
|
||||
- name: Download shared renderer artifact
|
||||
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093
|
||||
with:
|
||||
name: desktop-shared-${{ needs.meta.outputs.build_channel }}-${{ needs.meta.outputs.version }}-${{ needs.meta.outputs.source_sha }}
|
||||
path: source/desktop-shared-assets
|
||||
|
||||
- name: Restore shared renderer assets
|
||||
run: >-
|
||||
cargo run --locked --quiet --manifest-path ${{ github.workspace }}/_ci/tools/ci/Cargo.toml -- build-desktop
|
||||
--step restore_shared_assets
|
||||
|
||||
- name: Build Electron main process
|
||||
working-directory: ${{ env.WORKDIR }}/fluxer_desktop
|
||||
env:
|
||||
@@ -505,11 +656,13 @@ jobs:
|
||||
if: ${{ !cancelled() && needs.build.result == 'success' }}
|
||||
needs:
|
||||
- meta
|
||||
- shared_assets
|
||||
- build
|
||||
runs-on: ubuntu-24.04-arm
|
||||
environment: desktop-releases
|
||||
timeout-minutes: 180
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
env:
|
||||
CHANNEL: ${{ needs.meta.outputs.build_channel }}
|
||||
@@ -547,6 +700,17 @@ jobs:
|
||||
cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- build-desktop
|
||||
--step build_payload
|
||||
|
||||
- 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: Build desktop module manifest
|
||||
run: >-
|
||||
cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- build-desktop
|
||||
--step build_module_manifest
|
||||
|
||||
- name: Prepare GitHub release assets
|
||||
run: >-
|
||||
cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- build-desktop
|
||||
|
||||
@@ -44,6 +44,8 @@
|
||||
|
||||
/app-dist-output/
|
||||
/artifacts/
|
||||
/desktop-shared-assets/
|
||||
/desktop-modules/
|
||||
/s3_payload/
|
||||
/upload_staging/
|
||||
|
||||
|
||||
Generated
+35
-1
@@ -1710,6 +1710,16 @@ version = "0.2.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d"
|
||||
|
||||
[[package]]
|
||||
name = "filetime"
|
||||
version = "0.2.29"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "find-msvc-tools"
|
||||
version = "0.1.12"
|
||||
@@ -1741,6 +1751,7 @@ dependencies = [
|
||||
"aws-config",
|
||||
"aws-sdk-s3",
|
||||
"base64 0.23.1",
|
||||
"brotli",
|
||||
"bytes",
|
||||
"chrono",
|
||||
"clap",
|
||||
@@ -1750,6 +1761,7 @@ dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2 0.11.0",
|
||||
"tar",
|
||||
"tempfile",
|
||||
"tokio",
|
||||
"walkdir",
|
||||
@@ -1986,11 +1998,13 @@ dependencies = [
|
||||
"fluxer-svc",
|
||||
"fluxer_common",
|
||||
"futures",
|
||||
"hmac 0.13.0",
|
||||
"moka",
|
||||
"rmp-serde",
|
||||
"scylla",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2 0.11.0",
|
||||
"tokio",
|
||||
"tracing",
|
||||
]
|
||||
@@ -2040,7 +2054,6 @@ dependencies = [
|
||||
"hex",
|
||||
"rand 0.10.2",
|
||||
"reqwest",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2 0.11.0",
|
||||
"tokio",
|
||||
@@ -4788,6 +4801,17 @@ version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7b2093cf4c8eb1e67749a6762251bc9cd836b6fc171623bd0a9d324d37af2417"
|
||||
|
||||
[[package]]
|
||||
name = "tar"
|
||||
version = "0.4.46"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840"
|
||||
dependencies = [
|
||||
"filetime",
|
||||
"libc",
|
||||
"xattr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tempfile"
|
||||
version = "3.27.0"
|
||||
@@ -5816,6 +5840,16 @@ dependencies = [
|
||||
"tls_codec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "xattr"
|
||||
version = "1.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"rustix",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "xmlparser"
|
||||
version = "0.13.6"
|
||||
|
||||
@@ -23,6 +23,9 @@
|
||||
|
||||
# Fluxer
|
||||
|
||||
> [!IMPORTANT]
|
||||
> Bug reports and feature requests have moved to [feedback.fluxer.com](https://feedback.fluxer.com). Sign in with your Fluxer account to post, vote and follow updates. GitHub Issues and Discussions are closed. Report security vulnerabilities privately through [fluxer.app/security](https://fluxer.app/security).
|
||||
|
||||
Fluxer is a free and open source instant messaging and VoIP chat app built for friends, groups, and communities.
|
||||
|
||||
<p align="center">
|
||||
|
||||
Vendored
+1
@@ -89,6 +89,7 @@ FLUXER_ADMIN_OAUTH_REDIRECT_URI=http://localhost:8088/admin/oauth2_callback
|
||||
|
||||
FLUXER_SUDO_MODE_SECRET=dev-sudo-secret
|
||||
FLUXER_CONNECTION_INITIATION_SECRET=dev-connection-initiation-secret
|
||||
FLUXER_PROFILE_PSEUDONYM_SECRET=fluxer-dev-profile-pseudonym-secret
|
||||
FLUXER_VAPID_PUBLIC_KEY=BHIbdKs24FdPkOQS7hbeg3adceLS0IqlKsn71ywEe6kbeopeFFiG3lkvJac7BVqkuk7mxwEa555O2FXV3HLt56w
|
||||
FLUXER_VAPID_PRIVATE_KEY=cs24JvXSxHiqJQgkJNocJFAdzJpPmpfU9xD-fDpn3tw
|
||||
FLUXER_VAPID_EMAIL=dev@localhost
|
||||
|
||||
@@ -184,6 +184,7 @@ FLUXER_S3_SECRET_KEY=CHANGE_ME
|
||||
|
||||
FLUXER_SUDO_MODE_SECRET=CHANGE_ME
|
||||
FLUXER_CONNECTION_INITIATION_SECRET=CHANGE_ME
|
||||
FLUXER_PROFILE_PSEUDONYM_SECRET=CHANGE_ME
|
||||
FLUXER_GATEWAY_RPC_AUTH_TOKEN=CHANGE_ME
|
||||
FLUXER_ERLANG_COOKIE=CHANGE_ME
|
||||
FLUXER_MEDIA_PROXY_SECRET_KEY=CHANGE_ME
|
||||
@@ -218,6 +219,9 @@ FLUXER_VAPID_PRIVATE_KEY=CHANGE_ME
|
||||
#FLUXER_PUSH_SERVICE_FCM_BASE_URL=https://fcm.googleapis.com
|
||||
#FLUXER_PUSH_SERVICE_MANAGED_RELAY_HOSTS=push.fluxer.com
|
||||
#FLUXER_PUSH_SERVICE_OWN_RELAY_HOSTS=
|
||||
# Push hosts on your own network, such as a ntfy server, that may resolve to
|
||||
# private addresses. Comma separated.
|
||||
#FLUXER_PUSH_SERVICE_PRIVATE_HOSTS=ntfy.example.com
|
||||
#FLUXER_PUSH_SERVICE_RELAY_CONSENT_ACCEPTED=false
|
||||
|
||||
# Direct mobile push through your own APNs and FCM credentials, off by default.
|
||||
@@ -259,7 +263,7 @@ FLUXER_VAPID_PRIVATE_KEY=CHANGE_ME
|
||||
# only when a browser must reach an origin the defaults do not cover. Separate
|
||||
# several with spaces or commas. The three values below are illustrations.
|
||||
#FLUXER_CSP_EXTRA_DEFAULT_SRC=
|
||||
#FLUXER_CSP_EXTRA_CONNECT_SRC=wss://livekit.example.com:7881
|
||||
#FLUXER_CSP_EXTRA_CONNECT_SRC=wss://livekit.example.com
|
||||
#FLUXER_CSP_EXTRA_IMG_SRC=https://cdn.example.com
|
||||
#FLUXER_CSP_EXTRA_MEDIA_SRC=
|
||||
#FLUXER_CSP_EXTRA_FONT_SRC=
|
||||
@@ -306,6 +310,7 @@ FLUXER_KLIPY_API_KEY=
|
||||
# Hosts the api never unfurls, comma separated.
|
||||
#FLUXER_API_UNFURL_IGNORED_HOSTS=
|
||||
|
||||
# Email delivery. Only an instance where members sign in with email needs it.
|
||||
FLUXER_EMAIL_ENABLED=false
|
||||
FLUXER_EMAIL_PROVIDER=none
|
||||
FLUXER_EMAIL_FROM_EMAIL=[email protected]
|
||||
@@ -333,6 +338,10 @@ FLUXER_DISCOVERY_ENABLED=true
|
||||
#FLUXER_APP_STATUS_PAGE_URL=
|
||||
#FLUXER_APP_STATUS_PAGE_INCIDENT_HISTORY_URL=
|
||||
#FLUXER_INSTANCE_SETUP_CONFIGURED=false
|
||||
# How members sign in on a new instance, username or email. Unset means username. Read only on the first start.
|
||||
#FLUXER_ACCOUNT_IDENTITY=
|
||||
# Username tags on a new email instance. none gives unique names with no tag, random gives name#4821. Unset means none. A username instance always uses none. Read only on the first start.
|
||||
#FLUXER_TAG_STYLE=
|
||||
#FLUXER_AUTO_JOIN_INVITE_CODE=
|
||||
#FLUXER_DELETION_GRACE_PERIOD_HOURS=336
|
||||
|
||||
|
||||
@@ -152,6 +152,8 @@ x-fluxer-env: &fluxer-env
|
||||
FLUXER_APP_STATUS_PAGE_URL: ${FLUXER_APP_STATUS_PAGE_URL:-}
|
||||
FLUXER_APP_STATUS_PAGE_INCIDENT_HISTORY_URL: ${FLUXER_APP_STATUS_PAGE_INCIDENT_HISTORY_URL:-}
|
||||
FLUXER_INSTANCE_SETUP_CONFIGURED: ${FLUXER_INSTANCE_SETUP_CONFIGURED:-}
|
||||
FLUXER_ACCOUNT_IDENTITY: ${FLUXER_ACCOUNT_IDENTITY:-}
|
||||
FLUXER_TAG_STYLE: ${FLUXER_TAG_STYLE:-}
|
||||
FLUXER_AUTO_JOIN_INVITE_CODE: ${FLUXER_AUTO_JOIN_INVITE_CODE:-}
|
||||
FLUXER_DISCOVERY_ENABLED: ${FLUXER_DISCOVERY_ENABLED:-}
|
||||
FLUXER_DISCOVERY_MIN_MEMBER_COUNT: ${FLUXER_DISCOVERY_MIN_MEMBER_COUNT:-}
|
||||
@@ -175,6 +177,7 @@ x-fluxer-env: &fluxer-env
|
||||
|
||||
FLUXER_SUDO_MODE_SECRET: ${FLUXER_SUDO_MODE_SECRET:?set FLUXER_SUDO_MODE_SECRET in .env}
|
||||
FLUXER_CONNECTION_INITIATION_SECRET: ${FLUXER_CONNECTION_INITIATION_SECRET:?set FLUXER_CONNECTION_INITIATION_SECRET in .env}
|
||||
FLUXER_PROFILE_PSEUDONYM_SECRET: ${FLUXER_PROFILE_PSEUDONYM_SECRET:?set FLUXER_PROFILE_PSEUDONYM_SECRET in .env}
|
||||
FLUXER_SSO_ALLOW_PRIVATE_ADDRESSES: ${FLUXER_SSO_ALLOW_PRIVATE_ADDRESSES:-}
|
||||
FLUXER_VAPID_PUBLIC_KEY: ${FLUXER_VAPID_PUBLIC_KEY:?set FLUXER_VAPID_PUBLIC_KEY in .env}
|
||||
FLUXER_VAPID_PRIVATE_KEY: ${FLUXER_VAPID_PRIVATE_KEY:?set FLUXER_VAPID_PRIVATE_KEY in .env}
|
||||
@@ -612,6 +615,7 @@ services:
|
||||
FLUXER_PUSH_SERVICE_FCM_BASE_URL: ${FLUXER_PUSH_SERVICE_FCM_BASE_URL:-}
|
||||
FLUXER_PUSH_SERVICE_MANAGED_RELAY_HOSTS: ${FLUXER_PUSH_SERVICE_MANAGED_RELAY_HOSTS:-}
|
||||
FLUXER_PUSH_SERVICE_OWN_RELAY_HOSTS: ${FLUXER_PUSH_SERVICE_OWN_RELAY_HOSTS:-}
|
||||
FLUXER_PUSH_SERVICE_PRIVATE_HOSTS: ${FLUXER_PUSH_SERVICE_PRIVATE_HOSTS:-}
|
||||
FLUXER_PUSH_SERVICE_RELAY_CONSENT_ACCEPTED: ${FLUXER_PUSH_SERVICE_RELAY_CONSENT_ACCEPTED:-}
|
||||
FLUXER_PUSH_APNS_DEFAULT_ENVIRONMENT: ${FLUXER_PUSH_APNS_DEFAULT_ENVIRONMENT:-}
|
||||
FLUXER_PUSH_FCM_ENABLED: ${FLUXER_PUSH_FCM_ENABLED:-}
|
||||
@@ -667,6 +671,7 @@ services:
|
||||
FLUXER_S3_SECRET_ACCESS_KEY: ${FLUXER_S3_SECRET_KEY:?set FLUXER_S3_SECRET_KEY in .env}
|
||||
FLUXER_S3_BUCKET_UPLOADS: ${FLUXER_S3_BUCKET_UPLOADS:-}
|
||||
FLUXER_STATIC_CDN_ENDPOINT: ${FLUXER_STATIC_CDN_ENDPOINT:-}
|
||||
FLUXER_MEDIA_ENDPOINT: ${FLUXER_MEDIA_ENDPOINT:-}
|
||||
DISCOVERY_UPSTREAM_URL: http://edge:8088/.well-known/fluxer
|
||||
DISCOVERY_REFRESH_INTERVAL_MS: ${DISCOVERY_REFRESH_INTERVAL_MS:-}
|
||||
PUBLIC_BOOTSTRAP_API_ENDPOINT: /api
|
||||
|
||||
+931
-150
File diff suppressed because it is too large
Load Diff
@@ -76,6 +76,8 @@ pub const REPORT_VIEW: &str = "report:view";
|
||||
pub const REPORT_VIEW_REPORTER_PII: &str = "report:view:reporter_pii";
|
||||
pub const SYSTEM_DM_SEND: &str = "system_dm:send";
|
||||
pub const USER_CANCEL_BULK_MESSAGE_DELETION: &str = "user:cancel:bulk_message_deletion";
|
||||
pub const USER_CREATE_PASSWORD_RESET_LINK: &str = "user:create:password_reset_link";
|
||||
pub const USER_DELETE_RECOVERY_KIT: &str = "user:delete:recovery_kit";
|
||||
pub const USER_DELETE: &str = "user:delete";
|
||||
pub const USER_LIST_DM_CHANNELS: &str = "user:list:dm_channels";
|
||||
pub const USER_LIST_GUILDS: &str = "user:list:guilds";
|
||||
@@ -180,6 +182,8 @@ pub const ALL_ACLS: &[&str] = &[
|
||||
REPORT_VIEW_REPORTER_PII,
|
||||
SYSTEM_DM_SEND,
|
||||
USER_CANCEL_BULK_MESSAGE_DELETION,
|
||||
USER_CREATE_PASSWORD_RESET_LINK,
|
||||
USER_DELETE_RECOVERY_KIT,
|
||||
USER_DELETE,
|
||||
USER_LIST_DM_CHANNELS,
|
||||
USER_LIST_GUILDS,
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use crate::api::generated::snowflake;
|
||||
|
||||
use super::client::{AdminApiClient, ApiResult};
|
||||
use super::types::ListGuildThreadsResponse;
|
||||
|
||||
impl AdminApiClient {
|
||||
pub async fn list_guild_threads(&self, guild_id: &str) -> ApiResult<ListGuildThreadsResponse> {
|
||||
let response = self
|
||||
.generated()
|
||||
.list_admin_guild_threads(&snowflake(guild_id))
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
self.generated_value(response.into_inner())
|
||||
}
|
||||
|
||||
pub async fn delete_thread_channel(&self, channel_id: &str) -> ApiResult<()> {
|
||||
self.generated()
|
||||
.delete_admin_thread_channel(&snowflake(channel_id))
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -2,9 +2,9 @@
|
||||
|
||||
use super::client::{AdminApiClient, ApiResult};
|
||||
use super::types::{
|
||||
CreateRegistrationUrlRequest, CreateRegistrationUrlResponse, InstanceConfigResponse,
|
||||
InstanceConfigUpdateRequest, InstanceEmailSmtpTestRequest, InstanceEmailSmtpTestResponse,
|
||||
InstancePremiumDiscovery,
|
||||
AccountIdentitySettings, CreateRegistrationUrlRequest, CreateRegistrationUrlResponse,
|
||||
InstanceAccountIdentityDiscovery, InstanceConfigResponse, InstanceConfigUpdateRequest,
|
||||
InstanceEmailSmtpTestRequest, InstanceEmailSmtpTestResponse, InstancePremiumDiscovery,
|
||||
};
|
||||
|
||||
impl AdminApiClient {
|
||||
@@ -16,6 +16,16 @@ impl AdminApiClient {
|
||||
self.get("/.well-known/fluxer", None).await
|
||||
}
|
||||
|
||||
pub async fn get_instance_account_identity(&self) -> ApiResult<AccountIdentitySettings> {
|
||||
let discovery: InstanceAccountIdentityDiscovery =
|
||||
self.get("/.well-known/fluxer", None).await?;
|
||||
let mode = discovery.features.account_identity;
|
||||
Ok(AccountIdentitySettings {
|
||||
mode,
|
||||
tag_style: discovery.features.tag_style,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn update_instance_config(
|
||||
&self,
|
||||
update: &InstanceConfigUpdateRequest,
|
||||
|
||||
@@ -13,6 +13,7 @@ pub mod client;
|
||||
pub mod codes;
|
||||
pub mod discovery;
|
||||
pub mod guild_assets;
|
||||
pub mod guild_threads;
|
||||
pub mod guilds;
|
||||
pub mod instance_config;
|
||||
pub mod jobs;
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
pub struct GuildThreadMetadata {
|
||||
pub archived: bool,
|
||||
pub locked: bool,
|
||||
pub auto_archive_duration: i32,
|
||||
pub archive_timestamp: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
pub struct GuildThreadItem {
|
||||
pub id: String,
|
||||
#[serde(rename = "type")]
|
||||
pub channel_type: i32,
|
||||
#[serde(default)]
|
||||
pub name: Option<String>,
|
||||
#[serde(default)]
|
||||
pub parent_id: Option<String>,
|
||||
#[serde(default)]
|
||||
pub owner_id: Option<String>,
|
||||
#[serde(default)]
|
||||
pub member_count: Option<i32>,
|
||||
#[serde(default)]
|
||||
pub message_count: Option<i32>,
|
||||
#[serde(default)]
|
||||
pub thread_metadata: Option<GuildThreadMetadata>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
pub struct ListGuildThreadsResponse {
|
||||
pub threads: Vec<GuildThreadItem>,
|
||||
}
|
||||
@@ -13,6 +13,8 @@ pub struct InstanceConfigResponse {
|
||||
#[serde(default)]
|
||||
pub self_hosted: bool,
|
||||
#[serde(default)]
|
||||
pub account_identity: AccountIdentityConfigResponse,
|
||||
#[serde(default)]
|
||||
pub app_public: AppPublicConfigResponse,
|
||||
#[serde(default)]
|
||||
pub policy: InstancePolicyResponse,
|
||||
@@ -29,11 +31,86 @@ pub struct InstanceConfigResponse {
|
||||
#[serde(default)]
|
||||
pub captcha: CaptchaConfigResponse,
|
||||
#[serde(default)]
|
||||
pub channel_threads: ChannelThreadsConfigResponse,
|
||||
#[serde(default)]
|
||||
pub experiment_delivery: ExperimentDeliveryConfigResponse,
|
||||
#[serde(default)]
|
||||
pub billing: InstanceBillingResponse,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum AccountIdentityMode {
|
||||
#[default]
|
||||
Email,
|
||||
Username,
|
||||
}
|
||||
|
||||
impl AccountIdentityMode {
|
||||
pub fn is_username(self) -> bool {
|
||||
matches!(self, Self::Username)
|
||||
}
|
||||
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
Self::Email => "Email",
|
||||
Self::Username => "Username",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, Deserialize, Serialize, Eq, PartialEq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum TagStyle {
|
||||
None,
|
||||
#[default]
|
||||
#[serde(other)]
|
||||
Random,
|
||||
}
|
||||
|
||||
impl TagStyle {
|
||||
pub fn is_none(self) -> bool {
|
||||
matches!(self, Self::None)
|
||||
}
|
||||
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
Self::None => "No tags",
|
||||
Self::Random => "Random tags",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, Deserialize, Serialize)]
|
||||
pub struct AccountIdentityConfigResponse {
|
||||
#[serde(default)]
|
||||
pub mode: AccountIdentityMode,
|
||||
#[serde(default)]
|
||||
pub locked: Option<bool>,
|
||||
#[serde(default)]
|
||||
pub tag_style: TagStyle,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
|
||||
pub struct AccountIdentitySettings {
|
||||
pub mode: AccountIdentityMode,
|
||||
pub tag_style: TagStyle,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Deserialize)]
|
||||
pub struct InstanceAccountIdentityDiscovery {
|
||||
#[serde(default)]
|
||||
pub features: InstanceAccountIdentityDiscoveryFeatures,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Deserialize)]
|
||||
pub struct InstanceAccountIdentityDiscoveryFeatures {
|
||||
#[serde(default)]
|
||||
pub account_identity: AccountIdentityMode,
|
||||
#[serde(default)]
|
||||
pub tag_style: TagStyle,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
pub struct InstancePolicyResponse {
|
||||
#[serde(default)]
|
||||
@@ -435,6 +512,8 @@ 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)]
|
||||
@@ -578,6 +657,62 @@ 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 {
|
||||
@@ -700,6 +835,8 @@ pub struct InstanceConfigUpdateRequest {
|
||||
#[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>,
|
||||
|
||||
@@ -9,6 +9,7 @@ mod codes;
|
||||
mod common;
|
||||
mod discovery;
|
||||
mod guild_assets;
|
||||
mod guild_threads;
|
||||
mod instance_billing;
|
||||
mod instance_config;
|
||||
mod jobs;
|
||||
@@ -29,6 +30,7 @@ pub use codes::*;
|
||||
pub use common::*;
|
||||
pub use discovery::*;
|
||||
pub use guild_assets::*;
|
||||
pub use guild_threads::*;
|
||||
pub use instance_billing::*;
|
||||
pub use instance_config::*;
|
||||
pub use jobs::*;
|
||||
|
||||
@@ -232,3 +232,9 @@ pub struct WebAuthnCredential {
|
||||
}
|
||||
|
||||
pub type WebAuthnCredentialListResponse = Vec<WebAuthnCredential>;
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
pub struct PasswordResetLinkResponse {
|
||||
pub url: String,
|
||||
pub expires_at: String,
|
||||
}
|
||||
|
||||
@@ -5,7 +5,8 @@ use crate::api::generated::{snowflake, types as generated_types};
|
||||
use super::client::{AdminApiClient, ApiError, ApiResult};
|
||||
use super::types::{
|
||||
AdminUser, AdminUserMeResponse, GuildInfo, ListUserGuildsResponse, LookupUserResponse,
|
||||
SearchUsersResponse, TerminateSessionsResponse, UserMutationResponse,
|
||||
PasswordResetLinkResponse, SearchUsersResponse, TerminateSessionsResponse,
|
||||
UserMutationResponse,
|
||||
};
|
||||
|
||||
impl AdminApiClient {
|
||||
@@ -473,6 +474,26 @@ impl AdminApiClient {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn create_password_reset_link(
|
||||
&self,
|
||||
user_id: &str,
|
||||
) -> ApiResult<PasswordResetLinkResponse> {
|
||||
let response = self
|
||||
.generated()
|
||||
.create_admin_user_password_reset_link(&snowflake(user_id))
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
self.generated_value(response.into_inner())
|
||||
}
|
||||
|
||||
pub async fn revoke_recovery_kit(&self, user_id: &str) -> ApiResult<()> {
|
||||
self.generated()
|
||||
.revoke_admin_user_recovery_kit(&snowflake(user_id))
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn remove_relationship(
|
||||
&self,
|
||||
user_id: &str,
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use crate::{
|
||||
api::client::AdminApiClient, middleware::auth::AuthContext, state::AppState,
|
||||
utils::user_tag::with_unique_usernames,
|
||||
};
|
||||
use axum::{
|
||||
extract::{Request, State},
|
||||
middleware::Next,
|
||||
response::Response,
|
||||
};
|
||||
|
||||
pub async fn scope_account_identity(
|
||||
State(state): State<AppState>,
|
||||
request: Request,
|
||||
next: Next,
|
||||
) -> Response {
|
||||
let Some(auth) = request.extensions().get::<AuthContext>() else {
|
||||
return next.run(request).await;
|
||||
};
|
||||
let client = AdminApiClient::new(state.http_client(), state.config(), &auth.session);
|
||||
let settings = state.account_identity_settings(&client).await;
|
||||
let unique_usernames = settings.mode.is_username() || settings.tag_style.is_none();
|
||||
with_unique_usernames(unique_usernames, next.run(request)).await
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
pub mod account_identity;
|
||||
pub mod auth;
|
||||
pub mod csrf;
|
||||
pub mod error_handler;
|
||||
|
||||
@@ -48,17 +48,41 @@ pub fn router() -> Router<AppState> {
|
||||
)
|
||||
}
|
||||
|
||||
fn render_ban_page(state: &AppState, auth: &AuthContext, key: &str, req: &Request) -> Response {
|
||||
async fn render_ban_page(
|
||||
state: &AppState,
|
||||
auth: &AuthContext,
|
||||
key: &str,
|
||||
csrf_token: String,
|
||||
) -> Response {
|
||||
let config = state.config();
|
||||
let ban_cfg = match templates::pages::bans::get_ban_config(key) {
|
||||
Some(c) => c,
|
||||
None => return axum::http::StatusCode::NOT_FOUND.into_response(),
|
||||
};
|
||||
let csrf_token = csrf::get_csrf_token(req);
|
||||
let markup = templates::pages::bans::bans_page(config, auth, ban_cfg, None, &csrf_token);
|
||||
let username_sign_in = email_bans_on_username_instance(state, auth, key).await;
|
||||
let markup = templates::pages::bans::bans_page(
|
||||
config,
|
||||
auth,
|
||||
ban_cfg,
|
||||
None,
|
||||
&csrf_token,
|
||||
username_sign_in,
|
||||
);
|
||||
Html(markup.into_string()).into_response()
|
||||
}
|
||||
|
||||
async fn email_bans_on_username_instance(state: &AppState, auth: &AuthContext, key: &str) -> bool {
|
||||
key == "email-bans"
|
||||
&& state
|
||||
.account_identity(&AdminApiClient::new(
|
||||
state.http_client(),
|
||||
state.config(),
|
||||
&auth.session,
|
||||
))
|
||||
.await
|
||||
.is_username()
|
||||
}
|
||||
|
||||
macro_rules! ban_get {
|
||||
($name:ident, $key:expr) => {
|
||||
async fn $name(
|
||||
@@ -66,7 +90,8 @@ macro_rules! ban_get {
|
||||
auth: axum::Extension<AuthContext>,
|
||||
request: Request,
|
||||
) -> Response {
|
||||
render_ban_page(&state, &auth.0, $key, &request)
|
||||
let csrf_token = csrf::get_csrf_token(&request);
|
||||
render_ban_page(&state, &auth.0, $key, csrf_token).await
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -96,7 +121,17 @@ async fn generic_ban_post(
|
||||
let value = extract_value(form, ban_cfg.input_name);
|
||||
let is_htmx = htmx::is_htmx_request(headers);
|
||||
let (level, msg) = execute_ban(&client, ban_key, action, &value, form).await;
|
||||
flash_response(config, auth, is_htmx, level, &msg, ban_cfg, csrf_token)
|
||||
let username_sign_in = !is_htmx && email_bans_on_username_instance(state, auth, ban_key).await;
|
||||
flash_response(
|
||||
config,
|
||||
auth,
|
||||
is_htmx,
|
||||
level,
|
||||
&msg,
|
||||
ban_cfg,
|
||||
csrf_token,
|
||||
username_sign_in,
|
||||
)
|
||||
}
|
||||
|
||||
macro_rules! ban_post {
|
||||
@@ -114,14 +149,18 @@ macro_rules! ban_post {
|
||||
let form: BanFormData = match Form::from_request(request, &state).await {
|
||||
Ok(Form(f)) => f,
|
||||
Err(_) => {
|
||||
let is_htmx = htmx::is_htmx_request(&headers);
|
||||
let username_sign_in =
|
||||
!is_htmx && email_bans_on_username_instance(&state, &auth.0, $key).await;
|
||||
return flash_response(
|
||||
state.config(),
|
||||
&auth.0,
|
||||
htmx::is_htmx_request(&headers),
|
||||
is_htmx,
|
||||
"error",
|
||||
"Invalid form data",
|
||||
templates::pages::bans::get_ban_config($key).unwrap(),
|
||||
&csrf_token,
|
||||
username_sign_in,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -226,6 +226,7 @@ fn ban_action_result(
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn flash_response(
|
||||
config: &crate::config::AdminConfig,
|
||||
auth: &AuthContext,
|
||||
@@ -234,13 +235,20 @@ pub fn flash_response(
|
||||
message: &str,
|
||||
ban_cfg: &templates::pages::bans::BanConfig,
|
||||
csrf_token: &str,
|
||||
username_sign_in: bool,
|
||||
) -> Response {
|
||||
if is_htmx {
|
||||
render_inline_flash(level, message)
|
||||
} else {
|
||||
let flash = to_flash(level, message);
|
||||
let markup =
|
||||
templates::pages::bans::bans_page(config, auth, ban_cfg, Some(&flash), csrf_token);
|
||||
let markup = templates::pages::bans::bans_page(
|
||||
config,
|
||||
auth,
|
||||
ban_cfg,
|
||||
Some(&flash),
|
||||
csrf_token,
|
||||
username_sign_in,
|
||||
);
|
||||
Html(markup.into_string()).into_response()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -96,6 +96,33 @@ pub async fn render(
|
||||
config, &guild, &stickers, csrf_token,
|
||||
))
|
||||
}
|
||||
"threads" => {
|
||||
if !acl::has_permission(admin_acls, acl::GUILD_LOOKUP) {
|
||||
return None;
|
||||
}
|
||||
let threads = client
|
||||
.list_guild_threads(guild_id)
|
||||
.await
|
||||
.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,
|
||||
))
|
||||
}
|
||||
"audit_log" | "audit-log" => {
|
||||
if !acl::has_permission(admin_acls, acl::GUILD_AUDIT_LOG_VIEW) {
|
||||
return None;
|
||||
|
||||
@@ -134,6 +134,7 @@ async fn guild_detail(
|
||||
.as_ref()
|
||||
.map(|user| user.acls.as_slice())
|
||||
.unwrap_or(&[]);
|
||||
let username_sign_in = state.account_identity(&client).await.is_username();
|
||||
let tab_body = if let Some(guild) = guild.as_ref() {
|
||||
guild_tabs::render(
|
||||
&client,
|
||||
@@ -152,6 +153,7 @@ async fn guild_detail(
|
||||
active_tab,
|
||||
&csrf_token,
|
||||
admin_acls,
|
||||
username_sign_in,
|
||||
))
|
||||
})
|
||||
} else {
|
||||
@@ -166,6 +168,7 @@ async fn guild_detail(
|
||||
active_tab,
|
||||
tab_body,
|
||||
is_detail_fragment,
|
||||
username_sign_in,
|
||||
);
|
||||
Html(markup.into_string()).into_response()
|
||||
}
|
||||
@@ -430,6 +433,16 @@ async fn dispatch_guild_action(
|
||||
"Failed to delete sticker",
|
||||
)
|
||||
}
|
||||
"delete_thread" => {
|
||||
let Some(thread_id) = get("thread_id") else {
|
||||
return FlashData::error("Thread ID is required");
|
||||
};
|
||||
action_result(
|
||||
client.delete_thread_channel(&thread_id).await,
|
||||
"Thread deleted",
|
||||
"Failed to delete thread",
|
||||
)
|
||||
}
|
||||
"trigger_archive" => {
|
||||
let inc = form.bool_value("include_attachments");
|
||||
action_result(
|
||||
@@ -508,6 +521,7 @@ async fn guild_tab(
|
||||
normalize_guild_tab(&tab),
|
||||
&csrf_token,
|
||||
admin_acls,
|
||||
state.account_identity(&client).await.is_username(),
|
||||
),
|
||||
None => maud::html! {
|
||||
div class="p-4 text-red-600 text-sm" {
|
||||
|
||||
@@ -201,6 +201,13 @@ async fn bulk_actions_page(
|
||||
csrf: axum::Extension<CsrfToken>,
|
||||
) -> Response {
|
||||
let config = state.config();
|
||||
let markup = templates::pages::bulk_actions::bulk_actions_page(config, &auth.0, &csrf.0.0);
|
||||
let client = AdminApiClient::new(state.http_client(), config, &auth.0.session);
|
||||
let account_identity = state.account_identity(&client).await;
|
||||
let markup = templates::pages::bulk_actions::bulk_actions_page(
|
||||
config,
|
||||
&auth.0,
|
||||
&csrf.0.0,
|
||||
account_identity.is_username(),
|
||||
);
|
||||
Html(markup.into_string()).into_response()
|
||||
}
|
||||
|
||||
@@ -73,6 +73,10 @@ pub fn build_router(config: AdminConfig) -> Router {
|
||||
.merge(admin::router())
|
||||
.route("/", get(dashboard))
|
||||
.route("/dashboard", get(dashboard))
|
||||
.layer(from_fn_with_state(
|
||||
state.clone(),
|
||||
middleware::account_identity::scope_account_identity,
|
||||
))
|
||||
.layer(from_fn(middleware::htmx::flash_redirect_to_toast))
|
||||
.layer(from_fn_with_state(
|
||||
state.clone(),
|
||||
|
||||
@@ -7,19 +7,20 @@ use crate::{
|
||||
AppBrandingConfigUpdateRequest, AppLegalConfigUpdateRequest,
|
||||
AppPublicConfigUpdateRequest, AppRegistrationConfigUpdateRequest,
|
||||
AppSetupConfigUpdateRequest, CAPTCHA_COST_RANGE, CAPTCHA_MAX_COUNTER_RANGE,
|
||||
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, PlutoniumPageConfigUpdateRequest, PremiumMode,
|
||||
PushRelayConfigUpdateRequest, RegistrationMode, SsoConfigUpdateRequest, VoiceE2eeScope,
|
||||
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,
|
||||
},
|
||||
},
|
||||
config::AdminConfig,
|
||||
@@ -228,6 +229,10 @@ pub async fn instance_config_post(
|
||||
Ok(update) => instance_config_result(client.update_instance_config(&update).await),
|
||||
Err(message) => FlashData::error(message),
|
||||
},
|
||||
"update_channel_threads" => match build_channel_threads_update(&form) {
|
||||
Ok(update) => instance_config_result(client.update_instance_config(&update).await),
|
||||
Err(message) => FlashData::error(message),
|
||||
},
|
||||
"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),
|
||||
@@ -671,6 +676,53 @@ fn build_captcha_update(form: &MultiValueForm) -> Result<InstanceConfigUpdateReq
|
||||
})
|
||||
}
|
||||
|
||||
fn build_channel_threads_update(
|
||||
form: &MultiValueForm,
|
||||
) -> Result<InstanceConfigUpdateRequest, String> {
|
||||
Ok(InstanceConfigUpdateRequest {
|
||||
channel_threads: Some(ChannelThreadsConfigUpdateRequest {
|
||||
enabled: Some(form.bool_value("channel_threads_enabled")),
|
||||
guild_basis_points: parse_form_number(
|
||||
form,
|
||||
"channel_threads_guild_basis_points",
|
||||
"Guild rollout basis points",
|
||||
0,
|
||||
EXPERIMENT_ROLLOUT_BASIS_POINTS_MAX,
|
||||
)?,
|
||||
guild_salt: parse_experiment_rollout_salt(form, "channel_threads_guild_salt")?,
|
||||
enabled_guild_ids: Some(parse_experiment_user_ids(
|
||||
form.first("channel_threads_enabled_guild_ids")
|
||||
.unwrap_or_default(),
|
||||
"Enabled guild IDs",
|
||||
)?),
|
||||
disabled_guild_ids: Some(parse_experiment_user_ids(
|
||||
form.first("channel_threads_disabled_guild_ids")
|
||||
.unwrap_or_default(),
|
||||
"Disabled guild IDs",
|
||||
)?),
|
||||
user_basis_points: parse_form_number(
|
||||
form,
|
||||
"channel_threads_user_basis_points",
|
||||
"User rollout basis points",
|
||||
0,
|
||||
EXPERIMENT_ROLLOUT_BASIS_POINTS_MAX,
|
||||
)?,
|
||||
user_salt: parse_experiment_rollout_salt(form, "channel_threads_user_salt")?,
|
||||
included_user_ids: Some(parse_experiment_user_ids(
|
||||
form.first("channel_threads_included_user_ids")
|
||||
.unwrap_or_default(),
|
||||
"Included user IDs",
|
||||
)?),
|
||||
excluded_user_ids: Some(parse_experiment_user_ids(
|
||||
form.first("channel_threads_excluded_user_ids")
|
||||
.unwrap_or_default(),
|
||||
"Excluded user IDs",
|
||||
)?),
|
||||
}),
|
||||
..Default::default()
|
||||
})
|
||||
}
|
||||
|
||||
fn build_experiment_delivery_update(
|
||||
form: &MultiValueForm,
|
||||
) -> Result<InstanceConfigUpdateRequest, String> {
|
||||
@@ -838,7 +890,9 @@ fn build_integrations_update(form: &MultiValueForm) -> InstanceConfigUpdateReque
|
||||
youtube: Some(InstanceYoutubeIntegrationUpdateRequest {
|
||||
api_key: clean("integration_youtube_api_key"),
|
||||
}),
|
||||
email: Some(InstanceEmailIntegrationUpdateRequest {
|
||||
email: (form.has_key_starting_with("integration_email_")
|
||||
|| form.has_key_starting_with("integration_smtp_"))
|
||||
.then(|| InstanceEmailIntegrationUpdateRequest {
|
||||
enabled: Some(form.bool_value("integration_email_enabled")),
|
||||
provider: Some("smtp".to_owned()),
|
||||
from_email: clean("integration_email_from_email"),
|
||||
@@ -1195,6 +1249,37 @@ pub async fn limit_config_post(
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn build_integrations_update_leaves_email_alone_when_its_fields_are_hidden() {
|
||||
let hidden = build_integrations_update(&MultiValueForm::parse(
|
||||
b"integration_klipy_api_key=&integration_youtube_api_key=",
|
||||
));
|
||||
let integrations = hidden.integrations.expect("integrations update");
|
||||
assert!(integrations.email.is_none());
|
||||
assert!(integrations.gif.is_some());
|
||||
|
||||
let shown = build_integrations_update(&MultiValueForm::parse(
|
||||
b"integration_email_present=1&integration_smtp_host=smtp.example.com",
|
||||
));
|
||||
let email = shown
|
||||
.integrations
|
||||
.and_then(|integrations| integrations.email)
|
||||
.expect("email update");
|
||||
assert_eq!(email.enabled, Some(false));
|
||||
|
||||
let from_an_older_page = build_integrations_update(&MultiValueForm::parse(
|
||||
b"integration_klipy_api_key=&integration_smtp_host=smtp.example.com",
|
||||
));
|
||||
let email = from_an_older_page
|
||||
.integrations
|
||||
.and_then(|integrations| integrations.email)
|
||||
.expect("email update from a page without the presence marker");
|
||||
assert_eq!(
|
||||
email.smtp.and_then(|smtp| smtp.host).as_deref(),
|
||||
Some("smtp.example.com")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_sso_update_keeps_repeated_allowed_domains() {
|
||||
let form = MultiValueForm::parse(
|
||||
@@ -1403,6 +1488,111 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_channel_threads_update_reads_both_rollout_dimensions() {
|
||||
let form = MultiValueForm::parse(
|
||||
b"channel_threads_enabled=true&channel_threads_guild_basis_points=%2010%20&channel_threads_guild_salt=%20channel-threads-guild-v2%20&channel_threads_enabled_guild_ids=1600000000000000001%0A1600000000000000002%0A1600000000000000001&channel_threads_disabled_guild_ids=1600000000000000003&channel_threads_user_basis_points=10000&channel_threads_user_salt=channel-threads-user-v2&channel_threads_included_user_ids=1500000000000000001&channel_threads_excluded_user_ids=1500000000000000003%2C%201500000000000000004",
|
||||
);
|
||||
let update = build_channel_threads_update(&form)
|
||||
.expect("valid form")
|
||||
.channel_threads
|
||||
.expect("channel threads update");
|
||||
assert_eq!(update.enabled, Some(true));
|
||||
assert_eq!(update.guild_basis_points, Some(10));
|
||||
assert_eq!(
|
||||
update.guild_salt,
|
||||
Some("channel-threads-guild-v2".to_owned())
|
||||
);
|
||||
assert_eq!(
|
||||
update.enabled_guild_ids,
|
||||
Some(vec![
|
||||
"1600000000000000001".to_owned(),
|
||||
"1600000000000000002".to_owned()
|
||||
])
|
||||
);
|
||||
assert_eq!(
|
||||
update.disabled_guild_ids,
|
||||
Some(vec!["1600000000000000003".to_owned()])
|
||||
);
|
||||
assert_eq!(update.user_basis_points, Some(10000));
|
||||
assert_eq!(update.user_salt, Some("channel-threads-user-v2".to_owned()));
|
||||
assert_eq!(
|
||||
update.included_user_ids,
|
||||
Some(vec!["1500000000000000001".to_owned()])
|
||||
);
|
||||
assert_eq!(
|
||||
update.excluded_user_ids,
|
||||
Some(vec![
|
||||
"1500000000000000003".to_owned(),
|
||||
"1500000000000000004".to_owned()
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_channel_threads_update_leaves_the_experiment_off_when_nothing_is_submitted() {
|
||||
let form = MultiValueForm::parse(b"_csrf=token");
|
||||
let request = build_channel_threads_update(&form).expect("valid form");
|
||||
assert_eq!(
|
||||
serde_json::to_value(request).expect("serializable update"),
|
||||
serde_json::json!({"channel_threads": {
|
||||
"enabled": false,
|
||||
"enabled_guild_ids": [],
|
||||
"disabled_guild_ids": [],
|
||||
"included_user_ids": [],
|
||||
"excluded_user_ids": [],
|
||||
}})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_channel_threads_update_rejects_invalid_targeting() {
|
||||
let too_many_guilds = (0..=EXPERIMENT_MAX_TARGETED_USERS)
|
||||
.map(|index| index.to_string())
|
||||
.collect::<Vec<_>>()
|
||||
.join("%2C");
|
||||
for (form, message) in [
|
||||
(
|
||||
"channel_threads_guild_basis_points=10001".to_owned(),
|
||||
"Guild rollout basis points must be a whole number between 0 and 10000",
|
||||
),
|
||||
(
|
||||
"channel_threads_user_basis_points=-1".to_owned(),
|
||||
"User rollout basis points must be a whole number between 0 and 10000",
|
||||
),
|
||||
(
|
||||
"channel_threads_guild_salt=%20%20".to_owned(),
|
||||
"Rollout salt must be between 1 and 64 characters",
|
||||
),
|
||||
(
|
||||
"channel_threads_user_salt=caf%C3%A9".to_owned(),
|
||||
"Rollout salt must use printable ASCII",
|
||||
),
|
||||
(
|
||||
"channel_threads_enabled_guild_ids=123%2Cinvalid".to_owned(),
|
||||
"Enabled guild IDs entry 2 must contain 1 to 20 decimal digits",
|
||||
),
|
||||
(
|
||||
"channel_threads_disabled_guild_ids=123456789012345678901".to_owned(),
|
||||
"Disabled guild IDs entry 1 must contain 1 to 20 decimal digits",
|
||||
),
|
||||
(
|
||||
"channel_threads_excluded_user_ids=abc".to_owned(),
|
||||
"Excluded user IDs entry 1 must contain 1 to 20 decimal digits",
|
||||
),
|
||||
(
|
||||
format!("channel_threads_enabled_guild_ids={too_many_guilds}"),
|
||||
"Enabled guild IDs must contain at most 1000 unique IDs",
|
||||
),
|
||||
] {
|
||||
let form = MultiValueForm::parse(form.as_bytes());
|
||||
assert_eq!(
|
||||
build_channel_threads_update(&form).expect_err("invalid targeting"),
|
||||
message
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_push_relay_update_reads_the_consent_checkbox() {
|
||||
let unchecked = build_push_relay_update(&MultiValueForm::parse(b"_csrf=token"));
|
||||
|
||||
@@ -367,6 +367,11 @@ pub async fn dispatch(
|
||||
"Password reset sent successfully",
|
||||
"Failed to send password reset",
|
||||
),
|
||||
"revoke_recovery_kit" => DispatchOutcome::from_result(
|
||||
client.revoke_recovery_kit(user_id).await,
|
||||
"Recovery kit revoked",
|
||||
"Failed to revoke recovery kit",
|
||||
),
|
||||
"remove_relationship" => {
|
||||
let Some(target_id) = get("target_user_id").or_else(|| get("target_id")) else {
|
||||
return DispatchOutcome::error("Target user ID is required");
|
||||
|
||||
@@ -5,6 +5,7 @@ use crate::{
|
||||
api::{
|
||||
audit::SearchAuditLogsParams,
|
||||
client::{AdminApiClient, ApiResultExt},
|
||||
types::AccountIdentityMode,
|
||||
},
|
||||
config::AdminConfig,
|
||||
templates::{
|
||||
@@ -26,6 +27,7 @@ pub struct TabQuery {
|
||||
pub delete_all_messages_message_count: Option<u64>,
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn render(
|
||||
client: &AdminApiClient,
|
||||
config: &AdminConfig,
|
||||
@@ -34,6 +36,7 @@ pub async fn render(
|
||||
tab: &str,
|
||||
query: &TabQuery,
|
||||
admin_acls: &[String],
|
||||
account_identity: AccountIdentityMode,
|
||||
) -> Option<maud::Markup> {
|
||||
match tab {
|
||||
"overview" => {
|
||||
@@ -60,31 +63,22 @@ pub async fn render(
|
||||
csrf_token,
|
||||
change_log.as_ref(),
|
||||
limit_config.as_ref(),
|
||||
account_identity.is_username(),
|
||||
))
|
||||
}
|
||||
"account" => {
|
||||
let u = client
|
||||
.get_user_by_id(user_id)
|
||||
.await
|
||||
.log_error("load user account")?;
|
||||
let s = client
|
||||
.list_user_sessions(user_id)
|
||||
.await
|
||||
.map(|r| r.sessions)
|
||||
.map_err(|error| tracing::warn!(%error, user_id, "admin API request failed: list user sessions"))
|
||||
.unwrap_or_default();
|
||||
let webauthn_credentials = client
|
||||
.list_webauthn_credentials(user_id)
|
||||
.await
|
||||
.map_err(|error| tracing::warn!(%error, user_id, "admin API request failed: list webauthn credentials"))
|
||||
.unwrap_or_default();
|
||||
Some(tabs::account::account_tab(
|
||||
render_account(
|
||||
client,
|
||||
config,
|
||||
&u,
|
||||
&s,
|
||||
&webauthn_credentials,
|
||||
csrf_token,
|
||||
))
|
||||
user_id,
|
||||
&tabs::account::AccountTabOptions {
|
||||
admin_acls,
|
||||
account_identity,
|
||||
password_reset_link: None,
|
||||
},
|
||||
)
|
||||
.await
|
||||
}
|
||||
"moderation" => {
|
||||
let u = client
|
||||
@@ -145,6 +139,7 @@ pub async fn render(
|
||||
let context = tabs::moderation::ModerationContext {
|
||||
deletion_scheduler: deletion_scheduler.as_ref(),
|
||||
current_ban: tabs::moderation::find_current_ban(&u, &ban_logs),
|
||||
username_sign_in: account_identity.is_username(),
|
||||
};
|
||||
Some(tabs::moderation::moderation_tab(
|
||||
config,
|
||||
@@ -298,6 +293,40 @@ pub async fn render(
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn render_account(
|
||||
client: &AdminApiClient,
|
||||
config: &AdminConfig,
|
||||
csrf_token: &str,
|
||||
user_id: &str,
|
||||
options: &tabs::account::AccountTabOptions<'_>,
|
||||
) -> Option<maud::Markup> {
|
||||
let u = client
|
||||
.get_user_by_id(user_id)
|
||||
.await
|
||||
.log_error("load user account")?;
|
||||
let s = client
|
||||
.list_user_sessions(user_id)
|
||||
.await
|
||||
.map(|r| r.sessions)
|
||||
.map_err(
|
||||
|error| tracing::warn!(%error, user_id, "admin API request failed: list user sessions"),
|
||||
)
|
||||
.unwrap_or_default();
|
||||
let webauthn_credentials = client
|
||||
.list_webauthn_credentials(user_id)
|
||||
.await
|
||||
.map_err(|error| tracing::warn!(%error, user_id, "admin API request failed: list webauthn credentials"))
|
||||
.unwrap_or_default();
|
||||
Some(tabs::account::account_tab(
|
||||
config,
|
||||
&u,
|
||||
&s,
|
||||
&webauthn_credentials,
|
||||
csrf_token,
|
||||
options,
|
||||
))
|
||||
}
|
||||
|
||||
fn parse_bool_flag(value: &str) -> Option<bool> {
|
||||
match value.trim().to_ascii_lowercase().as_str() {
|
||||
"1" | "true" => Some(true),
|
||||
|
||||
@@ -9,7 +9,12 @@ use crate::{
|
||||
middleware::{auth::AuthContext, csrf::CsrfToken, flash, htmx},
|
||||
routes::user_tabs,
|
||||
state::AppState,
|
||||
templates,
|
||||
templates::{
|
||||
self,
|
||||
pages::user_detail_tabs::account::{
|
||||
PASSWORD_RESET_LINK_RESULT_ID, password_reset_link_result,
|
||||
},
|
||||
},
|
||||
utils::forms::MultiValueForm,
|
||||
};
|
||||
use axum::{
|
||||
@@ -86,8 +91,9 @@ async fn users_list(
|
||||
.as_ref()
|
||||
.map(|user| user.acls.as_slice())
|
||||
.unwrap_or(&[]);
|
||||
let can_view_email = acl::has_permission(admin_acls, acl::USER_VIEW_EMAIL);
|
||||
let client = AdminApiClient::new(state.http_client(), config, &auth.0.session);
|
||||
let username_sign_in = state.account_identity(&client).await.is_username();
|
||||
let can_view_email = acl::has_permission(admin_acls, acl::USER_VIEW_EMAIL) && !username_sign_in;
|
||||
let searching = params.has_id_lookup() || params.has_search();
|
||||
let results = async {
|
||||
if params.has_id_lookup() {
|
||||
@@ -136,6 +142,7 @@ async fn users_list(
|
||||
result_users,
|
||||
has_more,
|
||||
can_view_email,
|
||||
username_sign_in,
|
||||
premium_badge_name.as_deref(),
|
||||
is_results_fragment,
|
||||
);
|
||||
@@ -204,8 +211,16 @@ async fn user_detail(
|
||||
.map(|user| user.acls.as_slice())
|
||||
.unwrap_or(&[]);
|
||||
let tab_body = if user.is_some() {
|
||||
let account_identity = state.account_identity(&client).await;
|
||||
user_tabs::render(
|
||||
&client, config, &csrf.0.0, &user_id, active_tab, &tq, admin_acls,
|
||||
&client,
|
||||
config,
|
||||
&csrf.0.0,
|
||||
&user_id,
|
||||
active_tab,
|
||||
&tq,
|
||||
admin_acls,
|
||||
account_identity,
|
||||
)
|
||||
.await
|
||||
} else {
|
||||
@@ -229,6 +244,7 @@ async fn user_detail_post(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
auth: axum::Extension<AuthContext>,
|
||||
csrf: axum::Extension<CsrfToken>,
|
||||
Path(user_id): Path<String>,
|
||||
Query(aq): Query<ActionQuery>,
|
||||
request: Request,
|
||||
@@ -252,6 +268,10 @@ async fn user_detail_post(
|
||||
};
|
||||
let client = AdminApiClient::new(state.http_client(), config, &auth.0.session);
|
||||
let action = aq.action.as_deref().unwrap_or("");
|
||||
if action == "create_password_reset_link" {
|
||||
return create_password_reset_link(&state, &headers, &auth.0, &csrf.0.0, &client, &user_id)
|
||||
.await;
|
||||
}
|
||||
let outcome = super::user_actions::dispatch(&client, &user_id, action, &form).await;
|
||||
let mut redirect = if tab.is_empty() {
|
||||
format!("{base}/users/{user_id}")
|
||||
@@ -268,6 +288,74 @@ async fn user_detail_post(
|
||||
flash::redirect_with_flash(&redirect, outcome.flash, config.secure_cookies())
|
||||
}
|
||||
|
||||
async fn create_password_reset_link(
|
||||
state: &AppState,
|
||||
headers: &HeaderMap,
|
||||
auth: &AuthContext,
|
||||
csrf_token: &str,
|
||||
client: &AdminApiClient,
|
||||
user_id: &str,
|
||||
) -> Response {
|
||||
let config = state.config();
|
||||
let account_url = format!("{}/users/{user_id}?tab=account", config.base_path);
|
||||
let link = match client.create_password_reset_link(user_id).await {
|
||||
Ok(link) => link,
|
||||
Err(error) => {
|
||||
tracing::warn!(%error, user_id, "admin API request failed: create password reset link");
|
||||
let flash = flash::FlashData::error("Failed to create password reset link");
|
||||
if htmx::is_htmx_request(headers)
|
||||
&& (htmx::targets(headers, "flash-container")
|
||||
|| htmx::targets(headers, PASSWORD_RESET_LINK_RESULT_ID))
|
||||
{
|
||||
return htmx::toast_response(&flash);
|
||||
}
|
||||
return flash::redirect_with_flash(&account_url, flash, config.secure_cookies());
|
||||
}
|
||||
};
|
||||
if htmx::is_htmx_request(headers) && htmx::targets(headers, PASSWORD_RESET_LINK_RESULT_ID) {
|
||||
return Html(password_reset_link_result(Some(&link)).into_string()).into_response();
|
||||
}
|
||||
let admin_acls = auth
|
||||
.admin_user
|
||||
.as_ref()
|
||||
.map(|user| user.acls.as_slice())
|
||||
.unwrap_or(&[]);
|
||||
let (user, badge_name, account_identity) = tokio::join!(
|
||||
async {
|
||||
client
|
||||
.get_user_by_id(user_id)
|
||||
.await
|
||||
.log_error("load user after creating password reset link")
|
||||
},
|
||||
self_hosted_premium_badge_name(state, client),
|
||||
state.account_identity(client)
|
||||
);
|
||||
let tab_body = user_tabs::render_account(
|
||||
client,
|
||||
config,
|
||||
csrf_token,
|
||||
user_id,
|
||||
&templates::pages::user_detail_tabs::account::AccountTabOptions {
|
||||
admin_acls,
|
||||
account_identity,
|
||||
password_reset_link: Some(&link),
|
||||
},
|
||||
)
|
||||
.await;
|
||||
let premium_badge_name = user.as_ref().and(badge_name);
|
||||
let markup = templates::pages::user_detail::user_detail_with_tab(
|
||||
config,
|
||||
auth,
|
||||
user.as_ref(),
|
||||
user_id,
|
||||
"account",
|
||||
tab_body,
|
||||
premium_badge_name.as_deref(),
|
||||
htmx::targets(headers, "main-content"),
|
||||
);
|
||||
Html(markup.into_string()).into_response()
|
||||
}
|
||||
|
||||
async fn user_tab(
|
||||
State(state): State<AppState>,
|
||||
auth: axum::Extension<AuthContext>,
|
||||
@@ -299,14 +387,20 @@ async fn user_tab(
|
||||
.as_ref()
|
||||
.map(|user| user.acls.as_slice())
|
||||
.unwrap_or(&[]);
|
||||
let account_identity = state.account_identity(&client).await;
|
||||
let markup = match user {
|
||||
Some(ref u) => {
|
||||
user_tabs::render(&client, config, &csrf.0.0, &user_id, &tab, &tq, admin_acls)
|
||||
.await
|
||||
.unwrap_or_else(|| {
|
||||
templates::pages::user_detail::simple_tab_content(config, u, &tab)
|
||||
})
|
||||
}
|
||||
Some(ref u) => user_tabs::render(
|
||||
&client,
|
||||
config,
|
||||
&csrf.0.0,
|
||||
&user_id,
|
||||
&tab,
|
||||
&tq,
|
||||
admin_acls,
|
||||
account_identity,
|
||||
)
|
||||
.await
|
||||
.unwrap_or_else(|| templates::pages::user_detail::simple_tab_content(config, u, &tab)),
|
||||
None => maud::html! {
|
||||
div class="p-4 text-red-600 text-sm" { "Failed to load user data." }
|
||||
},
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
use crate::{
|
||||
api::{
|
||||
client::{AdminApiClient, ApiResultExt},
|
||||
types::PremiumBranding,
|
||||
types::{AccountIdentityMode, AccountIdentitySettings, PremiumBranding},
|
||||
},
|
||||
config::AdminConfig,
|
||||
};
|
||||
@@ -13,6 +13,8 @@ use std::{
|
||||
};
|
||||
|
||||
const PREMIUM_BRANDING_TTL: Duration = Duration::from_secs(60);
|
||||
const ACCOUNT_IDENTITY_TTL: Duration = Duration::from_secs(60);
|
||||
const ACCOUNT_IDENTITY_RETRY_TTL: Duration = Duration::from_secs(10);
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct AppState {
|
||||
@@ -23,6 +25,7 @@ struct AppStateInner {
|
||||
pub config: AdminConfig,
|
||||
pub http_client: reqwest::Client,
|
||||
premium_branding: Mutex<Option<(Instant, PremiumBranding)>>,
|
||||
account_identity: Mutex<Option<(Instant, AccountIdentitySettings)>>,
|
||||
}
|
||||
|
||||
impl AppState {
|
||||
@@ -36,6 +39,7 @@ impl AppState {
|
||||
config,
|
||||
http_client,
|
||||
premium_branding: Mutex::new(None),
|
||||
account_identity: Mutex::new(None),
|
||||
}),
|
||||
}
|
||||
}
|
||||
@@ -81,6 +85,47 @@ impl AppState {
|
||||
self.remember_premium_branding(branding.clone());
|
||||
Some(branding)
|
||||
}
|
||||
|
||||
pub async fn account_identity(&self, client: &AdminApiClient) -> AccountIdentityMode {
|
||||
self.account_identity_settings(client).await.mode
|
||||
}
|
||||
|
||||
pub async fn account_identity_settings(
|
||||
&self,
|
||||
client: &AdminApiClient,
|
||||
) -> AccountIdentitySettings {
|
||||
if !self.config().self_hosted {
|
||||
return AccountIdentitySettings::default();
|
||||
}
|
||||
let previous = *self
|
||||
.inner
|
||||
.account_identity
|
||||
.lock()
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner());
|
||||
if let Some((expires_at, settings)) = previous
|
||||
&& Instant::now() < expires_at
|
||||
{
|
||||
return settings;
|
||||
}
|
||||
let (settings, ttl) = match client
|
||||
.get_instance_account_identity()
|
||||
.await
|
||||
.log_error("load account identity mode")
|
||||
{
|
||||
Some(settings) => (settings, ACCOUNT_IDENTITY_TTL),
|
||||
None => (
|
||||
previous.map_or(AccountIdentitySettings::default(), |(_, settings)| settings),
|
||||
ACCOUNT_IDENTITY_RETRY_TTL,
|
||||
),
|
||||
};
|
||||
*self
|
||||
.inner
|
||||
.account_identity
|
||||
.lock()
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner()) =
|
||||
Some((Instant::now() + ttl, settings));
|
||||
settings
|
||||
}
|
||||
}
|
||||
|
||||
impl axum::extract::FromRef<AppState> for AdminConfig {
|
||||
|
||||
@@ -198,6 +198,7 @@ fn message_row(
|
||||
msg.author_global_name.as_deref(),
|
||||
Some(&msg.author_username),
|
||||
None,
|
||||
false,
|
||||
);
|
||||
let row_class = format!(
|
||||
"group relative mt-4 py-0.5 pr-4 pl-4 transition-colors first:mt-0{hover}{highlight}"
|
||||
|
||||
@@ -1,15 +1,46 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use crate::utils::user_tag::user_tag;
|
||||
|
||||
pub fn format_user_display(
|
||||
global_name: Option<&str>,
|
||||
username: Option<&str>,
|
||||
discriminator: Option<&str>,
|
||||
is_bot: bool,
|
||||
) -> String {
|
||||
match (global_name, username, discriminator) {
|
||||
(Some(gn), Some(un), Some("0")) => format!("{gn} (@{un})"),
|
||||
(Some(gn), _, _) => gn.to_owned(),
|
||||
(None, Some(un), Some(d)) if d != "0" => format!("{un}#{d}"),
|
||||
(None, Some(un), Some(d)) if d != "0" => user_tag(un, d, is_bot),
|
||||
(None, Some(un), _) => format!("@{un}"),
|
||||
_ => "Unknown".to_owned(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::utils::user_tag::sync_with_unique_usernames;
|
||||
|
||||
#[test]
|
||||
fn username_instances_show_bare_human_names_and_keep_bot_tags() {
|
||||
sync_with_unique_usernames(true, || {
|
||||
assert_eq!(
|
||||
format_user_display(None, Some("alice"), Some("0000"), false),
|
||||
"alice"
|
||||
);
|
||||
assert_eq!(
|
||||
format_user_display(None, Some("helper"), Some("4363"), true),
|
||||
"helper#4363"
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn email_instances_keep_the_zero_tag() {
|
||||
assert_eq!(
|
||||
format_user_display(None, Some("alice"), Some("0000"), false),
|
||||
"alice#0000"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use crate::{
|
||||
config::AdminConfig, middleware::auth::AuthContext,
|
||||
templates::components::media::user_avatar_url, utils::bigint::format_discriminator,
|
||||
config::AdminConfig,
|
||||
middleware::auth::AuthContext,
|
||||
templates::components::media::user_avatar_url,
|
||||
utils::{bigint::format_discriminator, user_tag::user_tag},
|
||||
};
|
||||
use maud::{Markup, html};
|
||||
|
||||
@@ -32,7 +34,7 @@ pub fn render_header(config: &AdminConfig, auth: &AuthContext, csrf_token: &str)
|
||||
(display)
|
||||
}
|
||||
div class="truncate text-neutral-500 text-xs" {
|
||||
(admin.username) "#" (format_discriminator(&admin.discriminator))
|
||||
(user_tag(&admin.username, &format_discriminator(&admin.discriminator), admin.bot))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -114,6 +114,7 @@ fn overview_card(config: &AdminConfig, app: &Application, can_list_by_owner: boo
|
||||
app.owner_global_name.as_deref(),
|
||||
app.owner_username.as_deref(),
|
||||
app.owner_discriminator.as_deref(),
|
||||
false,
|
||||
);
|
||||
section_card_simple(
|
||||
"Overview",
|
||||
@@ -157,6 +158,7 @@ fn bot_display_markup(config: &AdminConfig, app: &Application) -> Markup {
|
||||
app.bot_global_name.as_deref(),
|
||||
app.bot_username.as_deref(),
|
||||
app.bot_discriminator.as_deref(),
|
||||
true,
|
||||
);
|
||||
html! {
|
||||
div class="space-y-1" {
|
||||
|
||||
@@ -189,7 +189,12 @@ fn render_application_card(config: &AdminConfig, base: &str, app: &Application)
|
||||
|
||||
fn format_owner_display(app: &Application) -> String {
|
||||
if let (Some(un), Some(disc)) = (&app.owner_username, &app.owner_discriminator) {
|
||||
format_user_display(app.owner_global_name.as_deref(), Some(un), Some(disc))
|
||||
format_user_display(
|
||||
app.owner_global_name.as_deref(),
|
||||
Some(un),
|
||||
Some(disc),
|
||||
false,
|
||||
)
|
||||
} else {
|
||||
app.owner_user_id.clone()
|
||||
}
|
||||
@@ -199,7 +204,7 @@ fn format_bot_display(app: &Application) -> String {
|
||||
if let (Some(_bid), Some(un), Some(disc)) =
|
||||
(&app.bot_user_id, &app.bot_username, &app.bot_discriminator)
|
||||
{
|
||||
format_user_display(app.bot_global_name.as_deref(), Some(un), Some(disc))
|
||||
format_user_display(app.bot_global_name.as_deref(), Some(un), Some(disc), true)
|
||||
} else {
|
||||
app.bot_user_id.clone().unwrap_or_default()
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ use crate::{
|
||||
resource_link::{ResourceType, resource_link},
|
||||
table::{table_body, table_cell, table_head, table_header_cell, table_row},
|
||||
},
|
||||
utils::bigint::format_discriminator,
|
||||
utils::{bigint::format_discriminator, user_tag::user_tag},
|
||||
};
|
||||
use maud::{Markup, html};
|
||||
|
||||
@@ -42,10 +42,10 @@ fn type_label(target_type: &str) -> String {
|
||||
}
|
||||
|
||||
fn user_label(user: &AuditLogUserSummary) -> String {
|
||||
let tag = format!(
|
||||
"{}#{}",
|
||||
user.username,
|
||||
format_discriminator(&user.discriminator)
|
||||
let tag = user_tag(
|
||||
&user.username,
|
||||
&format_discriminator(&user.discriminator),
|
||||
false,
|
||||
);
|
||||
match user
|
||||
.global_name
|
||||
@@ -351,6 +351,20 @@ mod tests {
|
||||
assert!(!markup.contains("/admin/users/"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn admin_labels_drop_the_zero_tag_only_without_tags() {
|
||||
let admin = AuditLogUserSummary {
|
||||
id: "1500000000000000001".to_owned(),
|
||||
username: "lilith".to_owned(),
|
||||
discriminator: "0".to_owned(),
|
||||
global_name: Some("Lilith".to_owned()),
|
||||
};
|
||||
assert_eq!(user_label(&admin), "Lilith (lilith#0000)");
|
||||
crate::utils::user_tag::sync_with_unique_usernames(true, || {
|
||||
assert_eq!(user_label(&admin), "Lilith (lilith)");
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_target_types_stay_unlinked() {
|
||||
let markup = target_cell("/admin", &entry("email_domain", "spam.example")).into_string();
|
||||
|
||||
@@ -4,7 +4,7 @@ use crate::{
|
||||
config::AdminConfig,
|
||||
middleware::auth::AuthContext,
|
||||
templates::{
|
||||
components::{form::csrf_input, page_container::page_header},
|
||||
components::{alert::alert_info, form::csrf_input, page_container::page_header},
|
||||
layout::admin_layout,
|
||||
},
|
||||
};
|
||||
@@ -149,10 +149,16 @@ pub fn bans_page(
|
||||
ban_cfg: &BanConfig,
|
||||
flash: Option<&crate::api::types::FlashMessage>,
|
||||
csrf_token: &str,
|
||||
username_sign_in: bool,
|
||||
) -> Markup {
|
||||
let base = &config.base_path;
|
||||
let content = html! {
|
||||
(page_header(ban_cfg.title, None))
|
||||
@if username_sign_in && ban_cfg.active_page == "email-bans" {
|
||||
div class="mb-6" {
|
||||
(alert_info(html! { "People sign in with a username on this instance. Accounts have no email address, so email bans have no effect." }))
|
||||
}
|
||||
}
|
||||
div class="grid gap-6 lg:grid-cols-2" {
|
||||
(ban_card(base, ban_cfg, csrf_token))
|
||||
(check_ban_card(base, ban_cfg, csrf_token))
|
||||
|
||||
@@ -177,7 +177,12 @@ fn guild_feature_label(feature: &str) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn bulk_actions_page(config: &AdminConfig, auth: &AuthContext, csrf_token: &str) -> Markup {
|
||||
pub fn bulk_actions_page(
|
||||
config: &AdminConfig,
|
||||
auth: &AuthContext,
|
||||
csrf_token: &str,
|
||||
username_sign_in: bool,
|
||||
) -> Markup {
|
||||
let base = &config.base_path;
|
||||
let admin_acls = auth
|
||||
.admin_user
|
||||
@@ -198,7 +203,7 @@ pub fn bulk_actions_page(config: &AdminConfig, auth: &AuthContext, csrf_token: &
|
||||
(bulk_add_guild_members_section(base, csrf_token))
|
||||
}
|
||||
@if acl::has_permission(admin_acls, acl::BULK_DELETE_USERS) {
|
||||
(bulk_schedule_deletion_section(base, csrf_token))
|
||||
(bulk_schedule_deletion_section(base, csrf_token, username_sign_in))
|
||||
}
|
||||
@if acl::has_permission(admin_acls, acl::BULK_DELETE_USER_MESSAGES) {
|
||||
(bulk_delete_user_messages_section(base, csrf_token))
|
||||
@@ -331,7 +336,7 @@ fn bulk_add_guild_members_section(base: &str, csrf_token: &str) -> Markup {
|
||||
)
|
||||
}
|
||||
|
||||
fn bulk_schedule_deletion_section(base: &str, csrf_token: &str) -> Markup {
|
||||
fn bulk_schedule_deletion_section(base: &str, csrf_token: &str, username_sign_in: bool) -> Markup {
|
||||
section_card_simple(
|
||||
"Bulk Schedule User Deletion",
|
||||
html! {
|
||||
@@ -370,7 +375,11 @@ fn bulk_schedule_deletion_section(base: &str, csrf_token: &str) -> Markup {
|
||||
},
|
||||
))
|
||||
(text_input("audit_log_reason", "Audit Log Reason (optional)", "", "Reason for this bulk operation"))
|
||||
(opt_out_checkbox("notify_user", "Email each user about the scheduled deletion"))
|
||||
@if username_sign_in {
|
||||
input type="hidden" name="notify_user_present" value="1";
|
||||
} @else {
|
||||
(opt_out_checkbox("notify_user", "Email each user about the scheduled deletion"))
|
||||
}
|
||||
(form_actions(html! {
|
||||
(danger_button("Schedule Deletion"))
|
||||
}))
|
||||
@@ -416,7 +425,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn deletion_form_has_no_preselected_reason() {
|
||||
let markup = bulk_schedule_deletion_section("/admin", "csrf").into_string();
|
||||
let markup = bulk_schedule_deletion_section("/admin", "csrf", false).into_string();
|
||||
assert!(markup.contains(r#"<option value="" selected>Select a reason</option>"#));
|
||||
for (value, _) in DELETION_REASONS {
|
||||
assert!(!markup.contains(&format!(r#"<option value="{value}" selected>"#)));
|
||||
@@ -425,17 +434,25 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn deletion_form_defaults_to_the_moderation_retention_floor() {
|
||||
let markup = bulk_schedule_deletion_section("/admin", "csrf").into_string();
|
||||
let markup = bulk_schedule_deletion_section("/admin", "csrf", false).into_string();
|
||||
assert!(markup.contains(r#"name="days_until_deletion" value="60" min="14" max="365""#));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deletion_form_emails_each_user_by_default() {
|
||||
let markup = bulk_schedule_deletion_section("/admin", "csrf").into_string();
|
||||
let markup = bulk_schedule_deletion_section("/admin", "csrf", false).into_string();
|
||||
assert!(markup.contains(r#"name="notify_user" value="true" checked"#));
|
||||
assert!(markup.contains(r#"name="notify_user_present" value="1""#));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn username_mode_hides_the_email_choices() {
|
||||
let deletion = bulk_schedule_deletion_section("/admin", "csrf", true).into_string();
|
||||
assert!(!deletion.contains("Email each user"));
|
||||
assert!(!deletion.contains(r#"name="notify_user" value="true""#));
|
||||
assert!(deletion.contains(r#"name="notify_user_present" value="1""#));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remove_grid_can_clear_the_deprecated_clone_features() {
|
||||
let markup = guild_feature_checkbox_grid("remove_features[]", true).into_string();
|
||||
|
||||
@@ -18,6 +18,7 @@ use crate::{
|
||||
},
|
||||
utils::bigint::format_discriminator,
|
||||
utils::timestamps::format_admin_timestamp,
|
||||
utils::user_tag::user_tag,
|
||||
};
|
||||
use maud::{Markup, html};
|
||||
|
||||
@@ -74,7 +75,7 @@ fn owner_display(
|
||||
let Some(discriminator) = discriminator else {
|
||||
return owner_id.to_owned();
|
||||
};
|
||||
let tag = format!("{username}#{}", format_discriminator(discriminator));
|
||||
let tag = user_tag(username, &format_discriminator(discriminator), false);
|
||||
match global_name.filter(|value| !value.trim().is_empty()) {
|
||||
Some(global_name) => format!("{global_name} ({tag})"),
|
||||
None => tag,
|
||||
|
||||
@@ -26,11 +26,13 @@ pub const GUILD_TABS: &[(&str, &str)] = &[
|
||||
("archives", "Archives"),
|
||||
("emojis", "Emojis"),
|
||||
("stickers", "Stickers"),
|
||||
("threads", "Threads"),
|
||||
("audit_logs", "Admin Audit Logs"),
|
||||
("audit_log", "Guild Audit Log"),
|
||||
("reports", "Reports"),
|
||||
];
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn guild_detail_with_tab(
|
||||
config: &AdminConfig,
|
||||
auth: &AuthContext,
|
||||
@@ -39,9 +41,12 @@ pub fn guild_detail_with_tab(
|
||||
active_tab: &str,
|
||||
tab_body: Option<Markup>,
|
||||
is_htmx: bool,
|
||||
username_sign_in: bool,
|
||||
) -> Markup {
|
||||
let content = match guild {
|
||||
Some(guild) => render_guild_detail(config, auth, guild, active_tab, tab_body),
|
||||
Some(guild) => {
|
||||
render_guild_detail(config, auth, guild, active_tab, tab_body, username_sign_in)
|
||||
}
|
||||
None => not_found_state("Guild", guild_id, None, None),
|
||||
};
|
||||
let title = if guild.is_some() {
|
||||
@@ -62,6 +67,7 @@ pub fn simple_tab_content(
|
||||
tab: &str,
|
||||
csrf_token: &str,
|
||||
admin_acls: &[String],
|
||||
username_sign_in: bool,
|
||||
) -> Markup {
|
||||
let guild_info = GuildInfo::from(guild.clone());
|
||||
match tab {
|
||||
@@ -69,9 +75,13 @@ pub fn simple_tab_content(
|
||||
"features" => {
|
||||
guild_detail_tabs::features::features_tab(config, &guild_info, csrf_token, admin_acls)
|
||||
}
|
||||
"settings" => {
|
||||
guild_detail_tabs::settings::settings_tab(config, guild, csrf_token, admin_acls)
|
||||
}
|
||||
"settings" => guild_detail_tabs::settings::settings_tab(
|
||||
config,
|
||||
guild,
|
||||
csrf_token,
|
||||
admin_acls,
|
||||
username_sign_in,
|
||||
),
|
||||
"moderation" => guild_detail_tabs::moderation::moderation_tab(
|
||||
config,
|
||||
&guild_info,
|
||||
@@ -92,6 +102,7 @@ fn render_guild_detail(
|
||||
guild: &GuildDetailInfo,
|
||||
active_tab: &str,
|
||||
tab_body: Option<Markup>,
|
||||
username_sign_in: bool,
|
||||
) -> Markup {
|
||||
let base = &config.base_path;
|
||||
let admin_acls = auth
|
||||
@@ -111,8 +122,16 @@ fn render_guild_detail(
|
||||
effective_tab,
|
||||
|tab_id| guild_tab_visible(config, tab_id, admin_acls),
|
||||
);
|
||||
let body = tab_body
|
||||
.unwrap_or_else(|| simple_tab_content(config, guild, effective_tab, "", admin_acls));
|
||||
let body = tab_body.unwrap_or_else(|| {
|
||||
simple_tab_content(
|
||||
config,
|
||||
guild,
|
||||
effective_tab,
|
||||
"",
|
||||
admin_acls,
|
||||
username_sign_in,
|
||||
)
|
||||
});
|
||||
html! {
|
||||
div class="space-y-6" {
|
||||
a href={(base) "/guilds"}
|
||||
@@ -190,6 +209,7 @@ fn guild_tab_visible(_config: &AdminConfig, tab_id: &str, admin_acls: &[String])
|
||||
"overview" | "members" | "settings" | "features" | "moderation" => true,
|
||||
"reports" => acl::has_permission(admin_acls, acl::REPORT_VIEW),
|
||||
"emojis" | "stickers" => acl::has_permission(admin_acls, acl::ASSET_PURGE),
|
||||
"threads" => acl::has_permission(admin_acls, acl::GUILD_LOOKUP),
|
||||
"audit_logs" => acl::has_permission(admin_acls, acl::AUDIT_LOG_VIEW),
|
||||
"audit_log" => acl::has_permission(admin_acls, acl::GUILD_AUDIT_LOG_VIEW),
|
||||
"archives" => acl::has_any_permission(
|
||||
|
||||
@@ -4,7 +4,9 @@ use crate::{
|
||||
api::types::{GuildAuditLogEntry, GuildAuditLogUser, GuildInfo},
|
||||
config::AdminConfig,
|
||||
templates::components::{page_container::card_with_header, table::data_table},
|
||||
utils::{bigint::format_discriminator, timestamps::snowflake_creation_date},
|
||||
utils::{
|
||||
bigint::format_discriminator, timestamps::snowflake_creation_date, user_tag::user_tag,
|
||||
},
|
||||
};
|
||||
use maud::{Markup, html};
|
||||
|
||||
@@ -120,7 +122,7 @@ fn format_user(user: Option<&GuildAuditLogUser>) -> String {
|
||||
};
|
||||
let disc = u.discriminator.as_deref().unwrap_or("0000");
|
||||
let disc = format_discriminator(disc);
|
||||
let tag = format!("{}#{}", u.username, disc);
|
||||
let tag = user_tag(&u.username, &disc, false);
|
||||
match &u.global_name {
|
||||
Some(gn) if !gn.trim().is_empty() => format!("{} ({})", gn, tag),
|
||||
_ => tag,
|
||||
|
||||
@@ -9,7 +9,7 @@ use crate::{
|
||||
media::user_avatar_url,
|
||||
page_container::card_with_header,
|
||||
},
|
||||
utils::bigint::format_discriminator,
|
||||
utils::{bigint::format_discriminator, user_tag::user_tag},
|
||||
};
|
||||
use maud::{Markup, html};
|
||||
|
||||
@@ -103,12 +103,14 @@ fn member_card(
|
||||
member: &GuildMember,
|
||||
csrf_token: &str,
|
||||
) -> Markup {
|
||||
let disc = format_discriminator(&member.user.discriminator);
|
||||
let tag = user_tag(
|
||||
&member.user.username,
|
||||
&format_discriminator(&member.user.discriminator),
|
||||
member.user.bot,
|
||||
);
|
||||
let display = match &member.user.global_name {
|
||||
Some(gn) if !gn.trim().is_empty() => {
|
||||
format!("{} ({}#{})", gn, member.user.username, disc)
|
||||
}
|
||||
_ => format!("{}#{}", member.user.username, disc),
|
||||
Some(gn) if !gn.trim().is_empty() => format!("{gn} ({tag})"),
|
||||
_ => tag,
|
||||
};
|
||||
let user_url = format!("{base}/users/{}", member.user.id);
|
||||
let avatar_url = user_avatar_url(
|
||||
|
||||
@@ -11,8 +11,9 @@ pub mod overview;
|
||||
pub mod reports;
|
||||
pub mod settings;
|
||||
pub mod stickers;
|
||||
pub mod threads;
|
||||
|
||||
use crate::api::types::GuildDetailInfo;
|
||||
use crate::{api::types::GuildDetailInfo, utils::user_tag::user_tag};
|
||||
|
||||
pub(crate) fn owner_display(guild: &GuildDetailInfo) -> String {
|
||||
let Some(username) = guild.owner_username.as_deref() else {
|
||||
@@ -21,7 +22,7 @@ pub(crate) fn owner_display(guild: &GuildDetailInfo) -> String {
|
||||
let Some(discriminator) = guild.owner_discriminator.as_deref() else {
|
||||
return guild.owner_id.clone();
|
||||
};
|
||||
let tag = format!("{username}#{discriminator}");
|
||||
let tag = user_tag(username, discriminator, false);
|
||||
if let Some(global_name) = guild
|
||||
.owner_global_name
|
||||
.as_deref()
|
||||
|
||||
@@ -33,7 +33,11 @@ fn channel_type_label(channel_type: i32) -> &'static str {
|
||||
2 => "Voice",
|
||||
4 => "Category",
|
||||
5 => "Announcement",
|
||||
13 => "Link",
|
||||
11 => "Public thread",
|
||||
12 => "Private thread",
|
||||
15 => "Forum",
|
||||
16 => "Media",
|
||||
998 => "Link",
|
||||
_ => "Unknown",
|
||||
}
|
||||
}
|
||||
@@ -179,7 +183,7 @@ pub fn overview_tab(config: &AdminConfig, guild: &GuildDetailInfo, csrf_token: &
|
||||
} @else {
|
||||
div class="flex flex-col gap-2" {
|
||||
@for channel in &sorted_channels {
|
||||
@let is_link = channel.channel_type == 13;
|
||||
@let is_link = channel.channel_type == 998;
|
||||
@let parent = channel.parent_id.as_deref()
|
||||
.and_then(|pid| channels_by_id.get(pid));
|
||||
@let parent_nsfw_override = parent
|
||||
|
||||
@@ -4,6 +4,7 @@ use crate::{
|
||||
api::types::{GuildInfo, ReportEntry},
|
||||
config::AdminConfig,
|
||||
templates::components::{page_container::card_with_header, table::data_table},
|
||||
utils::user_tag::user_tag,
|
||||
};
|
||||
use maud::{Markup, html};
|
||||
|
||||
@@ -93,7 +94,7 @@ 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 = format!("{username}#{disc}");
|
||||
let tag = user_tag(username, disc, false);
|
||||
if let Some(ref gn) = report.reporter_global_name {
|
||||
let trimmed = gn.trim();
|
||||
if !trimmed.is_empty() {
|
||||
|
||||
@@ -28,16 +28,25 @@ const DISABLED_OPERATIONS: &[(&str, i32)] = &[
|
||||
("MEMBER_LIST_UPDATES", 1 << 6),
|
||||
];
|
||||
|
||||
fn low_verification_label(username_sign_in: bool) -> &'static str {
|
||||
if username_sign_in {
|
||||
"Low (claimed account)"
|
||||
} else {
|
||||
"Low (verified email)"
|
||||
}
|
||||
}
|
||||
|
||||
pub fn settings_tab(
|
||||
config: &AdminConfig,
|
||||
guild: &GuildDetailInfo,
|
||||
csrf_token: &str,
|
||||
admin_acls: &[String],
|
||||
username_sign_in: bool,
|
||||
) -> Markup {
|
||||
let can_edit = acl::has_permission(admin_acls, acl::GUILD_UPDATE_SETTINGS);
|
||||
|
||||
if !can_edit {
|
||||
return settings_tab_readonly(guild);
|
||||
return settings_tab_readonly(guild, username_sign_in);
|
||||
}
|
||||
|
||||
let base = &config.base_path;
|
||||
@@ -55,7 +64,7 @@ pub fn settings_tab(
|
||||
guild.verification_level.unwrap_or(0).min(3),
|
||||
&[
|
||||
(0, "None"),
|
||||
(1, "Low (verified email)"),
|
||||
(1, low_verification_label(username_sign_in)),
|
||||
(2, "Medium (5+ minutes)"),
|
||||
(3, "High (10+ minutes)"),
|
||||
],
|
||||
@@ -223,10 +232,10 @@ fn select_field(
|
||||
}
|
||||
}
|
||||
|
||||
fn settings_tab_readonly(guild: &GuildDetailInfo) -> Markup {
|
||||
fn settings_tab_readonly(guild: &GuildDetailInfo, username_sign_in: bool) -> Markup {
|
||||
let verification_label = match guild.verification_level.unwrap_or(0).min(3) {
|
||||
0 => "None",
|
||||
1 => "Low (verified email)",
|
||||
1 => low_verification_label(username_sign_in),
|
||||
2 => "Medium (5+ minutes)",
|
||||
3 => "High (10+ minutes)",
|
||||
_ => "Unknown",
|
||||
@@ -285,3 +294,32 @@ fn readonly_field(label: &str, value: &str) -> Markup {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
fn guild() -> GuildDetailInfo {
|
||||
serde_json::from_value(json!({
|
||||
"id": "1500000000000000001",
|
||||
"owner_id": "1400000000000000001",
|
||||
"name": "Guild",
|
||||
"verification_level": 1
|
||||
}))
|
||||
.expect("valid guild detail")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn low_verification_names_a_claimed_account_in_username_mode() {
|
||||
let markup = settings_tab_readonly(&guild(), true).into_string();
|
||||
assert!(markup.contains("Low (claimed account)"));
|
||||
assert!(!markup.contains("verified email"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn low_verification_names_a_verified_email_in_email_mode() {
|
||||
let markup = settings_tab_readonly(&guild(), false).into_string();
|
||||
assert!(markup.contains("Low (verified email)"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use crate::{
|
||||
api::types::{GuildInfo, GuildThreadItem},
|
||||
config::AdminConfig,
|
||||
templates::components::{
|
||||
badge::{BadgeVariant, badge},
|
||||
form::{csrf_input, danger_button, form_actions, submit_button},
|
||||
page_container::card_with_header,
|
||||
table::{data_table, table_cell, table_row},
|
||||
},
|
||||
};
|
||||
use maud::{Markup, html};
|
||||
|
||||
pub fn threads_tab(
|
||||
config: &AdminConfig,
|
||||
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(
|
||||
&format!("Threads ({})", threads.len()),
|
||||
html! {
|
||||
@if threads.is_empty() {
|
||||
p class="text-sm text-neutral-500" { "No threads found for this guild." }
|
||||
} @else {
|
||||
(data_table(
|
||||
&["Thread", "Parent", "State", "Members", "Messages", ""],
|
||||
html! {
|
||||
@for thread in threads {
|
||||
(thread_row(base, &guild.id, thread, can_delete, csrf_token))
|
||||
}
|
||||
},
|
||||
))
|
||||
}
|
||||
},
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
fn thread_kind(channel_type: i32) -> &'static str {
|
||||
match channel_type {
|
||||
10 => "Announcement",
|
||||
12 => "Private",
|
||||
_ => "Public",
|
||||
}
|
||||
}
|
||||
|
||||
fn thread_state(thread: &GuildThreadItem) -> Markup {
|
||||
let metadata = thread.thread_metadata.as_ref();
|
||||
let archived = metadata.is_some_and(|metadata| metadata.archived);
|
||||
let locked = metadata.is_some_and(|metadata| metadata.locked);
|
||||
html! {
|
||||
div class="flex flex-wrap gap-1" {
|
||||
(badge(thread_kind(thread.channel_type), BadgeVariant::Default))
|
||||
@if archived { (badge("Archived", BadgeVariant::Default)) }
|
||||
@if locked { (badge("Locked", BadgeVariant::Default)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn thread_row(
|
||||
base: &str,
|
||||
guild_id: &str,
|
||||
thread: &GuildThreadItem,
|
||||
can_delete: bool,
|
||||
csrf_token: &str,
|
||||
) -> Markup {
|
||||
table_row(html! {
|
||||
(table_cell(false, html! {
|
||||
div class="font-medium" { (thread.name.as_deref().unwrap_or("")) }
|
||||
div class="text-xs text-neutral-500" { "ID: " (thread.id) }
|
||||
}))
|
||||
(table_cell(true, html! { (thread.parent_id.as_deref().unwrap_or("")) }))
|
||||
(table_cell(false, thread_state(thread)))
|
||||
(table_cell(true, html! { (thread.member_count.unwrap_or(0)) }))
|
||||
(table_cell(true, html! { (thread.message_count.unwrap_or(0)) }))
|
||||
(table_cell(false, html! {
|
||||
@if can_delete {
|
||||
form method="post"
|
||||
action={(base) "/guilds/" (guild_id) "?tab=threads&action=delete_thread"} {
|
||||
(csrf_input(csrf_token))
|
||||
input type="hidden" name="thread_id" value=(thread.id);
|
||||
(danger_button("Delete thread"))
|
||||
}
|
||||
}
|
||||
}))
|
||||
})
|
||||
}
|
||||
@@ -14,7 +14,7 @@ use crate::{
|
||||
},
|
||||
layout::admin_layout,
|
||||
},
|
||||
utils::forms::parse_comma_separated,
|
||||
utils::{forms::parse_comma_separated, user_tag::user_tag},
|
||||
};
|
||||
use maud::{Markup, html};
|
||||
|
||||
@@ -270,7 +270,7 @@ fn owner_display(guild: &GuildInfo) -> String {
|
||||
let Some(discriminator) = guild.owner_discriminator.as_deref() else {
|
||||
return guild.owner_id.clone();
|
||||
};
|
||||
let tag = format!("{username}#{discriminator}");
|
||||
let tag = user_tag(username, discriminator, false);
|
||||
if let Some(global_name) = guild
|
||||
.owner_global_name
|
||||
.as_deref()
|
||||
|
||||
@@ -2,19 +2,22 @@
|
||||
|
||||
use crate::{
|
||||
api::types::{
|
||||
AppPublicConfigResponse, CAPTCHA_COST_RANGE, CAPTCHA_MAX_COUNTER_RANGE,
|
||||
CaptchaConfigResponse, DOMAIN_MIGRATION_DEFAULT_SALT, DomainMigrationConfigResponse,
|
||||
AccountIdentityConfigResponse, AccountIdentityMode, AppPublicConfigResponse,
|
||||
CAPTCHA_COST_RANGE, CAPTCHA_MAX_COUNTER_RANGE, CHANNEL_THREADS_DEFAULT_GUILD_SALT,
|
||||
CHANNEL_THREADS_DEFAULT_USER_SALT, CaptchaConfigResponse, ChannelThreadsConfigResponse,
|
||||
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,
|
||||
SsoConfigResponse, TagStyle,
|
||||
},
|
||||
config::AdminConfig,
|
||||
middleware::auth::AuthContext,
|
||||
templates::{
|
||||
components::{
|
||||
alert::alert_warning,
|
||||
badge::{BadgeVariant, badge},
|
||||
form::{
|
||||
FORM_INPUT_CLASS, checkbox, csrf_input, danger_button, form_actions,
|
||||
@@ -111,6 +114,9 @@ pub fn instance_config_page(
|
||||
"Access & accounts",
|
||||
"Who can sign in and create accounts on this instance.",
|
||||
html! {
|
||||
@if instance_config.self_hosted {
|
||||
(account_identity_section(&instance_config.account_identity))
|
||||
}
|
||||
(registration_config_section(
|
||||
config,
|
||||
csrf_token,
|
||||
@@ -159,7 +165,12 @@ pub fn instance_config_page(
|
||||
"Runtime integrations",
|
||||
"Credentials and provider choices that override environment variables at runtime.",
|
||||
html! {
|
||||
(integrations_config_section(base, csrf_token, &instance_config.integrations))
|
||||
(integrations_config_section(
|
||||
base,
|
||||
csrf_token,
|
||||
&instance_config.integrations,
|
||||
instance_config.account_identity.mode,
|
||||
))
|
||||
},
|
||||
))
|
||||
(config_group(
|
||||
@@ -182,6 +193,7 @@ pub fn instance_config_page(
|
||||
html! {
|
||||
(gateway_rollout_section(base, csrf_token, &instance_config.gateway_rollout))
|
||||
(domain_migration_section(base, csrf_token, &instance_config.domain_migration))
|
||||
(channel_threads_section(base, csrf_token, &instance_config.channel_threads))
|
||||
(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 {
|
||||
@@ -500,10 +512,65 @@ fn password_input(name: &str, label: &str, helper: Option<&str>) -> Markup {
|
||||
)
|
||||
}
|
||||
|
||||
fn account_identity_section(account_identity: &AccountIdentityConfigResponse) -> Markup {
|
||||
let description = match account_identity.mode {
|
||||
AccountIdentityMode::Username => {
|
||||
"Members sign in with a username and password. The instance never collects an email \
|
||||
address. A member who forgets their password uses their recovery kit or a reset link \
|
||||
from an admin."
|
||||
}
|
||||
AccountIdentityMode::Email => "Members sign in with an email address and password.",
|
||||
};
|
||||
section_card_with_description(
|
||||
"Sign-in Method",
|
||||
"How members identify themselves when they sign in.",
|
||||
html! {
|
||||
div class="space-y-3" {
|
||||
div class="flex flex-wrap items-center gap-2" {
|
||||
h3 class="text-sm font-semibold text-neutral-900" {
|
||||
(account_identity.mode.label())
|
||||
}
|
||||
@match account_identity.locked {
|
||||
Some(true) => (badge("Fixed", BadgeVariant::Default)),
|
||||
Some(false) => (badge("Not fixed yet", BadgeVariant::Warning)),
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
p class="text-sm text-neutral-600" { (description) }
|
||||
@if !account_identity.mode.is_username() {
|
||||
div class="flex flex-wrap items-center gap-2" {
|
||||
h3 class="text-sm font-semibold text-neutral-900" {
|
||||
(account_identity.tag_style.label())
|
||||
}
|
||||
}
|
||||
p class="text-sm text-neutral-600" {
|
||||
@match account_identity.tag_style {
|
||||
TagStyle::None => {
|
||||
"Each name belongs to one person and is shown without a tag."
|
||||
}
|
||||
TagStyle::Random => {
|
||||
"Names have a random tag, like alex#4821, so several people can share a name."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
p class="text-xs text-neutral-500" {
|
||||
@if account_identity.mode.is_username() {
|
||||
"The sign-in method is chosen during setup. It cannot be changed once setup is complete or the first account exists."
|
||||
} @else {
|
||||
"The sign-in method and the username tags are chosen during setup. They cannot be changed once setup is complete or the first account exists."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
fn integrations_config_section(
|
||||
base: &str,
|
||||
csrf_token: &str,
|
||||
integrations: &InstanceIntegrationsResponse,
|
||||
account_identity: AccountIdentityMode,
|
||||
) -> Markup {
|
||||
let smtp_port = integrations
|
||||
.email
|
||||
@@ -536,62 +603,65 @@ fn integrations_config_section(
|
||||
(password_input("integration_youtube_api_key", "YouTube API key", Some("Leave blank to keep the current key.")))
|
||||
}
|
||||
|
||||
div class="space-y-4 border-t border-neutral-200 pt-6" {
|
||||
div class="flex flex-wrap items-center gap-2" {
|
||||
h3 class="text-sm font-semibold text-neutral-900" { "Email delivery" }
|
||||
@if integrations.email.effective_enabled {
|
||||
(badge("Effective: enabled", BadgeVariant::Success))
|
||||
} @else {
|
||||
(badge("Effective: disabled", BadgeVariant::Default))
|
||||
@if !account_identity.is_username() {
|
||||
div class="space-y-4 border-t border-neutral-200 pt-6" {
|
||||
div class="flex flex-wrap items-center gap-2" {
|
||||
h3 class="text-sm font-semibold text-neutral-900" { "Email delivery" }
|
||||
@if integrations.email.effective_enabled {
|
||||
(badge("Effective: enabled", BadgeVariant::Success))
|
||||
} @else {
|
||||
(badge("Effective: disabled", BadgeVariant::Default))
|
||||
}
|
||||
@if integrations.email.effective_disable_new_ip_authorization {
|
||||
(badge("IP auth disabled", BadgeVariant::Warning))
|
||||
} @else {
|
||||
(badge("IP auth required", BadgeVariant::Default))
|
||||
}
|
||||
(secret_badge("SMTP password", integrations.email.smtp.password_set))
|
||||
}
|
||||
@if integrations.email.effective_disable_new_ip_authorization {
|
||||
(badge("IP auth disabled", BadgeVariant::Warning))
|
||||
} @else {
|
||||
(badge("IP auth required", BadgeVariant::Default))
|
||||
input type="hidden" name="integration_email_present" value="1";
|
||||
(checkbox("integration_email_enabled", "true", "Enable email delivery", integrations.email.effective_enabled, true))
|
||||
div class="grid grid-cols-1 gap-4 sm:grid-cols-2" {
|
||||
(text_input(
|
||||
"integration_email_from_email",
|
||||
"From email",
|
||||
integrations.email.from_email.as_deref().unwrap_or(""),
|
||||
"[email protected]",
|
||||
))
|
||||
(text_input(
|
||||
"integration_email_from_name",
|
||||
"From name",
|
||||
integrations.email.from_name.as_deref().unwrap_or(""),
|
||||
"Fluxer",
|
||||
))
|
||||
(text_input(
|
||||
"integration_smtp_host",
|
||||
"SMTP host",
|
||||
integrations.email.smtp.host.as_deref().unwrap_or(""),
|
||||
"smtp.example.com",
|
||||
))
|
||||
(text_input(
|
||||
"integration_smtp_port",
|
||||
"SMTP port",
|
||||
&smtp_port,
|
||||
"587",
|
||||
))
|
||||
(text_input(
|
||||
"integration_smtp_username",
|
||||
"SMTP username",
|
||||
integrations.email.smtp.username.as_deref().unwrap_or(""),
|
||||
"[email protected]",
|
||||
))
|
||||
(password_input("integration_smtp_password", "SMTP password", Some("Leave blank to keep the current password.")))
|
||||
}
|
||||
(secret_badge("SMTP password", integrations.email.smtp.password_set))
|
||||
}
|
||||
(checkbox("integration_email_enabled", "true", "Enable email delivery", integrations.email.effective_enabled, true))
|
||||
div class="grid grid-cols-1 gap-4 sm:grid-cols-2" {
|
||||
(text_input(
|
||||
"integration_email_from_email",
|
||||
"From email",
|
||||
integrations.email.from_email.as_deref().unwrap_or(""),
|
||||
"[email protected]",
|
||||
))
|
||||
(text_input(
|
||||
"integration_email_from_name",
|
||||
"From name",
|
||||
integrations.email.from_name.as_deref().unwrap_or(""),
|
||||
"Fluxer",
|
||||
))
|
||||
(text_input(
|
||||
"integration_smtp_host",
|
||||
"SMTP host",
|
||||
integrations.email.smtp.host.as_deref().unwrap_or(""),
|
||||
"smtp.example.com",
|
||||
))
|
||||
(text_input(
|
||||
"integration_smtp_port",
|
||||
"SMTP port",
|
||||
&smtp_port,
|
||||
"587",
|
||||
))
|
||||
(text_input(
|
||||
"integration_smtp_username",
|
||||
"SMTP username",
|
||||
integrations.email.smtp.username.as_deref().unwrap_or(""),
|
||||
"[email protected]",
|
||||
))
|
||||
(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))
|
||||
div class="flex flex-wrap gap-2" {
|
||||
button type="submit"
|
||||
formaction={(base) "/instance-config?action=test_smtp"}
|
||||
class="inline-flex w-fit items-center justify-center gap-2 rounded-lg border border-neutral-300 bg-neutral-50 px-4 py-2 font-medium text-base text-neutral-700 transition-all duration-150 hover:border-neutral-400 hover:text-neutral-900 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-white" {
|
||||
span { "Test SMTP connection" }
|
||||
(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))
|
||||
div class="flex flex-wrap gap-2" {
|
||||
button type="submit"
|
||||
formaction={(base) "/instance-config?action=test_smtp"}
|
||||
class="inline-flex w-fit items-center justify-center gap-2 rounded-lg border border-neutral-300 bg-neutral-50 px-4 py-2 font-medium text-base text-neutral-700 transition-all duration-150 hover:border-neutral-400 hover:text-neutral-900 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-white" {
|
||||
span { "Test SMTP connection" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1417,6 +1487,189 @@ fn captcha_section(base: &str, csrf_token: &str, captcha: &CaptchaConfigResponse
|
||||
)
|
||||
}
|
||||
|
||||
fn channel_threads_section(
|
||||
base: &str,
|
||||
csrf_token: &str,
|
||||
channel_threads: &ChannelThreadsConfigResponse,
|
||||
) -> Markup {
|
||||
let status = if channel_threads.enabled {
|
||||
("Live", BadgeVariant::Success)
|
||||
} else {
|
||||
("Inert", BadgeVariant::Default)
|
||||
};
|
||||
let enabled_guild_ids = channel_threads.enabled_guild_ids.join("\n");
|
||||
let disabled_guild_ids = channel_threads.disabled_guild_ids.join("\n");
|
||||
let included_user_ids = channel_threads.included_user_ids.join("\n");
|
||||
let excluded_user_ids = channel_threads.excluded_user_ids.join("\n");
|
||||
section_card_with_description(
|
||||
"Channel threads",
|
||||
"Threads, forum channels and media channels. A guild gets the feature only when the guild \
|
||||
is selected, and a member sees it only when they are also selected and use a client \
|
||||
that supports threads. Bots follow the guild selection.",
|
||||
html! {
|
||||
form method="post" action={(base) "/instance-config?action=update_channel_threads"} {
|
||||
(csrf_input(csrf_token))
|
||||
div class="space-y-6" {
|
||||
(alert_warning("Before enabling", html! {
|
||||
p class="text-sm" {
|
||||
"Enable only after every gateway role, the api, the workers and the \
|
||||
messages service run the gate build and the bit 34-38 overwrite audit \
|
||||
is clean."
|
||||
}
|
||||
}))
|
||||
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))
|
||||
@if channel_threads.ever_enabled {
|
||||
(badge("Ever enabled", BadgeVariant::Warning))
|
||||
}
|
||||
span class="text-xs text-neutral-500" {
|
||||
"Config version " (channel_threads.config_version)
|
||||
}
|
||||
}
|
||||
(checkbox(
|
||||
"channel_threads_enabled",
|
||||
"true",
|
||||
"Turn on threads for the selected guilds and users",
|
||||
channel_threads.enabled,
|
||||
true,
|
||||
))
|
||||
p class="text-xs text-neutral-500" {
|
||||
"Off is the safe state and the kill switch. With this unchecked no guild \
|
||||
has threads, and existing threads and forums stay stored but hidden \
|
||||
until it is turned back on."
|
||||
}
|
||||
|
||||
h3 class="text-sm font-semibold text-neutral-900" { "Guilds" }
|
||||
(number_field(
|
||||
"channel_threads_guild_basis_points",
|
||||
"Guild rollout (basis points)",
|
||||
&channel_threads.guild_basis_points.to_string(),
|
||||
Some(0), Some(10000), "1",
|
||||
Some("Share of guilds bucketed into the experiment, in basis points: 0 is nobody, 100 is 1%, 10000 is every guild."),
|
||||
))
|
||||
div class="flex flex-col gap-2" {
|
||||
(text_input(
|
||||
"channel_threads_guild_salt",
|
||||
"Guild rollout salt",
|
||||
&channel_threads.guild_salt,
|
||||
CHANNEL_THREADS_DEFAULT_GUILD_SALT,
|
||||
))
|
||||
p class="text-xs text-neutral-500" {
|
||||
"Seeds the guild bucketing hash. Printable ASCII only. Changing it \
|
||||
reshuffles which guilds fall inside the percentage above."
|
||||
}
|
||||
}
|
||||
div class="flex flex-col gap-2" {
|
||||
(textarea_input(
|
||||
"channel_threads_enabled_guild_ids",
|
||||
"Always-on Guild IDs",
|
||||
"1600000000000000001\n1600000000000000002",
|
||||
&enabled_guild_ids,
|
||||
4,
|
||||
false,
|
||||
))
|
||||
(entry_count_hint(
|
||||
channel_threads.enabled_guild_ids.len(),
|
||||
EXPERIMENT_MAX_TARGETED_USERS,
|
||||
))
|
||||
p class="text-xs text-neutral-500" {
|
||||
"One snowflake per line, or comma separated. These guilds 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" {
|
||||
(textarea_input(
|
||||
"channel_threads_disabled_guild_ids",
|
||||
"Never-on Guild IDs",
|
||||
"1600000000000000003\n1600000000000000004",
|
||||
&disabled_guild_ids,
|
||||
4,
|
||||
false,
|
||||
))
|
||||
(entry_count_hint(
|
||||
channel_threads.disabled_guild_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. This is the per-guild kill switch."
|
||||
}
|
||||
}
|
||||
|
||||
h3 class="text-sm font-semibold text-neutral-900" { "Users" }
|
||||
(number_field(
|
||||
"channel_threads_user_basis_points",
|
||||
"User rollout (basis points)",
|
||||
&channel_threads.user_basis_points.to_string(),
|
||||
Some(0), Some(10000), "1",
|
||||
Some("Share of users bucketed into the experiment, in basis points. Set 10000 before enrolling any guild outside staff, so every member of that guild, moderators included, sees its threads."),
|
||||
))
|
||||
div class="flex flex-col gap-2" {
|
||||
(text_input(
|
||||
"channel_threads_user_salt",
|
||||
"User rollout salt",
|
||||
&channel_threads.user_salt,
|
||||
CHANNEL_THREADS_DEFAULT_USER_SALT,
|
||||
))
|
||||
p class="text-xs text-neutral-500" {
|
||||
"Seeds the user bucketing hash. Printable ASCII only. Changing it \
|
||||
reshuffles which users fall inside the percentage above."
|
||||
}
|
||||
}
|
||||
div class="flex flex-col gap-2" {
|
||||
(textarea_input(
|
||||
"channel_threads_included_user_ids",
|
||||
"Always-on User IDs",
|
||||
"1500000000000000001\n1500000000000000002",
|
||||
&included_user_ids,
|
||||
4,
|
||||
false,
|
||||
))
|
||||
(entry_count_hint(
|
||||
channel_threads.included_user_ids.len(),
|
||||
EXPERIMENT_MAX_TARGETED_USERS,
|
||||
))
|
||||
p class="text-xs text-neutral-500" {
|
||||
"Same format as the guild lists. These users are targeted regardless \
|
||||
of the percentage above."
|
||||
}
|
||||
}
|
||||
div class="flex flex-col gap-2" {
|
||||
(textarea_input(
|
||||
"channel_threads_excluded_user_ids",
|
||||
"Never-on User IDs",
|
||||
"1500000000000000003\n1500000000000000004",
|
||||
&excluded_user_ids,
|
||||
4,
|
||||
false,
|
||||
))
|
||||
(entry_count_hint(
|
||||
channel_threads.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, and it also applies to bots."
|
||||
}
|
||||
}
|
||||
(alert_warning("Excluded bots", html! {
|
||||
p class="text-sm" {
|
||||
"Excluded bots are blind to threads, including moderation bots."
|
||||
}
|
||||
}))
|
||||
|
||||
(form_actions(html! {
|
||||
(submit_button("Save channel threads configuration"))
|
||||
}))
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
fn experiment_delivery_section(
|
||||
base: &str,
|
||||
csrf_token: &str,
|
||||
@@ -2032,6 +2285,95 @@ fn limit_config_section(base: &str, limit_config: &LimitConfigResponse) -> Marku
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn username_instances_hide_email_delivery_and_the_smtp_test() {
|
||||
let integrations = InstanceIntegrationsResponse::default();
|
||||
let username = integrations_config_section(
|
||||
"/admin",
|
||||
"csrf",
|
||||
&integrations,
|
||||
AccountIdentityMode::Username,
|
||||
)
|
||||
.into_string();
|
||||
assert!(!username.contains("Email delivery"));
|
||||
assert!(!username.contains("test_smtp"));
|
||||
assert!(!username.contains("integration_email_present"));
|
||||
assert!(username.contains("Bluesky OAuth"));
|
||||
|
||||
let email = integrations_config_section(
|
||||
"/admin",
|
||||
"csrf",
|
||||
&integrations,
|
||||
AccountIdentityMode::Email,
|
||||
)
|
||||
.into_string();
|
||||
assert!(email.contains("Email delivery"));
|
||||
assert!(email.contains("test_smtp"));
|
||||
assert!(email.contains(r#"name="integration_email_present" value="1""#));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn account_identity_section_has_no_tag_choice_in_username_mode() {
|
||||
let markup = account_identity_section(&AccountIdentityConfigResponse {
|
||||
mode: AccountIdentityMode::Username,
|
||||
locked: Some(true),
|
||||
tag_style: TagStyle::None,
|
||||
})
|
||||
.into_string();
|
||||
assert!(markup.contains("Sign-in Method"));
|
||||
assert!(markup.contains("Username"));
|
||||
assert!(markup.contains("Fixed"));
|
||||
assert!(!markup.contains("No tags"));
|
||||
assert!(!markup.contains("Random tags"));
|
||||
assert!(!markup.contains("username tags"));
|
||||
assert!(!markup.contains("<form"));
|
||||
assert!(!markup.contains("<input"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn account_identity_section_shows_random_tags_in_email_mode() {
|
||||
let markup = account_identity_section(&AccountIdentityConfigResponse {
|
||||
mode: AccountIdentityMode::Email,
|
||||
locked: Some(true),
|
||||
tag_style: TagStyle::Random,
|
||||
})
|
||||
.into_string();
|
||||
assert!(markup.contains("Random tags"));
|
||||
assert!(!markup.contains("No tags"));
|
||||
assert!(markup.contains("username tags"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn account_identity_section_shows_no_tags_in_email_mode() {
|
||||
let markup = account_identity_section(&AccountIdentityConfigResponse {
|
||||
mode: AccountIdentityMode::Email,
|
||||
locked: Some(true),
|
||||
tag_style: TagStyle::None,
|
||||
})
|
||||
.into_string();
|
||||
assert!(markup.contains("No tags"));
|
||||
assert!(!markup.contains("Random tags"));
|
||||
assert!(!markup.contains("<input"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn account_identity_section_shows_the_lock_state_only_when_known() {
|
||||
let render = |locked| {
|
||||
account_identity_section(&AccountIdentityConfigResponse {
|
||||
mode: AccountIdentityMode::Email,
|
||||
locked,
|
||||
tag_style: TagStyle::Random,
|
||||
})
|
||||
.into_string()
|
||||
};
|
||||
let unlocked = render(Some(false));
|
||||
assert!(unlocked.contains("Not fixed yet"));
|
||||
let unknown = render(None);
|
||||
assert!(!unknown.contains("Fixed"));
|
||||
assert!(!unknown.contains("Not fixed yet"));
|
||||
assert!(unknown.contains("Random tags"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn captcha_section_posts_the_switch_and_difficulty_fields() {
|
||||
let markup =
|
||||
@@ -2071,6 +2413,52 @@ mod tests {
|
||||
assert!(!markup.contains("at the cap"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn channel_threads_section_shows_both_dimensions_and_the_warnings() {
|
||||
let channel_threads = ChannelThreadsConfigResponse {
|
||||
enabled: true,
|
||||
config_version: 7,
|
||||
ever_enabled: true,
|
||||
guild_basis_points: 25,
|
||||
enabled_guild_ids: vec!["1600000000000000001".to_owned()],
|
||||
excluded_user_ids: vec![
|
||||
"1500000000000000002".to_owned(),
|
||||
"1500000000000000003".to_owned(),
|
||||
],
|
||||
..ChannelThreadsConfigResponse::default()
|
||||
};
|
||||
let markup = channel_threads_section("/admin", "csrf", &channel_threads).into_string();
|
||||
assert!(markup.contains("action=update_channel_threads"));
|
||||
for name in [
|
||||
"channel_threads_enabled",
|
||||
"channel_threads_guild_basis_points",
|
||||
"channel_threads_guild_salt",
|
||||
"channel_threads_enabled_guild_ids",
|
||||
"channel_threads_disabled_guild_ids",
|
||||
"channel_threads_user_basis_points",
|
||||
"channel_threads_user_salt",
|
||||
"channel_threads_included_user_ids",
|
||||
"channel_threads_excluded_user_ids",
|
||||
] {
|
||||
assert!(markup.contains(&format!("name=\"{name}\"")), "{name}");
|
||||
}
|
||||
assert!(markup.contains("value=\"25\""));
|
||||
assert!(markup.contains("Config version 7"));
|
||||
assert!(markup.contains("Ever enabled"));
|
||||
assert!(markup.contains("bit 34-38 overwrite audit is clean"));
|
||||
assert!(markup.contains("Excluded bots are blind to threads, including moderation bots."));
|
||||
assert!(markup.contains("1 of 1000 stored"));
|
||||
assert!(markup.contains("2 of 1000 stored"));
|
||||
|
||||
let off =
|
||||
channel_threads_section("/admin", "csrf", &ChannelThreadsConfigResponse::default())
|
||||
.into_string();
|
||||
assert!(off.contains("Inert"));
|
||||
assert!(!off.contains("Ever enabled"));
|
||||
assert!(off.contains(CHANNEL_THREADS_DEFAULT_GUILD_SALT));
|
||||
assert!(off.contains(CHANNEL_THREADS_DEFAULT_USER_SALT));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plutonium_page_section_shows_the_rollout_and_list_counts() {
|
||||
let plutonium_page = PlutoniumPageConfigResponse {
|
||||
|
||||
@@ -22,7 +22,7 @@ use crate::{
|
||||
},
|
||||
layout::admin_layout,
|
||||
},
|
||||
utils::timestamps::format_admin_timestamp,
|
||||
utils::{timestamps::format_admin_timestamp, user_tag::user_tag},
|
||||
};
|
||||
use maud::{Markup, html};
|
||||
|
||||
@@ -54,7 +54,7 @@ fn reporter_label(report: &ReportEntry) -> String {
|
||||
}
|
||||
if let Some(username) = &report.reporter_username {
|
||||
let discriminator = report.reporter_discriminator.as_deref().unwrap_or("0000");
|
||||
return format!("{username}#{discriminator}");
|
||||
return user_tag(username, discriminator, false);
|
||||
}
|
||||
if let Some(email) = &report.reporter_email {
|
||||
return email.to_owned();
|
||||
@@ -71,7 +71,7 @@ fn reported_user_label(report: &ReportEntry) -> String {
|
||||
.reported_user_discriminator
|
||||
.as_deref()
|
||||
.unwrap_or("0000");
|
||||
return format!("{username}#{discriminator}");
|
||||
return user_tag(username, discriminator, false);
|
||||
}
|
||||
format!(
|
||||
"User {}",
|
||||
|
||||
@@ -15,6 +15,7 @@ use crate::{
|
||||
},
|
||||
layout::admin_layout,
|
||||
},
|
||||
utils::user_tag::user_tag,
|
||||
};
|
||||
use maud::{Markup, PreEscaped, html};
|
||||
|
||||
@@ -189,7 +190,7 @@ fn format_category(category: Option<&str>) -> String {
|
||||
fn reporter_label(report: &ReportEntry) -> String {
|
||||
if let Some(username) = &report.reporter_username {
|
||||
let discriminator = report.reporter_discriminator.as_deref().unwrap_or("0000");
|
||||
let tag = format!("{username}#{discriminator}");
|
||||
let tag = user_tag(username, discriminator, false);
|
||||
if let Some(display) = report
|
||||
.reporter_global_name
|
||||
.as_ref()
|
||||
@@ -214,7 +215,7 @@ fn reported_user_label(report: &ReportEntry) -> String {
|
||||
.reported_user_discriminator
|
||||
.as_deref()
|
||||
.unwrap_or("0000");
|
||||
let tag = format!("{username}#{discriminator}");
|
||||
let tag = user_tag(username, discriminator, false);
|
||||
if let Some(display) = report
|
||||
.reported_user_global_name
|
||||
.as_ref()
|
||||
|
||||
@@ -15,7 +15,7 @@ use crate::{
|
||||
layout::admin_layout,
|
||||
pages::user_detail_tabs,
|
||||
},
|
||||
utils::bigint::format_discriminator,
|
||||
utils::{bigint::format_discriminator, user_tag::user_tag},
|
||||
};
|
||||
use maud::{Markup, html};
|
||||
|
||||
@@ -163,7 +163,7 @@ fn render_user_detail(
|
||||
))
|
||||
}
|
||||
p class="break-words text-sm text-neutral-500" {
|
||||
(user.username) "#" (format_discriminator(&user.discriminator))
|
||||
(user_tag(&user.username, &format_discriminator(&user.discriminator), user.bot))
|
||||
}
|
||||
p class="break-all text-sm text-neutral-500" {
|
||||
(user.id)
|
||||
|
||||
@@ -1,12 +1,17 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use crate::{
|
||||
api::types::{AdminUser, UserSession, WebAuthnCredential},
|
||||
acl,
|
||||
api::types::{
|
||||
AccountIdentityMode, AdminUser, PasswordResetLinkResponse, UserSession, WebAuthnCredential,
|
||||
},
|
||||
config::AdminConfig,
|
||||
templates::components::{
|
||||
alert::{AlertVariant, alert},
|
||||
form::{checkbox, csrf_input, form_actions, submit_button},
|
||||
page_container::card_with_header,
|
||||
},
|
||||
utils::timestamps::format_admin_timestamp,
|
||||
};
|
||||
use maud::{Markup, html};
|
||||
|
||||
@@ -17,19 +22,35 @@ const BTN_CLS: &str = "w-full inline-flex items-center justify-center rounded-md
|
||||
bg-brand-primary px-4 py-2 text-sm font-medium text-white \
|
||||
shadow-sm hover:bg-brand-primary-dark";
|
||||
|
||||
pub struct AccountTabOptions<'a> {
|
||||
pub admin_acls: &'a [String],
|
||||
pub account_identity: AccountIdentityMode,
|
||||
pub password_reset_link: Option<&'a PasswordResetLinkResponse>,
|
||||
}
|
||||
|
||||
pub fn account_tab(
|
||||
config: &AdminConfig,
|
||||
user: &AdminUser,
|
||||
sessions: &[UserSession],
|
||||
webauthn_credentials: &[WebAuthnCredential],
|
||||
csrf_token: &str,
|
||||
options: &AccountTabOptions<'_>,
|
||||
) -> Markup {
|
||||
let base = &config.base_path;
|
||||
let username_sign_in = options.account_identity.is_username();
|
||||
let can_create_reset_link = username_sign_in
|
||||
&& acl::has_permission(options.admin_acls, acl::USER_CREATE_PASSWORD_RESET_LINK);
|
||||
let can_revoke_recovery_kit = username_sign_in
|
||||
&& !user.bot
|
||||
&& acl::has_permission(options.admin_acls, acl::USER_DELETE_RECOVERY_KIT);
|
||||
html! {
|
||||
div class="space-y-6" {
|
||||
(edit_account_card(base, user, csrf_token))
|
||||
@if can_create_reset_link {
|
||||
(password_reset_link_card(base, user, csrf_token, options.password_reset_link))
|
||||
}
|
||||
(edit_account_card(base, user, csrf_token, username_sign_in))
|
||||
(sessions_card(config, sessions))
|
||||
(quick_actions_card(base, user, csrf_token))
|
||||
(quick_actions_card(base, user, csrf_token, username_sign_in, can_revoke_recovery_kit))
|
||||
(clear_fields_card(base, user, csrf_token))
|
||||
(security_actions_card(base, user, csrf_token))
|
||||
(webauthn_credentials_card(base, user, webauthn_credentials, csrf_token))
|
||||
@@ -37,7 +58,77 @@ pub fn account_tab(
|
||||
}
|
||||
}
|
||||
|
||||
fn edit_account_card(base: &str, user: &AdminUser, csrf_token: &str) -> Markup {
|
||||
fn password_reset_link_card(
|
||||
base: &str,
|
||||
user: &AdminUser,
|
||||
csrf_token: &str,
|
||||
link: Option<&PasswordResetLinkResponse>,
|
||||
) -> Markup {
|
||||
let action_url = format!(
|
||||
"{base}/users/{}?action=create_password_reset_link&tab=account",
|
||||
user.id
|
||||
);
|
||||
html! {
|
||||
(card_with_header("Password Reset Link", html! {
|
||||
div class="space-y-4" {
|
||||
(password_reset_link_result(link))
|
||||
p class="text-sm text-neutral-600" {
|
||||
"Create a one-time link that lets this user choose a new password. \
|
||||
Hand it to them yourself. It works once and expires after an hour."
|
||||
}
|
||||
form method="post"
|
||||
action=(&action_url)
|
||||
data-admin-result-form="true"
|
||||
hx-post=(&action_url)
|
||||
hx-target={"#" (PASSWORD_RESET_LINK_RESULT_ID)}
|
||||
hx-swap="outerHTML"
|
||||
hx-push-url="false" {
|
||||
(csrf_input(csrf_token))
|
||||
button type="submit" class=(BTN_CLS) { "Create Password Reset Link" }
|
||||
}
|
||||
}
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
pub const PASSWORD_RESET_LINK_RESULT_ID: &str = "password-reset-link-result";
|
||||
|
||||
pub fn password_reset_link_result(link: Option<&PasswordResetLinkResponse>) -> Markup {
|
||||
html! {
|
||||
div id=(PASSWORD_RESET_LINK_RESULT_ID) hx-history=[link.is_some().then_some("false")] {
|
||||
@if let Some(link) = link {
|
||||
(alert(AlertVariant::Success, Some("Password reset link created"), html! {
|
||||
div class="flex flex-col gap-2" {
|
||||
p class="text-sm" {
|
||||
"Copy this link now. It is shown only once."
|
||||
}
|
||||
div class="flex items-center gap-2" {
|
||||
input type="url" readonly value=(link.url)
|
||||
aria-label="Password reset link"
|
||||
class="h-8 min-w-0 flex-1 rounded-lg border border-green-200 bg-white px-3 py-1.5 text-xs text-neutral-900";
|
||||
button type="button"
|
||||
class="inline-flex h-8 shrink-0 items-center justify-center rounded-lg border border-neutral-300 bg-neutral-50 px-3 text-xs font-medium text-neutral-700 hover:border-neutral-400 hover:text-neutral-900"
|
||||
data-copy-value=(link.url)
|
||||
onclick="window.__adminCopyToClipboard && window.__adminCopyToClipboard(this.dataset.copyValue, this, 'Copied')" {
|
||||
"Copy Link"
|
||||
}
|
||||
}
|
||||
p class="text-xs" {
|
||||
"Expires " (format_admin_timestamp(&link.expires_at))
|
||||
}
|
||||
}
|
||||
}))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn edit_account_card(
|
||||
base: &str,
|
||||
user: &AdminUser,
|
||||
csrf_token: &str,
|
||||
username_sign_in: bool,
|
||||
) -> Markup {
|
||||
html! {
|
||||
(card_with_header("Edit Account Information", html! {
|
||||
div class="grid gap-4 md:grid-cols-2" {
|
||||
@@ -46,22 +137,26 @@ fn edit_account_card(base: &str, user: &AdminUser, csrf_token: &str) -> Markup {
|
||||
p class="text-sm font-medium text-neutral-700" { "Change Username:" }
|
||||
input type="text" name="username" placeholder="New username"
|
||||
required class=(INPUT_CLS);
|
||||
input type="text" name="discriminator"
|
||||
placeholder="Discriminator (optional)" inputmode="numeric" pattern="[0-9]{1,4}" maxlength="4"
|
||||
class=(INPUT_CLS);
|
||||
@if !crate::utils::user_tag::unique_usernames() || user.bot {
|
||||
input type="text" name="discriminator"
|
||||
placeholder="Discriminator (optional)" inputmode="numeric" pattern="[0-9]{1,4}" maxlength="4"
|
||||
class=(INPUT_CLS);
|
||||
}
|
||||
(form_actions(html! {
|
||||
(submit_button("Change Username"))
|
||||
}))
|
||||
}, csrf_token))
|
||||
(post_form(base, &user.id, "change_email", "account",
|
||||
"Are you sure you want to change this user\\'s email address?", html! {
|
||||
p class="text-sm font-medium text-neutral-700" { "Change Email:" }
|
||||
input type="email" name="email" placeholder="New email address"
|
||||
required class=(INPUT_CLS);
|
||||
(form_actions(html! {
|
||||
(submit_button("Change Email"))
|
||||
}))
|
||||
}, csrf_token))
|
||||
@if !username_sign_in {
|
||||
(post_form(base, &user.id, "change_email", "account",
|
||||
"Are you sure you want to change this user\\'s email address?", html! {
|
||||
p class="text-sm font-medium text-neutral-700" { "Change Email:" }
|
||||
input type="email" name="email" placeholder="New email address"
|
||||
required class=(INPUT_CLS);
|
||||
(form_actions(html! {
|
||||
(submit_button("Change Email"))
|
||||
}))
|
||||
}, csrf_token))
|
||||
}
|
||||
(post_form(base, &user.id, "change_dob", "account",
|
||||
"Are you sure you want to change this user\\'s date of birth?", html! {
|
||||
p class="text-sm font-medium text-neutral-700" { "Change Date of Birth:" }
|
||||
@@ -152,16 +247,28 @@ fn session_entry(base: &str, s: &UserSession, is_tombstone: bool) -> Markup {
|
||||
}
|
||||
}
|
||||
|
||||
fn quick_actions_card(base: &str, user: &AdminUser, csrf_token: &str) -> Markup {
|
||||
fn quick_actions_card(
|
||||
base: &str,
|
||||
user: &AdminUser,
|
||||
csrf_token: &str,
|
||||
username_sign_in: bool,
|
||||
can_revoke_recovery_kit: bool,
|
||||
) -> Markup {
|
||||
html! {
|
||||
(card_with_header("Quick Actions", html! {
|
||||
div class="flex flex-wrap gap-3" {
|
||||
@if !user.email_verified {
|
||||
@if !user.email_verified && !username_sign_in {
|
||||
(action_form(base, &user.id, "verify_email", "account", None,
|
||||
"Verify Email", csrf_token))
|
||||
}
|
||||
(action_form(base, &user.id, "send_password_reset", "account", None,
|
||||
"Send Password Reset", csrf_token))
|
||||
@if !username_sign_in {
|
||||
(action_form(base, &user.id, "send_password_reset", "account", None,
|
||||
"Send Password Reset", csrf_token))
|
||||
}
|
||||
@if can_revoke_recovery_kit {
|
||||
(action_form(base, &user.id, "revoke_recovery_kit", "account", None,
|
||||
"Revoke Recovery Kit", csrf_token))
|
||||
}
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use crate::{api::types::AdminResolvedUser, utils::bigint::format_discriminator};
|
||||
use crate::{
|
||||
api::types::AdminResolvedUser,
|
||||
utils::{bigint::format_discriminator, user_tag::user_tag},
|
||||
};
|
||||
|
||||
pub mod account;
|
||||
pub mod applications;
|
||||
@@ -15,8 +18,11 @@ pub mod reports;
|
||||
pub mod settings;
|
||||
|
||||
pub(super) fn resolved_user_display(user: &AdminResolvedUser) -> String {
|
||||
let disc = format_discriminator(&user.discriminator);
|
||||
let tag = format!("{}#{}", user.username, disc);
|
||||
let tag = user_tag(
|
||||
&user.username,
|
||||
&format_discriminator(&user.discriminator),
|
||||
false,
|
||||
);
|
||||
match &user.global_name {
|
||||
Some(gn) if !gn.trim().is_empty() => format!("{} ({})", gn, tag),
|
||||
_ => tag,
|
||||
|
||||
@@ -63,6 +63,7 @@ pub struct CurrentBan<'a> {
|
||||
pub struct ModerationContext<'a> {
|
||||
pub deletion_scheduler: Option<&'a AdminUser>,
|
||||
pub current_ban: Option<CurrentBan<'a>>,
|
||||
pub username_sign_in: bool,
|
||||
}
|
||||
|
||||
pub fn find_current_ban<'a>(user: &AdminUser, logs: &'a [AuditLogEntry]) -> Option<CurrentBan<'a>> {
|
||||
@@ -118,8 +119,8 @@ pub fn moderation_tab(
|
||||
html! {
|
||||
div class="space-y-6" {
|
||||
div class="grid grid-cols-1 gap-6 md:grid-cols-2" {
|
||||
(ban_actions_card(base, user, csrf_token, context.current_ban.as_ref()))
|
||||
(deletion_card(base, user, csrf_token, context.deletion_scheduler))
|
||||
(ban_actions_card(base, user, csrf_token, context.current_ban.as_ref(), context.username_sign_in))
|
||||
(deletion_card(base, user, csrf_token, context.deletion_scheduler, context.username_sign_in))
|
||||
}
|
||||
@if can_delete_all_messages {
|
||||
(delete_all_messages_card(base, user, csrf_token, delete_all_messages_dry_run))
|
||||
@@ -131,11 +132,20 @@ pub fn moderation_tab(
|
||||
}
|
||||
}
|
||||
|
||||
fn notify_user_checkbox(username_sign_in: bool, label: &str) -> Markup {
|
||||
if username_sign_in {
|
||||
html! { input type="hidden" name="notify_user_present" value="1"; }
|
||||
} else {
|
||||
opt_out_checkbox("notify_user", label)
|
||||
}
|
||||
}
|
||||
|
||||
fn ban_actions_card(
|
||||
base: &str,
|
||||
user: &AdminUser,
|
||||
csrf_token: &str,
|
||||
current_ban: Option<&CurrentBan<'_>>,
|
||||
username_sign_in: bool,
|
||||
) -> Markup {
|
||||
html! {
|
||||
(card_with_header("Ban Actions", html! {
|
||||
@@ -159,7 +169,7 @@ fn ban_actions_card(
|
||||
px-3 py-2 text-sm shadow-sm \
|
||||
focus:border-brand-primary focus:outline-none \
|
||||
focus:ring-1 focus:ring-brand-primary";
|
||||
(opt_out_checkbox("notify_user", "Email the user that the suspension was lifted"))
|
||||
(notify_user_checkbox(username_sign_in, "Email the user that the suspension was lifted"))
|
||||
(form_actions(html! {
|
||||
(submit_button("Unban User"))
|
||||
}))
|
||||
@@ -194,7 +204,7 @@ fn ban_actions_card(
|
||||
px-3 py-2 text-sm shadow-sm \
|
||||
focus:border-brand-primary focus:outline-none \
|
||||
focus:ring-1 focus:ring-brand-primary";
|
||||
(opt_out_checkbox("notify_user", "Email the user about this suspension (temporary bans only)"))
|
||||
(notify_user_checkbox(username_sign_in, "Email the user about this suspension (temporary bans only)"))
|
||||
(form_actions(html! {
|
||||
(submit_button("Ban/Suspend User"))
|
||||
}))
|
||||
@@ -335,6 +345,7 @@ fn deletion_card(
|
||||
user: &AdminUser,
|
||||
csrf_token: &str,
|
||||
scheduler: Option<&AdminUser>,
|
||||
username_sign_in: bool,
|
||||
) -> Markup {
|
||||
html! {
|
||||
(card_with_header("Account Deletion", html! {
|
||||
@@ -353,7 +364,9 @@ fn deletion_card(
|
||||
px-3 py-2 text-sm shadow-sm \
|
||||
focus:border-brand-primary focus:outline-none \
|
||||
focus:ring-1 focus:ring-brand-primary";
|
||||
(checkbox("notify_user", "true", "Email the user that the deletion was cancelled", false, true))
|
||||
@if !username_sign_in {
|
||||
(checkbox("notify_user", "true", "Email the user that the deletion was cancelled", false, true))
|
||||
}
|
||||
(checkbox("confirm", "true", &confirmation, false, true))
|
||||
(form_actions(html! {
|
||||
(danger_button("Cancel Deletion"))
|
||||
@@ -397,7 +410,7 @@ fn deletion_card(
|
||||
px-3 py-2 text-sm shadow-sm \
|
||||
focus:border-brand-primary focus:outline-none \
|
||||
focus:ring-1 focus:ring-brand-primary";
|
||||
(opt_out_checkbox("notify_user", "Email the user about the scheduled deletion"))
|
||||
(notify_user_checkbox(username_sign_in, "Email the user about the scheduled deletion"))
|
||||
(form_actions(html! {
|
||||
(submit_button("Schedule Deletion"))
|
||||
}))
|
||||
@@ -776,7 +789,8 @@ mod tests {
|
||||
"deletion_scheduled_at": "2026-08-31T17:40:29.690Z"
|
||||
}));
|
||||
let scheduler = user(json!({"id": "1400000000000000001", "username": "lilith"}));
|
||||
let markup = deletion_card("/admin", &target, "csrf", Some(&scheduler)).into_string();
|
||||
let markup =
|
||||
deletion_card("/admin", &target, "csrf", Some(&scheduler), false).into_string();
|
||||
assert!(markup.contains(r#"href="/admin/users/1400000000000000001""#));
|
||||
assert!(markup.contains("lilith"));
|
||||
assert!(markup.contains("Report batch 12"));
|
||||
@@ -793,7 +807,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn schedule_form_makes_the_reason_an_explicit_choice() {
|
||||
let markup = deletion_card("/admin", &user(json!({})), "csrf", None).into_string();
|
||||
let markup = deletion_card("/admin", &user(json!({})), "csrf", None, false).into_string();
|
||||
assert!(markup.contains(r#"<option value="" disabled selected>Choose a reason</option>"#));
|
||||
assert!(!markup.contains(r#"<option value="1" selected>"#));
|
||||
assert!(!markup.contains("replace_pending_deletion_at"));
|
||||
@@ -801,22 +815,46 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn schedule_form_emails_the_user_by_default() {
|
||||
let markup = deletion_card("/admin", &user(json!({})), "csrf", None).into_string();
|
||||
let markup = deletion_card("/admin", &user(json!({})), "csrf", None, false).into_string();
|
||||
assert!(markup.contains(r#"name="notify_user" value="true" checked"#));
|
||||
assert!(markup.contains(r#"name="notify_user_present" value="1""#));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn temp_ban_form_emails_the_user_by_default() {
|
||||
let markup = ban_actions_card("/admin", &user(json!({})), "csrf", None).into_string();
|
||||
let markup =
|
||||
ban_actions_card("/admin", &user(json!({})), "csrf", None, false).into_string();
|
||||
assert!(markup.contains(r#"name="notify_user" value="true" checked"#));
|
||||
assert!(markup.contains(r#"name="notify_user_present" value="1""#));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn username_mode_offers_no_email_and_sends_none() {
|
||||
let pending = user(json!({
|
||||
"pending_deletion_at": "2026-10-30T17:40:29.690Z",
|
||||
"deletion_reason_code": 3
|
||||
}));
|
||||
let banned = user(json!({"temp_banned_until": "2026-10-01T00:00:00.000Z"}));
|
||||
let forms = [
|
||||
deletion_card("/admin", &user(json!({})), "csrf", None, true).into_string(),
|
||||
deletion_card("/admin", &pending, "csrf", None, true).into_string(),
|
||||
ban_actions_card("/admin", &user(json!({})), "csrf", None, true).into_string(),
|
||||
ban_actions_card("/admin", &banned, "csrf", None, true).into_string(),
|
||||
];
|
||||
for markup in &forms {
|
||||
assert!(!markup.contains("Email the user"));
|
||||
assert!(!markup.contains(r#"name="notify_user" value="true""#));
|
||||
}
|
||||
assert!(forms[0].contains(r#"name="notify_user_present" value="1""#));
|
||||
assert!(!forms[1].contains("notify_user"));
|
||||
assert!(forms[2].contains(r#"name="notify_user_present" value="1""#));
|
||||
assert!(forms[3].contains(r#"name="notify_user_present" value="1""#));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unban_form_separates_the_public_and_private_reasons() {
|
||||
let target = user(json!({"temp_banned_until": "2026-10-01T00:00:00.000Z"}));
|
||||
let markup = ban_actions_card("/admin", &target, "csrf", None).into_string();
|
||||
let markup = ban_actions_card("/admin", &target, "csrf", None, false).into_string();
|
||||
assert!(markup.contains("?action=unban&tab=moderation"));
|
||||
assert!(markup.contains(r#"name="notify_user" value="true" checked"#));
|
||||
assert!(markup.contains(r#"name="notify_user_present" value="1""#));
|
||||
@@ -864,7 +902,7 @@ mod tests {
|
||||
.collect::<Vec<_>>(),
|
||||
["3"]
|
||||
);
|
||||
let markup = ban_actions_card("/admin", &target, "csrf", Some(&ban)).into_string();
|
||||
let markup = ban_actions_card("/admin", &target, "csrf", Some(&ban), false).into_string();
|
||||
assert!(markup.contains("Regel § 3"));
|
||||
assert!(markup.contains("Also sent links"));
|
||||
assert!(markup.contains(r#"name="ban_audit_log_id" value="2""#));
|
||||
|
||||
@@ -12,6 +12,7 @@ use crate::{
|
||||
utils::{
|
||||
bigint::format_discriminator,
|
||||
timestamps::{format_admin_timestamp, snowflake_creation_date},
|
||||
user_tag::user_tag,
|
||||
},
|
||||
};
|
||||
use maud::{Markup, html};
|
||||
@@ -24,10 +25,11 @@ pub fn overview_tab(
|
||||
change_log: Option<&ListUserChangeLogResponse>,
|
||||
) -> Markup {
|
||||
render_overview_tab(
|
||||
config, user, admin_acls, csrf_token, change_log, None, false,
|
||||
config, user, admin_acls, csrf_token, change_log, None, false, false,
|
||||
)
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn overview_tab_with_limit_config(
|
||||
config: &AdminConfig,
|
||||
user: &AdminUser,
|
||||
@@ -35,6 +37,7 @@ pub fn overview_tab_with_limit_config(
|
||||
csrf_token: &str,
|
||||
change_log: Option<&ListUserChangeLogResponse>,
|
||||
limit_config: Option<&LimitConfigResponse>,
|
||||
username_sign_in: bool,
|
||||
) -> Markup {
|
||||
render_overview_tab(
|
||||
config,
|
||||
@@ -44,9 +47,11 @@ pub fn overview_tab_with_limit_config(
|
||||
change_log,
|
||||
limit_config,
|
||||
true,
|
||||
username_sign_in,
|
||||
)
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn render_overview_tab(
|
||||
config: &AdminConfig,
|
||||
user: &AdminUser,
|
||||
@@ -55,6 +60,7 @@ fn render_overview_tab(
|
||||
change_log: Option<&ListUserChangeLogResponse>,
|
||||
limit_config: Option<&LimitConfigResponse>,
|
||||
show_traits: bool,
|
||||
username_sign_in: bool,
|
||||
) -> Markup {
|
||||
html! {
|
||||
div class="space-y-6" {
|
||||
@@ -118,7 +124,7 @@ fn render_overview_tab(
|
||||
(snowflake_creation_date(&user.id))
|
||||
}))
|
||||
(detail_row("Username", html! {
|
||||
(user.username) "#" (format_discriminator(&user.discriminator))
|
||||
(user_tag(&user.username, &format_discriminator(&user.discriminator), user.bot))
|
||||
}))
|
||||
(detail_row("Display Name", html! {
|
||||
@if let Some(ref name) = user.global_name {
|
||||
@@ -127,7 +133,7 @@ fn render_overview_tab(
|
||||
span class="text-neutral-400" { "Not set" }
|
||||
}
|
||||
}))
|
||||
@if acl::has_permission(admin_acls, acl::USER_VIEW_EMAIL) {
|
||||
@if acl::has_permission(admin_acls, acl::USER_VIEW_EMAIL) && !username_sign_in {
|
||||
(detail_row("Email", html! {
|
||||
@if let Some(ref email) = user.email {
|
||||
(email)
|
||||
@@ -573,3 +579,64 @@ fn custom_traits<'a>(user: &'a AdminUser, trait_definitions: &[&str]) -> Vec<&'a
|
||||
.filter(|trait_name| !DERIVED_TRAITS.contains(trait_name))
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn test_config() -> AdminConfig {
|
||||
AdminConfig {
|
||||
env: crate::config::RuntimeEnv::Test,
|
||||
host: String::new(),
|
||||
port: 3020,
|
||||
secret_key_base: "test-secret".to_owned(),
|
||||
base_path: "/admin".to_owned(),
|
||||
api_endpoint: String::new(),
|
||||
media_endpoint: String::new(),
|
||||
static_cdn_endpoint: String::new(),
|
||||
admin_endpoint: String::new(),
|
||||
web_app_endpoint: String::new(),
|
||||
oauth_client_id: String::new(),
|
||||
oauth_client_secret: String::new(),
|
||||
oauth_redirect_uri: String::new(),
|
||||
build_version: "test".to_owned(),
|
||||
self_hosted: true,
|
||||
proxy: crate::config::ProxyConfig {
|
||||
trust_client_ip_header: false,
|
||||
client_ip_header_name: String::new(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn render_email_row(username_sign_in: bool) -> String {
|
||||
let user: AdminUser = serde_json::from_value(serde_json::json!({
|
||||
"id": "1500000000000000001",
|
||||
"username": "target",
|
||||
"discriminator": "0001",
|
||||
"email": "[email protected]"
|
||||
}))
|
||||
.expect("valid admin user");
|
||||
let acls = vec![acl::USER_VIEW_EMAIL.to_owned()];
|
||||
render_overview_tab(
|
||||
&test_config(),
|
||||
&user,
|
||||
&acls,
|
||||
"csrf",
|
||||
None,
|
||||
None,
|
||||
false,
|
||||
username_sign_in,
|
||||
)
|
||||
.into_string()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn username_mode_hides_the_email_row() {
|
||||
assert!(!render_email_row(true).contains("[email protected]"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn email_mode_shows_the_email_row() {
|
||||
assert!(render_email_row(false).contains("[email protected]"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ use crate::{
|
||||
page_container::card_with_header,
|
||||
table::data_table,
|
||||
},
|
||||
utils::user_tag::user_tag,
|
||||
};
|
||||
use maud::{Markup, html};
|
||||
|
||||
@@ -162,7 +163,7 @@ 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 = format!("{username}#{disc}");
|
||||
let tag = user_tag(username, disc, false);
|
||||
if let Some(ref gn) = report.reporter_global_name {
|
||||
let trimmed = gn.trim();
|
||||
if !trimmed.is_empty() {
|
||||
@@ -260,7 +261,7 @@ fn format_reported_entity(report: &ReportEntry) -> String {
|
||||
.reported_user_discriminator
|
||||
.as_deref()
|
||||
.unwrap_or("0000");
|
||||
let tag = format!("{username}#{disc}");
|
||||
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() {
|
||||
|
||||
@@ -3,7 +3,10 @@
|
||||
use crate::{
|
||||
api::types::AdminUser,
|
||||
templates::components::page_container::{card_with_header, detail_row},
|
||||
utils::bigint::{format_discriminator, has_flag, list_flags},
|
||||
utils::{
|
||||
bigint::{format_discriminator, has_flag, list_flags},
|
||||
user_tag::user_tag,
|
||||
},
|
||||
};
|
||||
use maud::{Markup, html};
|
||||
|
||||
@@ -13,7 +16,7 @@ pub fn settings_tab(user: &AdminUser) -> Markup {
|
||||
(card_with_header("Profile Settings", html! {
|
||||
dl class="divide-y divide-neutral-100" {
|
||||
(detail_row("Username", html! {
|
||||
(user.username) "#" (format_discriminator(&user.discriminator))
|
||||
(user_tag(&user.username, &format_discriminator(&user.discriminator), user.bot))
|
||||
}))
|
||||
(detail_row("Display Name", html! {
|
||||
@if let Some(ref name) = user.global_name {
|
||||
|
||||
@@ -10,7 +10,9 @@ use crate::{
|
||||
media::user_avatar_url,
|
||||
user_profile_badges::user_profile_badges,
|
||||
},
|
||||
utils::{bigint::format_discriminator, timestamps::snowflake_creation_date},
|
||||
utils::{
|
||||
bigint::format_discriminator, timestamps::snowflake_creation_date, user_tag::user_tag,
|
||||
},
|
||||
};
|
||||
use maud::{Markup, html};
|
||||
|
||||
@@ -72,7 +74,7 @@ pub fn user_peek_fragment(
|
||||
))
|
||||
}
|
||||
p class="break-words text-sm text-neutral-500" {
|
||||
(user.username) "#" (format_discriminator(&user.discriminator))
|
||||
(user_tag(&user.username, &format_discriminator(&user.discriminator), user.bot))
|
||||
}
|
||||
div class="flex flex-wrap items-center justify-center gap-2 \
|
||||
sm:justify-start" {
|
||||
|
||||
@@ -22,7 +22,10 @@ use crate::{
|
||||
},
|
||||
layout::admin_layout,
|
||||
},
|
||||
utils::bigint::format_discriminator,
|
||||
utils::{
|
||||
bigint::format_discriminator,
|
||||
user_tag::{unique_usernames, user_tag},
|
||||
},
|
||||
};
|
||||
use maud::{Markup, html};
|
||||
|
||||
@@ -103,6 +106,7 @@ pub fn users_list_page(
|
||||
results: Option<&[AdminUser]>,
|
||||
has_more: bool,
|
||||
can_view_email: bool,
|
||||
username_sign_in: bool,
|
||||
premium_badge_name: Option<&str>,
|
||||
is_htmx: bool,
|
||||
) -> Markup {
|
||||
@@ -127,7 +131,7 @@ pub fn users_list_page(
|
||||
p class="mb-1 text-xs text-neutral-500" {
|
||||
"For example, type " span class="font-mono" { "*" } " in to search for all users."
|
||||
}
|
||||
(search_form(base, params))
|
||||
(search_form(base, params, !username_sign_in))
|
||||
}
|
||||
(results_markup)
|
||||
}
|
||||
@@ -153,15 +157,20 @@ fn parse_ids_query(ids_query: &str) -> Vec<String> {
|
||||
ids
|
||||
}
|
||||
|
||||
fn search_form(base: &str, params: &UserListParams) -> Markup {
|
||||
fn search_form(base: &str, params: &UserListParams, show_email_search: bool) -> Markup {
|
||||
let action = format!("{base}/users");
|
||||
let placeholder = if unique_usernames() {
|
||||
"Search by user ID, username, or Stripe ID..."
|
||||
} else {
|
||||
"Search by user ID, username, tag#0000, or Stripe ID..."
|
||||
};
|
||||
html! {
|
||||
form method="get" action=(&action)
|
||||
class="flex flex-col gap-3 sm:flex-row sm:items-center" {
|
||||
div class="flex flex-1 flex-col gap-2 sm:flex-row" {
|
||||
div class="flex-1" {
|
||||
input id="search-q" type="text" name="q" value=(params.q)
|
||||
placeholder="Search by user ID, username, tag#0000, or Stripe ID..."
|
||||
placeholder=(placeholder)
|
||||
class={(FORM_CONTROL_CLASS) " " (FORM_SEARCH_INPUT_SIZE_CLASS)}
|
||||
hx-get=(&action)
|
||||
hx-trigger="input changed delay:300ms, search"
|
||||
@@ -170,16 +179,18 @@ fn search_form(base: &str, params: &UserListParams) -> Markup {
|
||||
hx-include="closest form"
|
||||
hx-swap="outerHTML";
|
||||
}
|
||||
div class="flex-1" {
|
||||
input id="search-email" type="text" name="email" value=(params.email)
|
||||
placeholder="Exact email address..."
|
||||
class={(FORM_CONTROL_CLASS) " " (FORM_SEARCH_INPUT_SIZE_CLASS)}
|
||||
hx-get=(&action)
|
||||
hx-trigger="input changed delay:300ms, search"
|
||||
hx-target="#users-results"
|
||||
hx-push-url="true"
|
||||
hx-include="closest form"
|
||||
hx-swap="outerHTML";
|
||||
@if show_email_search {
|
||||
div class="flex-1" {
|
||||
input id="search-email" type="text" name="email" value=(params.email)
|
||||
placeholder="Exact email address..."
|
||||
class={(FORM_CONTROL_CLASS) " " (FORM_SEARCH_INPUT_SIZE_CLASS)}
|
||||
hx-get=(&action)
|
||||
hx-trigger="input changed delay:300ms, search"
|
||||
hx-target="#users-results"
|
||||
hx-push-url="true"
|
||||
hx-include="closest form"
|
||||
hx-swap="outerHTML";
|
||||
}
|
||||
}
|
||||
div class="flex-1" {
|
||||
input id="search-ip" type="text" name="ip" value=(params.ip)
|
||||
@@ -349,7 +360,7 @@ fn render_users_table(
|
||||
@if user.global_name.as_deref().map(|n| !n.trim().is_empty()).unwrap_or(false) {
|
||||
(display_name)
|
||||
} @else {
|
||||
(user.username) "#" (format_discriminator(&user.discriminator))
|
||||
(user_tag(&user.username, &format_discriminator(&user.discriminator), user.bot))
|
||||
}
|
||||
}
|
||||
(user_profile_badges(
|
||||
@@ -364,7 +375,7 @@ fn render_users_table(
|
||||
}
|
||||
@if user.global_name.as_deref().map(|n| !n.trim().is_empty()).unwrap_or(false) {
|
||||
p class="text-xs font-normal text-neutral-500" {
|
||||
(user.username) "#" (format_discriminator(&user.discriminator))
|
||||
(user_tag(&user.username, &format_discriminator(&user.discriminator), user.bot))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -450,3 +461,25 @@ fn users_url(base: &str, params: &UserListParams, page: u32) -> String {
|
||||
pairs.push(format!("page={page}"));
|
||||
format!("{base}/users?{}", pairs.join("&"))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn params() -> UserListParams {
|
||||
UserListParams::from_query(None, None, None, None, None, None)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn username_mode_has_no_email_search() {
|
||||
let markup = search_form("/admin", ¶ms(), false).into_string();
|
||||
assert!(!markup.contains("search-email"));
|
||||
assert!(markup.contains("search-q"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn email_mode_keeps_the_email_search() {
|
||||
let markup = search_form("/admin", ¶ms(), true).into_string();
|
||||
assert!(markup.contains("search-email"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,6 +55,10 @@ impl MultiValueForm {
|
||||
self.fields.contains_key(key)
|
||||
}
|
||||
|
||||
pub fn has_key_starting_with(&self, prefix: &str) -> bool {
|
||||
self.fields.keys().any(|key| key.starts_with(prefix))
|
||||
}
|
||||
|
||||
pub fn values(&self, key: &str) -> &[String] {
|
||||
self.fields.get(key).map(Vec::as_slice).unwrap_or_default()
|
||||
}
|
||||
|
||||
@@ -3,3 +3,4 @@
|
||||
pub mod bigint;
|
||||
pub mod forms;
|
||||
pub mod timestamps;
|
||||
pub mod user_tag;
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use std::future::Future;
|
||||
|
||||
tokio::task_local! {
|
||||
static UNIQUE_USERNAMES: bool;
|
||||
}
|
||||
|
||||
pub async fn with_unique_usernames<F: Future>(unique_usernames: bool, future: F) -> F::Output {
|
||||
UNIQUE_USERNAMES.scope(unique_usernames, future).await
|
||||
}
|
||||
|
||||
pub fn sync_with_unique_usernames<R>(unique_usernames: bool, f: impl FnOnce() -> R) -> R {
|
||||
UNIQUE_USERNAMES.sync_scope(unique_usernames, f)
|
||||
}
|
||||
|
||||
pub fn unique_usernames() -> bool {
|
||||
UNIQUE_USERNAMES.try_with(|value| *value).unwrap_or(false)
|
||||
}
|
||||
|
||||
pub fn shows_discriminator(discriminator: &str, is_bot: bool) -> bool {
|
||||
is_bot || !unique_usernames() || discriminator.trim().parse::<u16>() != Ok(0)
|
||||
}
|
||||
|
||||
pub fn user_tag(username: &str, discriminator: &str, is_bot: bool) -> String {
|
||||
if shows_discriminator(discriminator, is_bot) {
|
||||
format!("{username}#{discriminator}")
|
||||
} else {
|
||||
username.to_owned()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn email_mode_keeps_every_tag() {
|
||||
assert_eq!(user_tag("alice", "0000", false), "alice#0000");
|
||||
assert_eq!(user_tag("alice", "0042", false), "alice#0042");
|
||||
sync_with_unique_usernames(false, || {
|
||||
assert_eq!(user_tag("alice", "0000", false), "alice#0000");
|
||||
assert_eq!(user_tag("bot", "0000", true), "bot#0000");
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn username_mode_hides_zero_tag_for_humans() {
|
||||
sync_with_unique_usernames(true, || {
|
||||
assert_eq!(user_tag("alice", "0000", false), "alice");
|
||||
assert_eq!(user_tag("alice", "0", false), "alice");
|
||||
assert!(!shows_discriminator("0000", false));
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn username_mode_keeps_bot_and_non_zero_tags() {
|
||||
sync_with_unique_usernames(true, || {
|
||||
assert_eq!(user_tag("helper", "4363", true), "helper#4363");
|
||||
assert_eq!(user_tag("helper", "0000", true), "helper#0000");
|
||||
assert_eq!(user_tag("legacy", "0042", false), "legacy#0042");
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mode_defaults_to_email_outside_a_request() {
|
||||
assert!(!unique_usernames());
|
||||
}
|
||||
}
|
||||
@@ -433,6 +433,19 @@ fn deserialize_instance_config_response_with_unknown_keys() {
|
||||
"max_counter": 1000,
|
||||
"future_captcha_knob": 1
|
||||
},
|
||||
"channel_threads": {
|
||||
"enabled": true,
|
||||
"config_version": 3,
|
||||
"ever_enabled": true,
|
||||
"guild_basis_points": 0,
|
||||
"guild_salt": "channel-threads-guild-v1",
|
||||
"enabled_guild_ids": ["1600000000000000001"],
|
||||
"disabled_guild_ids": [],
|
||||
"user_basis_points": 10000,
|
||||
"user_salt": "channel-threads-user-v1",
|
||||
"included_user_ids": [],
|
||||
"excluded_user_ids": []
|
||||
},
|
||||
"experiment_delivery": {"poll_interval_seconds": 300, "poll_jitter_percent": 15},
|
||||
"registration": {
|
||||
"mode": "open",
|
||||
@@ -441,6 +454,7 @@ fn deserialize_instance_config_response_with_unknown_keys() {
|
||||
"pending_registrations": []
|
||||
},
|
||||
"self_hosted": false,
|
||||
"account_identity": {"mode": "username", "locked": true, "tag_style": "none"},
|
||||
"app_public": {
|
||||
"branding": {
|
||||
"product_name": "Fluxer",
|
||||
@@ -594,6 +608,9 @@ fn deserialize_instance_config_response_with_unknown_keys() {
|
||||
assert!(resp.push_relay.relay_consent_accepted);
|
||||
assert!(resp.captcha.enabled);
|
||||
assert_eq!(resp.captcha.max_counter, 1000);
|
||||
assert!(resp.channel_threads.enabled);
|
||||
assert_eq!(resp.channel_threads.config_version, 3);
|
||||
assert_eq!(resp.channel_threads.enabled_guild_ids.len(), 1);
|
||||
assert_eq!(resp.experiment_delivery.poll_interval_seconds, 300);
|
||||
assert!(resp.policy.single_community_guild_id.is_none());
|
||||
assert_eq!(resp.policy.services.gif_enabled, Some(true));
|
||||
@@ -601,9 +618,15 @@ fn deserialize_instance_config_response_with_unknown_keys() {
|
||||
assert_eq!(resp.app_public.branding.premium_product_name, "Gold");
|
||||
assert!(resp.billing.billing_active);
|
||||
assert!(resp.media.attachment_decay.effective.enabled);
|
||||
assert!(resp.account_identity.locked);
|
||||
|
||||
let ours: types::InstanceConfigResponse =
|
||||
serde_json::from_str(json).expect("hand-written instance config");
|
||||
assert_eq!(
|
||||
ours.account_identity.mode,
|
||||
types::AccountIdentityMode::Username
|
||||
);
|
||||
assert_eq!(ours.account_identity.locked, Some(true));
|
||||
assert_eq!(ours.app_public.branding.premium_product_name, "Gold");
|
||||
assert!(ours.billing.stripe_secret_key_stored);
|
||||
assert_eq!(ours.billing.tax_id_collection, Some(true));
|
||||
@@ -655,6 +678,63 @@ fn deserialize_instance_config_response_with_unknown_keys() {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deserialize_channel_threads_config() {
|
||||
let config: types::ChannelThreadsConfigResponse = serde_json::from_str(
|
||||
r#"{
|
||||
"enabled": true,
|
||||
"config_version": 12,
|
||||
"ever_enabled": true,
|
||||
"guild_basis_points": 50,
|
||||
"guild_salt": "channel-threads-guild-v2",
|
||||
"enabled_guild_ids": ["1600000000000000001"],
|
||||
"disabled_guild_ids": ["1600000000000000002", "1600000000000000003"],
|
||||
"user_basis_points": 10000,
|
||||
"user_salt": "channel-threads-user-v1",
|
||||
"included_user_ids": [],
|
||||
"excluded_user_ids": ["1500000000000000001"],
|
||||
"future_threads_knob": 1
|
||||
}"#,
|
||||
)
|
||||
.expect("a channel threads config must deserialize");
|
||||
|
||||
assert!(config.enabled);
|
||||
assert!(config.ever_enabled);
|
||||
assert_eq!(config.config_version, 12);
|
||||
assert_eq!(config.guild_basis_points, 50);
|
||||
assert_eq!(config.guild_salt, "channel-threads-guild-v2");
|
||||
assert_eq!(config.enabled_guild_ids, vec!["1600000000000000001"]);
|
||||
assert_eq!(config.disabled_guild_ids.len(), 2);
|
||||
assert_eq!(config.user_basis_points, 10000);
|
||||
assert_eq!(config.excluded_user_ids, vec!["1500000000000000001"]);
|
||||
|
||||
let absent: types::ChannelThreadsConfigResponse =
|
||||
serde_json::from_str("{}").expect("an api without the experiment still deserializes");
|
||||
assert!(!absent.enabled);
|
||||
assert!(!absent.ever_enabled);
|
||||
assert_eq!(absent.guild_salt, types::CHANNEL_THREADS_DEFAULT_GUILD_SALT);
|
||||
assert_eq!(absent.user_salt, types::CHANNEL_THREADS_DEFAULT_USER_SALT);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serialize_channel_threads_update_never_sends_server_owned_fields() {
|
||||
let update = types::InstanceConfigUpdateRequest {
|
||||
channel_threads: Some(types::ChannelThreadsConfigUpdateRequest {
|
||||
enabled: Some(true),
|
||||
enabled_guild_ids: Some(vec!["1600000000000000001".to_owned()]),
|
||||
..Default::default()
|
||||
}),
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(
|
||||
serde_json::to_value(&update).unwrap(),
|
||||
serde_json::json!({"channel_threads": {
|
||||
"enabled": true,
|
||||
"enabled_guild_ids": ["1600000000000000001"],
|
||||
}})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deserialize_push_relay_config() {
|
||||
let accepted: types::PushRelayConfigResponse = serde_json::from_str(
|
||||
@@ -1069,3 +1149,56 @@ fn deserialize_list_admin_api_key_entry() {
|
||||
assert_eq!(resp.created_by_user_id, "1130650140672000000");
|
||||
assert_eq!(resp.acls.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn account_identity_lock_is_unknown_when_the_api_omits_it() {
|
||||
let identity: types::AccountIdentityConfigResponse =
|
||||
serde_json::from_str("{}").expect("empty account identity");
|
||||
assert_eq!(identity.mode, types::AccountIdentityMode::Email);
|
||||
assert_eq!(identity.locked, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deserialize_guild_threads_response() {
|
||||
let json = r#"{
|
||||
"threads": [
|
||||
{
|
||||
"id": "1600000000000000010",
|
||||
"type": 12,
|
||||
"guild_id": "1600000000000000001",
|
||||
"parent_id": "1600000000000000002",
|
||||
"owner_id": "1500000000000000001",
|
||||
"name": "secret plans",
|
||||
"last_message_id": null,
|
||||
"last_pin_timestamp": null,
|
||||
"rate_limit_per_user": 0,
|
||||
"flags": 0,
|
||||
"thread_metadata": {
|
||||
"archived": true,
|
||||
"auto_archive_duration": 4320,
|
||||
"archive_timestamp": "2026-09-27T12:00:00.000Z",
|
||||
"locked": false,
|
||||
"invitable": false,
|
||||
"create_timestamp": "2026-09-26T12:00:00.000Z"
|
||||
},
|
||||
"message_count": 3,
|
||||
"total_message_sent": 4,
|
||||
"member_count": 2
|
||||
}
|
||||
]
|
||||
}"#;
|
||||
let generated: generated_types::ListGuildThreadsResponse =
|
||||
serde_json::from_str(json).expect("the generated client must accept the thread list");
|
||||
assert_eq!(generated.threads.len(), 1);
|
||||
let resp: types::ListGuildThreadsResponse = serde_json::from_str(json).unwrap();
|
||||
let thread = &resp.threads[0];
|
||||
assert_eq!(thread.channel_type, 12);
|
||||
assert_eq!(thread.name.as_deref(), Some("secret plans"));
|
||||
assert_eq!(thread.member_count, Some(2));
|
||||
assert!(
|
||||
thread
|
||||
.thread_metadata
|
||||
.as_ref()
|
||||
.is_some_and(|m| m.archived && !m.locked)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -469,6 +469,7 @@ async fn mutating_admin_pages_render_usable_csrf_tokens() {
|
||||
"/instance-config?action=update_gateway_rollout",
|
||||
"/instance-config?action=update_sso",
|
||||
"/instance-config?action=update_domain_migration",
|
||||
"/instance-config?action=update_channel_threads",
|
||||
"/instance-config?action=update_plutonium_page",
|
||||
"/instance-config?action=update_experiment_delivery",
|
||||
][..],
|
||||
@@ -485,6 +486,57 @@ async fn mutating_admin_pages_render_usable_csrf_tokens() {
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn channel_threads_section_renders_and_saves_through_htmx_toasts() {
|
||||
let app = setup().await;
|
||||
let (headers, body) = get_with_headers(&app, "/instance-config", &[]).await;
|
||||
assert_full_layout(&body);
|
||||
assert!(body.contains("Channel threads"), "{body}");
|
||||
assert!(body.contains("Config version 3"), "{body}");
|
||||
assert!(body.contains("Ever enabled"), "{body}");
|
||||
assert!(body.contains("1600000000000000001"), "{body}");
|
||||
assert!(body.contains("1500000000000000009"), "{body}");
|
||||
assert!(
|
||||
body.contains("Excluded bots are blind to threads, including moderation bots."),
|
||||
"{body}"
|
||||
);
|
||||
let csrf_token = csrf_cookie(&headers)
|
||||
.unwrap_or_else(|| panic!("instance config page did not set csrf_token cookie\n{body}"));
|
||||
let cookie = format!("{}; csrf_token={}", app.session_cookie, csrf_token);
|
||||
let htmx_headers = [
|
||||
("HX-Request", "true"),
|
||||
("HX-Target", "flash-container"),
|
||||
("Cookie", cookie.as_str()),
|
||||
];
|
||||
|
||||
for (form, expected) in [
|
||||
(
|
||||
format!(
|
||||
"_csrf={csrf_token}&channel_threads_enabled=true&channel_threads_guild_basis_points=0&channel_threads_enabled_guild_ids=1600000000000000001&channel_threads_user_basis_points=10000"
|
||||
),
|
||||
"Instance config updated",
|
||||
),
|
||||
(
|
||||
format!("_csrf={csrf_token}&channel_threads_enabled_guild_ids=not-a-guild"),
|
||||
"Enabled guild IDs entry 1 must contain 1 to 20 decimal digits",
|
||||
),
|
||||
] {
|
||||
let (status, response_headers, response_body) = post_form_with_headers(
|
||||
&app,
|
||||
"/instance-config?action=update_channel_threads",
|
||||
&htmx_headers,
|
||||
&form,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::NO_CONTENT, "{response_body}");
|
||||
let toast = response_headers
|
||||
.get("X-Fluxer-Admin-Toast")
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.unwrap_or_else(|| panic!("missing toast header\n{response_body}"));
|
||||
assert!(toast.contains(expected), "{toast}");
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn instance_config_registration_tables_show_copyable_urls_and_compact_pending_actions() {
|
||||
let app = setup().await;
|
||||
@@ -1193,6 +1245,19 @@ fn instance_config() -> Value {
|
||||
"anonymous_rollout_basis_points": 0,
|
||||
"standalone_forwarding": false
|
||||
},
|
||||
"channel_threads": {
|
||||
"enabled": false,
|
||||
"config_version": 3,
|
||||
"ever_enabled": true,
|
||||
"guild_basis_points": 0,
|
||||
"guild_salt": "channel-threads-guild-v1",
|
||||
"enabled_guild_ids": ["1600000000000000001"],
|
||||
"disabled_guild_ids": [],
|
||||
"user_basis_points": 10000,
|
||||
"user_salt": "channel-threads-user-v1",
|
||||
"included_user_ids": [],
|
||||
"excluded_user_ids": ["1500000000000000009"]
|
||||
},
|
||||
"plutonium_page": {
|
||||
"enabled": false,
|
||||
"config_version": 0,
|
||||
|
||||
@@ -0,0 +1,446 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
#![recursion_limit = "256"]
|
||||
|
||||
use axum::{
|
||||
Json, Router,
|
||||
body::{Body, to_bytes},
|
||||
extract::State,
|
||||
http::{Method, Request, StatusCode, Uri, header},
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
use fluxer_admin::{
|
||||
build_router,
|
||||
config::{AdminConfig, ProxyConfig, RuntimeEnv},
|
||||
session,
|
||||
};
|
||||
use serde_json::{Value, json};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use tokio::net::TcpListener;
|
||||
use tower::ServiceExt;
|
||||
|
||||
const SECRET_KEY: &str = "password-reset-link-test-secret";
|
||||
const ADMIN_ID: &str = "1500000000000000000";
|
||||
const TARGET_ID: &str = "1500000000000000042";
|
||||
const RESET_URL: &str = "https://chat.example.test/reset#token=one-time-reset-token";
|
||||
|
||||
#[derive(Clone)]
|
||||
struct MockApi {
|
||||
account_identity: &'static str,
|
||||
admin_acls: Vec<&'static str>,
|
||||
requests: Arc<Mutex<Vec<String>>>,
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn creating_a_reset_link_shows_the_url_once_with_a_copy_button() {
|
||||
let app = setup(true, "username", vec!["*"]).await;
|
||||
let csrf_token = csrf_token(&app).await;
|
||||
let (status, body) = post_form(
|
||||
&app,
|
||||
&format!("/users/{TARGET_ID}?action=create_password_reset_link&tab=account"),
|
||||
&format!("_csrf={csrf_token}"),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
assert!(app.saw(&format!(
|
||||
"POST /admin/users/{TARGET_ID}/password-reset-link"
|
||||
)));
|
||||
assert!(body.contains("Copy this link now. It is shown only once."));
|
||||
assert!(body.contains(&format!(r#"value="{RESET_URL}""#)));
|
||||
assert!(body.contains(&format!(r#"data-copy-value="{RESET_URL}""#)));
|
||||
assert!(body.contains("Copy Link"));
|
||||
|
||||
let page = get(&app, &format!("/users/{TARGET_ID}?tab=account")).await;
|
||||
assert!(page.contains("Create Password Reset Link"));
|
||||
assert!(!page.contains(RESET_URL));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn an_htmx_reset_link_request_gets_only_the_result_fragment() {
|
||||
let app = setup(true, "username", vec!["*"]).await;
|
||||
let page = get(&app, &format!("/users/{TARGET_ID}?tab=account")).await;
|
||||
assert!(page.contains(r##"hx-target="#password-reset-link-result""##));
|
||||
assert!(page.contains(r#"hx-push-url="false""#));
|
||||
let csrf_token = csrf_token(&app).await;
|
||||
let (status, body) = post_form_with_headers(
|
||||
&app,
|
||||
&format!("/users/{TARGET_ID}?action=create_password_reset_link&tab=account"),
|
||||
&format!("_csrf={csrf_token}"),
|
||||
&[
|
||||
("HX-Request", "true"),
|
||||
("HX-Target", "password-reset-link-result"),
|
||||
],
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
assert!(
|
||||
body.starts_with(r#"<div id="password-reset-link-result""#),
|
||||
"{body}"
|
||||
);
|
||||
assert!(body.contains(r#"hx-history="false""#));
|
||||
assert!(body.contains(&format!(r#"data-copy-value="{RESET_URL}""#)));
|
||||
assert!(!body.contains("<html"));
|
||||
assert!(!body.contains("Create Password Reset Link"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn revoking_a_recovery_kit_calls_the_api() {
|
||||
let app = setup(true, "username", vec!["*"]).await;
|
||||
let page = get(&app, &format!("/users/{TARGET_ID}?tab=account")).await;
|
||||
assert!(page.contains("Revoke Recovery Kit"));
|
||||
let csrf_token = csrf_token(&app).await;
|
||||
let (status, _) = post_form(
|
||||
&app,
|
||||
&format!("/users/{TARGET_ID}?action=revoke_recovery_kit&tab=account"),
|
||||
&format!("_csrf={csrf_token}"),
|
||||
)
|
||||
.await;
|
||||
assert!(status.is_redirection() || status.is_success(), "{status}");
|
||||
assert!(app.saw(&format!("DELETE /admin/users/{TARGET_ID}/recovery-kit")));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn the_revoke_recovery_kit_action_needs_its_acl_and_a_username_instance() {
|
||||
let without_acl = setup(
|
||||
true,
|
||||
"username",
|
||||
vec![
|
||||
"admin:authenticate",
|
||||
"user:lookup",
|
||||
"user:create:password_reset_link",
|
||||
],
|
||||
)
|
||||
.await;
|
||||
let page = get(&without_acl, &format!("/users/{TARGET_ID}?tab=account")).await;
|
||||
assert!(page.contains("Create Password Reset Link"));
|
||||
assert!(!page.contains("Revoke Recovery Kit"));
|
||||
|
||||
let with_acl = setup(
|
||||
true,
|
||||
"username",
|
||||
vec![
|
||||
"admin:authenticate",
|
||||
"user:lookup",
|
||||
"user:delete:recovery_kit",
|
||||
],
|
||||
)
|
||||
.await;
|
||||
let page = get(&with_acl, &format!("/users/{TARGET_ID}?tab=account")).await;
|
||||
assert!(page.contains("Revoke Recovery Kit"));
|
||||
|
||||
let email = setup(true, "email", vec!["*"]).await;
|
||||
let page = get(&email, &format!("/users/{TARGET_ID}?tab=account")).await;
|
||||
assert!(!page.contains("Revoke Recovery Kit"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn username_instances_hide_email_actions_on_the_account_tab() {
|
||||
let app = setup(true, "username", vec!["*"]).await;
|
||||
let page = get(&app, &format!("/users/{TARGET_ID}?tab=account")).await;
|
||||
assert!(page.contains("Create Password Reset Link"));
|
||||
assert!(!page.contains("Send Password Reset"));
|
||||
assert!(!page.contains("Change Email"));
|
||||
assert!(!page.contains("Verify Email"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn the_reset_link_action_needs_its_acl() {
|
||||
let app = setup(true, "username", vec!["admin:authenticate", "user:lookup"]).await;
|
||||
let page = get(&app, &format!("/users/{TARGET_ID}?tab=account")).await;
|
||||
assert!(page.contains("Terminate All Sessions"));
|
||||
assert!(!page.contains("Create Password Reset Link"));
|
||||
assert!(!page.contains("Send Password Reset"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn email_instances_keep_the_email_actions() {
|
||||
let app = setup(true, "email", vec!["*"]).await;
|
||||
let page = get(&app, &format!("/users/{TARGET_ID}?tab=account")).await;
|
||||
assert!(page.contains("Send Password Reset"));
|
||||
assert!(page.contains("Change Email"));
|
||||
assert!(page.contains("Verify Email"));
|
||||
assert!(!page.contains("Create Password Reset Link"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn the_email_ban_notice_stays_after_a_ban_action_on_a_username_instance() {
|
||||
let notice = "Accounts have no email address, so email bans have no effect.";
|
||||
let username = setup(true, "username", vec!["*"]).await;
|
||||
let username_csrf = csrf_token(&username).await;
|
||||
let (status, body) = post_form(
|
||||
&username,
|
||||
"/email-bans?action=ban",
|
||||
&format!("_csrf={username_csrf}&email="),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
assert!(body.contains("Value is required"));
|
||||
assert!(body.contains(notice));
|
||||
|
||||
let email = setup(true, "email", vec!["*"]).await;
|
||||
let email_csrf = csrf_token(&email).await;
|
||||
let (_, body) = post_form(
|
||||
&email,
|
||||
"/email-bans?action=ban",
|
||||
&format!("_csrf={email_csrf}&email="),
|
||||
)
|
||||
.await;
|
||||
assert!(body.contains("Value is required"));
|
||||
assert!(!body.contains(notice));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn hosted_admin_never_asks_discovery_for_the_sign_in_method() {
|
||||
let app = setup(false, "username", vec!["*"]).await;
|
||||
let page = get(&app, &format!("/users/{TARGET_ID}?tab=account")).await;
|
||||
assert!(page.contains("Send Password Reset"));
|
||||
assert!(!page.contains("Create Password Reset Link"));
|
||||
assert!(!app.saw("GET /.well-known/fluxer"));
|
||||
}
|
||||
|
||||
struct TestApp {
|
||||
router: Router,
|
||||
session_cookie: String,
|
||||
requests: Arc<Mutex<Vec<String>>>,
|
||||
}
|
||||
|
||||
impl TestApp {
|
||||
fn saw(&self, route: &str) -> bool {
|
||||
self.requests
|
||||
.lock()
|
||||
.expect("requests")
|
||||
.iter()
|
||||
.any(|seen| seen == route)
|
||||
}
|
||||
}
|
||||
|
||||
async fn setup(
|
||||
self_hosted: bool,
|
||||
account_identity: &'static str,
|
||||
admin_acls: Vec<&'static str>,
|
||||
) -> TestApp {
|
||||
let requests = Arc::new(Mutex::new(Vec::new()));
|
||||
let api_endpoint = spawn_mock_api(MockApi {
|
||||
account_identity,
|
||||
admin_acls,
|
||||
requests: Arc::clone(&requests),
|
||||
})
|
||||
.await;
|
||||
let router = build_router(test_config(api_endpoint, self_hosted));
|
||||
let session_value = session::create_session(ADMIN_ID, "test-token", SECRET_KEY);
|
||||
TestApp {
|
||||
router,
|
||||
session_cookie: format!("{}={session_value}", session::SESSION_COOKIE_NAME),
|
||||
requests,
|
||||
}
|
||||
}
|
||||
|
||||
async fn get(app: &TestApp, uri: &str) -> String {
|
||||
let response = app
|
||||
.router
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method(Method::GET)
|
||||
.uri(uri)
|
||||
.header(header::COOKIE, &app.session_cookie)
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::OK, "{uri}");
|
||||
body_text(response).await
|
||||
}
|
||||
|
||||
async fn csrf_token(app: &TestApp) -> String {
|
||||
let response = app
|
||||
.router
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method(Method::GET)
|
||||
.uri(format!("/users/{TARGET_ID}?tab=account"))
|
||||
.header(header::COOKIE, &app.session_cookie)
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
response
|
||||
.headers()
|
||||
.get_all(header::SET_COOKIE)
|
||||
.iter()
|
||||
.filter_map(|value| value.to_str().ok())
|
||||
.find_map(|value| {
|
||||
let pair = value.split(';').next()?;
|
||||
let token = pair
|
||||
.strip_prefix("__Host-csrf_token=")
|
||||
.or_else(|| pair.strip_prefix("csrf_token="))?;
|
||||
(!token.is_empty()).then(|| token.to_owned())
|
||||
})
|
||||
.expect("csrf_token cookie")
|
||||
}
|
||||
|
||||
async fn post_form(app: &TestApp, uri: &str, body: &str) -> (StatusCode, String) {
|
||||
post_form_with_headers(app, uri, body, &[]).await
|
||||
}
|
||||
|
||||
async fn post_form_with_headers(
|
||||
app: &TestApp,
|
||||
uri: &str,
|
||||
body: &str,
|
||||
headers: &[(&str, &str)],
|
||||
) -> (StatusCode, String) {
|
||||
let csrf = body
|
||||
.split('&')
|
||||
.find_map(|pair| pair.strip_prefix("_csrf="))
|
||||
.expect("form carries a csrf token");
|
||||
let mut request = Request::builder()
|
||||
.method(Method::POST)
|
||||
.uri(uri)
|
||||
.header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
|
||||
.header(
|
||||
header::COOKIE,
|
||||
format!("{}; __Host-csrf_token={csrf}", app.session_cookie),
|
||||
);
|
||||
for (name, value) in headers {
|
||||
request = request.header(*name, *value);
|
||||
}
|
||||
let response = app
|
||||
.router
|
||||
.clone()
|
||||
.oneshot(request.body(Body::from(body.to_owned())).unwrap())
|
||||
.await
|
||||
.unwrap();
|
||||
let status = response.status();
|
||||
(status, body_text(response).await)
|
||||
}
|
||||
|
||||
async fn body_text(response: Response) -> String {
|
||||
let bytes = to_bytes(response.into_body(), usize::MAX).await.unwrap();
|
||||
String::from_utf8(bytes.to_vec()).unwrap()
|
||||
}
|
||||
|
||||
async fn spawn_mock_api(mock: MockApi) -> String {
|
||||
let listener = TcpListener::bind(("127.0.0.1", 0)).await.unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
tokio::spawn(async move {
|
||||
axum::serve(listener, Router::new().fallback(mock_api).with_state(mock))
|
||||
.await
|
||||
.unwrap();
|
||||
});
|
||||
format!("http://{addr}")
|
||||
}
|
||||
|
||||
async fn mock_api(State(mock): State<MockApi>, method: Method, uri: Uri) -> Response {
|
||||
let path = uri.path().to_owned();
|
||||
mock.requests
|
||||
.lock()
|
||||
.expect("requests")
|
||||
.push(format!("{method} {path}"));
|
||||
let target_user = format!("/admin/users/{TARGET_ID}");
|
||||
let target_sessions = format!("{target_user}/sessions");
|
||||
let target_credentials = format!("{target_user}/webauthn-credentials");
|
||||
let target_reset_link = format!("{target_user}/password-reset-link");
|
||||
let target_recovery_kit = format!("{target_user}/recovery-kit");
|
||||
match (method, path.as_str()) {
|
||||
(Method::GET, "/admin/users/@me") => Json(json!({
|
||||
"user": user(ADMIN_ID, "AdminUser", &mock.admin_acls)
|
||||
}))
|
||||
.into_response(),
|
||||
(Method::GET, "/.well-known/fluxer") => Json(json!({
|
||||
"features": {
|
||||
"premium_enabled": false,
|
||||
"account_identity": mock.account_identity
|
||||
}
|
||||
}))
|
||||
.into_response(),
|
||||
(Method::GET, p) if p == target_user => Json(json!({
|
||||
"users": [user(TARGET_ID, "member", &[])]
|
||||
}))
|
||||
.into_response(),
|
||||
(Method::GET, p) if p == target_sessions => Json(json!({ "sessions": [] })).into_response(),
|
||||
(Method::GET, p) if p == target_credentials => Json(json!([])).into_response(),
|
||||
(Method::POST, p) if p == target_reset_link => Json(json!({
|
||||
"url": RESET_URL,
|
||||
"expires_at": "2026-10-01T13:00:00.000Z"
|
||||
}))
|
||||
.into_response(),
|
||||
(Method::DELETE, p) if p == target_recovery_kit => StatusCode::NO_CONTENT.into_response(),
|
||||
_ => (
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(json!({ "message": "not found" })),
|
||||
)
|
||||
.into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
fn user(id: &str, username: &str, acls: &[&str]) -> Value {
|
||||
json!({
|
||||
"id": id,
|
||||
"username": username,
|
||||
"discriminator": 1,
|
||||
"avatar": null,
|
||||
"banner": null,
|
||||
"email": null,
|
||||
"email_verified": false,
|
||||
"email_bounced": false,
|
||||
"global_name": username,
|
||||
"bio": null,
|
||||
"pronouns": null,
|
||||
"accent_color": null,
|
||||
"date_of_birth": null,
|
||||
"locale": "en-GB",
|
||||
"acls": acls,
|
||||
"traits": [],
|
||||
"flags": "0",
|
||||
"premium_flags": 0,
|
||||
"bot": false,
|
||||
"system": false,
|
||||
"premium_type": null,
|
||||
"premium_since": null,
|
||||
"premium_until": null,
|
||||
"premium_grace_ends_at": null,
|
||||
"premium_lifetime_sequence": null,
|
||||
"has_totp": false,
|
||||
"authenticator_types": [],
|
||||
"temp_banned_until": null,
|
||||
"pending_deletion_at": null,
|
||||
"pending_bulk_message_deletion_at": null,
|
||||
"deletion_reason_code": null,
|
||||
"deletion_public_reason": null,
|
||||
"deletion_audit_log_reason": null,
|
||||
"deletion_scheduled_by": null,
|
||||
"deletion_scheduled_at": null,
|
||||
"last_active_at": null,
|
||||
"last_active_ip": null,
|
||||
"last_active_ip_reverse": null,
|
||||
"last_active_location": null
|
||||
})
|
||||
}
|
||||
|
||||
fn test_config(api_endpoint: String, self_hosted: bool) -> AdminConfig {
|
||||
AdminConfig {
|
||||
env: RuntimeEnv::Test,
|
||||
host: "127.0.0.1".to_owned(),
|
||||
port: 0,
|
||||
secret_key_base: SECRET_KEY.to_owned(),
|
||||
base_path: String::new(),
|
||||
api_endpoint,
|
||||
media_endpoint: "https://media.example.test".to_owned(),
|
||||
static_cdn_endpoint: "https://static.example.test".to_owned(),
|
||||
admin_endpoint: "https://admin.example.test".to_owned(),
|
||||
web_app_endpoint: "https://app.example.test".to_owned(),
|
||||
oauth_client_id: "admin-client".to_owned(),
|
||||
oauth_client_secret: "admin-secret".to_owned(),
|
||||
oauth_redirect_uri: "https://admin.example.test/callback".to_owned(),
|
||||
build_version: "test".to_owned(),
|
||||
self_hosted,
|
||||
proxy: ProxyConfig {
|
||||
trust_client_ip_header: false,
|
||||
client_ip_header_name: "x-forwarded-for".to_owned(),
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
#![recursion_limit = "256"]
|
||||
|
||||
use axum::{
|
||||
Json, Router,
|
||||
body::{Body, to_bytes},
|
||||
extract::State,
|
||||
http::{Method, Request, StatusCode, Uri, header},
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
use fluxer_admin::{
|
||||
build_router,
|
||||
config::{AdminConfig, ProxyConfig, RuntimeEnv},
|
||||
session,
|
||||
};
|
||||
use serde_json::{Value, json};
|
||||
use tokio::net::TcpListener;
|
||||
use tower::ServiceExt;
|
||||
|
||||
const SECRET_KEY: &str = "username-tags-test-secret";
|
||||
const ADMIN_ID: &str = "1500000000000000000";
|
||||
const TARGET_ID: &str = "1500000000000000042";
|
||||
const DISCRIMINATOR_INPUT: &str = r#"name="discriminator""#;
|
||||
|
||||
#[derive(Clone)]
|
||||
struct MockApi {
|
||||
account_identity: &'static str,
|
||||
unique_usernames: bool,
|
||||
target: Value,
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn username_instances_show_humans_without_a_tag() {
|
||||
let page = account_page(true, "username", user(TARGET_ID, "member", 0, false)).await;
|
||||
assert!(page.contains(r#"<p class="break-words text-sm text-neutral-500">member</p>"#));
|
||||
assert!(page.contains(r#"<div class="truncate text-neutral-500 text-xs">lilith</div>"#));
|
||||
assert!(!page.contains("member#0000"));
|
||||
assert!(!page.contains("lilith#0000"));
|
||||
assert!(!page.contains(DISCRIMINATOR_INPUT));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn email_instances_with_random_tags_show_tags_and_allow_tag_changes() {
|
||||
let page =
|
||||
account_page_with(true, "email", false, user(TARGET_ID, "member", 1234, false)).await;
|
||||
assert!(page.contains("member#1234"));
|
||||
assert!(page.contains(DISCRIMINATOR_INPUT));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn email_instances_with_no_tags_show_humans_without_a_tag() {
|
||||
let page = account_page_with(true, "email", true, user(TARGET_ID, "member", 0, false)).await;
|
||||
assert!(page.contains(r#"<p class="break-words text-sm text-neutral-500">member</p>"#));
|
||||
assert!(!page.contains("member#0000"));
|
||||
assert!(!page.contains("lilith#0000"));
|
||||
assert!(!page.contains(DISCRIMINATOR_INPUT));
|
||||
assert!(page.contains("Send Password Reset"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn username_instances_never_show_tags_even_if_told_random() {
|
||||
let page =
|
||||
account_page_with(true, "username", false, user(TARGET_ID, "member", 0, false)).await;
|
||||
assert!(!page.contains("member#0000"));
|
||||
assert!(!page.contains(DISCRIMINATOR_INPUT));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn username_instances_keep_bot_tags() {
|
||||
let page = account_page(true, "username", user(TARGET_ID, "helper", 4363, true)).await;
|
||||
assert!(page.contains("helper#4363"));
|
||||
assert!(page.contains(DISCRIMINATOR_INPUT));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn email_instances_keep_the_zero_tag() {
|
||||
let page = account_page(true, "email", user(TARGET_ID, "member", 0, false)).await;
|
||||
assert!(page.contains("member#0000"));
|
||||
assert!(page.contains("lilith#0000"));
|
||||
assert!(page.contains(DISCRIMINATOR_INPUT));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn hosted_admin_keeps_the_zero_tag() {
|
||||
let page = account_page(false, "username", user(TARGET_ID, "member", 0, false)).await;
|
||||
assert!(page.contains("member#0000"));
|
||||
assert!(page.contains(DISCRIMINATOR_INPUT));
|
||||
}
|
||||
|
||||
async fn account_page(self_hosted: bool, account_identity: &'static str, target: Value) -> String {
|
||||
account_page_with(
|
||||
self_hosted,
|
||||
account_identity,
|
||||
account_identity == "username",
|
||||
target,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn account_page_with(
|
||||
self_hosted: bool,
|
||||
account_identity: &'static str,
|
||||
unique_usernames: bool,
|
||||
target: Value,
|
||||
) -> String {
|
||||
let api_endpoint = spawn_mock_api(MockApi {
|
||||
account_identity,
|
||||
unique_usernames,
|
||||
target,
|
||||
})
|
||||
.await;
|
||||
let router = build_router(test_config(api_endpoint, self_hosted));
|
||||
let session_value = session::create_session(ADMIN_ID, "test-token", SECRET_KEY);
|
||||
let response = router
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method(Method::GET)
|
||||
.uri(format!("/users/{TARGET_ID}?tab=account"))
|
||||
.header(
|
||||
header::COOKIE,
|
||||
format!("{}={session_value}", session::SESSION_COOKIE_NAME),
|
||||
)
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let bytes = to_bytes(response.into_body(), usize::MAX).await.unwrap();
|
||||
String::from_utf8(bytes.to_vec()).unwrap()
|
||||
}
|
||||
|
||||
async fn spawn_mock_api(mock: MockApi) -> String {
|
||||
let listener = TcpListener::bind(("127.0.0.1", 0)).await.unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
tokio::spawn(async move {
|
||||
axum::serve(listener, Router::new().fallback(mock_api).with_state(mock))
|
||||
.await
|
||||
.unwrap();
|
||||
});
|
||||
format!("http://{addr}")
|
||||
}
|
||||
|
||||
async fn mock_api(State(mock): State<MockApi>, method: Method, uri: Uri) -> Response {
|
||||
let target_user = format!("/admin/users/{TARGET_ID}");
|
||||
let target_sessions = format!("{target_user}/sessions");
|
||||
let target_credentials = format!("{target_user}/webauthn-credentials");
|
||||
match (method, uri.path()) {
|
||||
(Method::GET, "/admin/users/@me") => Json(json!({
|
||||
"user": user(ADMIN_ID, "lilith", 0, false)
|
||||
}))
|
||||
.into_response(),
|
||||
(Method::GET, "/.well-known/fluxer") => Json(json!({
|
||||
"features": {
|
||||
"premium_enabled": false,
|
||||
"account_identity": mock.account_identity,
|
||||
"tag_style": if mock.unique_usernames { "none" } else { "random" }
|
||||
}
|
||||
}))
|
||||
.into_response(),
|
||||
(Method::GET, p) if p == target_user => {
|
||||
Json(json!({ "users": [mock.target] })).into_response()
|
||||
}
|
||||
(Method::GET, p) if p == target_sessions => Json(json!({ "sessions": [] })).into_response(),
|
||||
(Method::GET, p) if p == target_credentials => Json(json!([])).into_response(),
|
||||
_ => (
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(json!({ "message": "not found" })),
|
||||
)
|
||||
.into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
fn user(id: &str, username: &str, discriminator: u16, bot: bool) -> Value {
|
||||
json!({
|
||||
"id": id,
|
||||
"username": username,
|
||||
"discriminator": discriminator,
|
||||
"avatar": null,
|
||||
"banner": null,
|
||||
"email": null,
|
||||
"email_verified": false,
|
||||
"email_bounced": false,
|
||||
"global_name": null,
|
||||
"bio": null,
|
||||
"pronouns": null,
|
||||
"accent_color": null,
|
||||
"date_of_birth": null,
|
||||
"locale": "en-GB",
|
||||
"acls": ["*"],
|
||||
"traits": [],
|
||||
"flags": "0",
|
||||
"premium_flags": 0,
|
||||
"bot": bot,
|
||||
"system": false,
|
||||
"premium_type": null,
|
||||
"premium_since": null,
|
||||
"premium_until": null,
|
||||
"premium_grace_ends_at": null,
|
||||
"premium_lifetime_sequence": null,
|
||||
"has_totp": false,
|
||||
"authenticator_types": [],
|
||||
"temp_banned_until": null,
|
||||
"pending_deletion_at": null,
|
||||
"pending_bulk_message_deletion_at": null,
|
||||
"deletion_reason_code": null,
|
||||
"deletion_public_reason": null,
|
||||
"deletion_audit_log_reason": null,
|
||||
"deletion_scheduled_by": null,
|
||||
"deletion_scheduled_at": null,
|
||||
"last_active_at": null,
|
||||
"last_active_ip": null,
|
||||
"last_active_ip_reverse": null,
|
||||
"last_active_location": null
|
||||
})
|
||||
}
|
||||
|
||||
fn test_config(api_endpoint: String, self_hosted: bool) -> AdminConfig {
|
||||
AdminConfig {
|
||||
env: RuntimeEnv::Test,
|
||||
host: "127.0.0.1".to_owned(),
|
||||
port: 0,
|
||||
secret_key_base: SECRET_KEY.to_owned(),
|
||||
base_path: String::new(),
|
||||
api_endpoint,
|
||||
media_endpoint: "https://media.example.test".to_owned(),
|
||||
static_cdn_endpoint: "https://static.example.test".to_owned(),
|
||||
admin_endpoint: "https://admin.example.test".to_owned(),
|
||||
web_app_endpoint: "https://app.example.test".to_owned(),
|
||||
oauth_client_id: "admin-client".to_owned(),
|
||||
oauth_client_secret: "admin-secret".to_owned(),
|
||||
oauth_redirect_uri: "https://admin.example.test/callback".to_owned(),
|
||||
build_version: "test".to_owned(),
|
||||
self_hosted,
|
||||
proxy: ProxyConfig {
|
||||
trust_client_ip_header: false,
|
||||
client_ip_header_name: "x-forwarded-for".to_owned(),
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,14 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
type ElasticsearchFieldType = 'text' | 'keyword' | 'boolean' | 'long' | 'integer' | 'date' | 'float';
|
||||
export type FluxerSearchIndexName = 'messages' | 'guilds' | 'users' | 'reports' | 'audit_logs' | 'guild_members';
|
||||
export type FluxerSearchIndexName =
|
||||
| 'messages'
|
||||
| 'guilds'
|
||||
| 'users'
|
||||
| 'reports'
|
||||
| 'audit_logs'
|
||||
| 'guild_members'
|
||||
| 'threads';
|
||||
|
||||
export interface ElasticsearchFieldMapping {
|
||||
type: ElasticsearchFieldType;
|
||||
@@ -173,6 +180,26 @@ export const ELASTICSEARCH_INDEX_DEFINITIONS: Record<FluxerSearchIndexName, Elas
|
||||
},
|
||||
},
|
||||
},
|
||||
threads: {
|
||||
indexName: 'threads',
|
||||
mappings: {
|
||||
properties: {
|
||||
id: keyword(),
|
||||
guildId: keyword(),
|
||||
parentId: keyword(),
|
||||
type: integer(),
|
||||
name: textWithKeyword(),
|
||||
ownerId: keyword(),
|
||||
archived: bool(),
|
||||
locked: bool(),
|
||||
appliedTagIds: keyword(),
|
||||
createdAt: long(),
|
||||
idSequence: long(),
|
||||
lastMessageAt: long(),
|
||||
archivedAt: long(),
|
||||
},
|
||||
},
|
||||
},
|
||||
audit_logs: {
|
||||
indexName: 'audit_logs',
|
||||
mappings: {
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import type {Client} from '@elastic/elasticsearch';
|
||||
import type {SortCombinations} from '@elastic/elasticsearch/lib/api/types';
|
||||
import type {
|
||||
SearchableThread,
|
||||
ThreadSearchCursor,
|
||||
ThreadSearchFilters,
|
||||
} from '@fluxer/schema/src/contracts/search/SearchDocumentTypes';
|
||||
import type {ElasticsearchDistributedLock} from '@pkgs/elasticsearch_search/src/adapters/ElasticsearchIndexAdapter';
|
||||
import {ElasticsearchIndexAdapter} from '@pkgs/elasticsearch_search/src/adapters/ElasticsearchIndexAdapter';
|
||||
import type {ElasticsearchFilter} from '@pkgs/elasticsearch_search/src/ElasticsearchFilterUtils';
|
||||
import {
|
||||
compactFilters,
|
||||
esAndTerms,
|
||||
esRangeFilter,
|
||||
esTermFilter,
|
||||
esTermsFilter,
|
||||
} from '@pkgs/elasticsearch_search/src/ElasticsearchFilterUtils';
|
||||
import {ELASTICSEARCH_INDEX_DEFINITIONS} from '@pkgs/elasticsearch_search/src/ElasticsearchIndexDefinitions';
|
||||
|
||||
const SORT_FIELDS = {
|
||||
last_message_time: 'lastMessageAt',
|
||||
archive_time: 'archivedAt',
|
||||
creation_time: 'createdAt',
|
||||
} as const;
|
||||
|
||||
function cursorFilter(cursor: ThreadSearchCursor, op: 'gt' | 'lt'): ElasticsearchFilter {
|
||||
return {
|
||||
bool: {
|
||||
should: [
|
||||
esRangeFilter('createdAt', {[op]: cursor.createdAt}),
|
||||
{
|
||||
bool: {
|
||||
filter: [
|
||||
esTermFilter('createdAt', cursor.createdAt),
|
||||
esRangeFilter('idSequence', {[op]: cursor.idSequence}),
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
minimum_should_match: 1,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function buildThreadFilters(filters: ThreadSearchFilters): Array<ElasticsearchFilter | undefined> {
|
||||
const clauses: Array<ElasticsearchFilter | undefined> = [
|
||||
esTermFilter('guildId', filters.guildId),
|
||||
esTermFilter('parentId', filters.parentId),
|
||||
];
|
||||
if (filters.publicOnly) {
|
||||
clauses.push(
|
||||
filters.privateThreadIds && filters.privateThreadIds.length > 0
|
||||
? {
|
||||
bool: {
|
||||
should: [esTermsFilter('type', [10, 11]), esTermsFilter('id', filters.privateThreadIds)],
|
||||
minimum_should_match: 1,
|
||||
},
|
||||
}
|
||||
: esTermsFilter('type', [10, 11]),
|
||||
);
|
||||
}
|
||||
if (filters.archived !== undefined) clauses.push(esTermFilter('archived', filters.archived));
|
||||
if (filters.tagIds && filters.tagIds.length > 0) {
|
||||
if (filters.tagSetting === 'match_all') clauses.push(...esAndTerms('appliedTagIds', filters.tagIds));
|
||||
else clauses.push(esTermsFilter('appliedTagIds', filters.tagIds));
|
||||
}
|
||||
if (filters.after) clauses.push(cursorFilter(filters.after, 'gt'));
|
||||
if (filters.before) clauses.push(cursorFilter(filters.before, 'lt'));
|
||||
return compactFilters(clauses);
|
||||
}
|
||||
|
||||
function buildThreadSort(filters: ThreadSearchFilters): Array<SortCombinations> | undefined {
|
||||
const sortBy = filters.sortBy ?? 'last_message_time';
|
||||
if (sortBy === 'relevance') return undefined;
|
||||
const order = filters.sortOrder ?? 'desc';
|
||||
return [...new Set([SORT_FIELDS[sortBy], 'createdAt', 'idSequence'])].map((field) => ({[field]: {order}}));
|
||||
}
|
||||
|
||||
export interface ElasticsearchThreadAdapterOptions {
|
||||
client: Client;
|
||||
lock?: ElasticsearchDistributedLock;
|
||||
}
|
||||
|
||||
export class ElasticsearchThreadAdapter extends ElasticsearchIndexAdapter<ThreadSearchFilters, SearchableThread> {
|
||||
constructor(options: ElasticsearchThreadAdapterOptions) {
|
||||
super({
|
||||
client: options.client,
|
||||
index: ELASTICSEARCH_INDEX_DEFINITIONS.threads,
|
||||
searchableFields: ['name'],
|
||||
buildFilters: buildThreadFilters,
|
||||
buildSort: buildThreadSort,
|
||||
lock: options.lock,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,7 @@ import {Hono} from 'hono';
|
||||
interface CreateAPIAppOptions {
|
||||
config: APIConfig;
|
||||
logger: ILogger;
|
||||
registerRoutes?: (routes: HonoApp) => void;
|
||||
}
|
||||
|
||||
interface APIAppResult {
|
||||
@@ -53,6 +54,7 @@ export async function createAPIApp(options: CreateAPIAppOptions): Promise<APIApp
|
||||
routes.onError(TelemetryAwareAppErrorHandler);
|
||||
routes.notFound(AppNotFoundHandler);
|
||||
registerControllers(routes, config);
|
||||
options.registerRoutes?.(routes);
|
||||
const app = new Hono<HonoEnv>({strict: true});
|
||||
const {middleware: metricsMiddleware, metricsHandler} = createMetricsMiddleware('api');
|
||||
app.use('*', metricsMiddleware);
|
||||
|
||||
@@ -187,6 +187,7 @@ export function buildAPIConfigFromMaster(master: MasterConfig): APIConfig {
|
||||
headersTimeoutMs: master.services.api.headers_timeout_ms,
|
||||
requestTimeoutMs: master.services.api.request_timeout_ms,
|
||||
maxInflightRequests: master.services.api.max_inflight_requests,
|
||||
automatedMessageDeletionDelayDays: master.services.api.automated_message_deletion_delay_days,
|
||||
ipBanExemptIps: normalizeIpBanExemptIps(master.services.api.ip_ban_exempt_ips),
|
||||
cassandra: {
|
||||
hosts: cassandraSource?.hosts.join(',') ?? '',
|
||||
@@ -391,6 +392,7 @@ export function buildAPIConfigFromMaster(master: MasterConfig): APIConfig {
|
||||
auth: {
|
||||
sudoModeSecret: master.auth.sudo_mode_secret,
|
||||
connectionInitiationSecret: master.auth.connection_initiation_secret,
|
||||
profilePseudonymSecret: master.auth.profile_pseudonym_secret,
|
||||
ssoAllowPrivateAddresses: master.auth.sso_allow_private_addresses,
|
||||
passkeys: {
|
||||
rpName: master.auth.passkeys.rp_name,
|
||||
@@ -412,6 +414,7 @@ export function buildAPIConfigFromMaster(master: MasterConfig): APIConfig {
|
||||
},
|
||||
instance: {
|
||||
selfHosted: master.instance.self_hosted,
|
||||
baseDomain: master.domain.base_domain,
|
||||
autoJoinInviteCode: master.instance.auto_join_invite_code,
|
||||
visionariesGuildId: master.instance.visionaries_guild_id,
|
||||
visionariesGuildVisionaryRoleId: master.instance.visionaries_guild_visionary_role_id,
|
||||
@@ -429,6 +432,8 @@ export function buildAPIConfigFromMaster(master: MasterConfig): APIConfig {
|
||||
setup: {
|
||||
configured: master.instance.setup.configured,
|
||||
},
|
||||
accountIdentity: master.instance.account_identity,
|
||||
tagStyle: master.instance.tag_style,
|
||||
},
|
||||
discovery: {
|
||||
enabled: master.discovery.enabled,
|
||||
@@ -490,6 +495,7 @@ export function buildAPIConfigFromMaster(master: MasterConfig): APIConfig {
|
||||
laneName: apiWorkerConfig?.lane,
|
||||
taskName: apiWorkerConfig?.task as WorkerTaskName | undefined,
|
||||
enableCronScheduler: apiWorkerConfig?.enable_cron_scheduler,
|
||||
metricsPort: apiWorkerConfig?.metrics_port,
|
||||
laneConcurrencyOverrides: {
|
||||
realtime: apiWorkerConfig?.lane_concurrency_overrides?.realtime,
|
||||
unfurl: apiWorkerConfig?.lane_concurrency_overrides?.unfurl,
|
||||
|
||||
@@ -12,6 +12,7 @@ import type {IGuildSearchService} from '@app/api/search/IGuildSearchService';
|
||||
import type {IMessageSearchService} from '@app/api/search/IMessageSearchService';
|
||||
import type {IReportSearchService} from '@app/api/search/IReportSearchService';
|
||||
import type {ISearchProvider} from '@app/api/search/ISearchProvider';
|
||||
import type {IThreadSearchService} from '@app/api/search/IThreadSearchService';
|
||||
import type {IUserSearchService} from '@app/api/search/IUserSearchService';
|
||||
import {DEFAULT_SEARCH_CLIENT_TIMEOUT_MS} from '@fluxer/constants/src/Timeouts';
|
||||
import type {ElasticsearchDistributedLock} from '@pkgs/elasticsearch_search/src/adapters/ElasticsearchIndexAdapter';
|
||||
@@ -87,6 +88,10 @@ export function getGuildMemberSearchService(): IGuildMemberSearchService | null
|
||||
return searchProvider?.getGuildMemberSearchService() ?? null;
|
||||
}
|
||||
|
||||
export function getThreadSearchService(): IThreadSearchService | null {
|
||||
return searchProvider?.getThreadSearchService() ?? null;
|
||||
}
|
||||
|
||||
export async function initializeSearch(lock?: ElasticsearchDistributedLock): Promise<void> {
|
||||
if (searchProvider) {
|
||||
await shutdownSearch();
|
||||
|
||||
@@ -63,7 +63,9 @@ import {
|
||||
PASSWORD_RESET_TOKEN_COLUMNS,
|
||||
type PasswordChangeTicketRow,
|
||||
type PasswordResetTokenRow,
|
||||
USER_RECOVERY_KIT_COLUMNS,
|
||||
USER_SSO_IDENTITY_COLUMNS,
|
||||
type UserRecoveryKitRow,
|
||||
type UserSsoIdentityRow,
|
||||
WEBAUTHN_CREDENTIAL_COLUMNS,
|
||||
type WebAuthnCredentialRow,
|
||||
@@ -139,6 +141,8 @@ import {
|
||||
type InviteRow,
|
||||
PRIVATE_CHANNEL_COLUMNS,
|
||||
type PrivateChannelRow,
|
||||
READ_STATE_COLUMNS,
|
||||
type ReadStateRow,
|
||||
WEBHOOK_COLUMNS,
|
||||
WEBHOOKS_BY_SOURCE_CHANNEL_COLUMNS,
|
||||
type WebhookRow,
|
||||
@@ -266,6 +270,30 @@ import {
|
||||
type StorePurchaseByUserRow,
|
||||
type StorePurchaseRow,
|
||||
} from '@app/api/database/types/StoreBillingTypes';
|
||||
import {
|
||||
ACTIVE_THREADS_BY_GUILD_COLUMNS,
|
||||
type ActiveThreadsByGuildRow,
|
||||
ARCHIVED_THREADS_BY_PARENT_COLUMNS,
|
||||
type ArchivedThreadsByParentRow,
|
||||
FORUM_PINNED_THREAD_COLUMNS,
|
||||
type ForumPinnedThreadRow,
|
||||
GUILD_THREAD_STATE_COLUMNS,
|
||||
type GuildThreadStateRow,
|
||||
THREAD_MEMBER_COLUMNS,
|
||||
THREAD_MEMBERS_BY_USER_COLUMNS,
|
||||
THREAD_ONLY_CHANNELS_BY_GUILD_COLUMNS,
|
||||
THREAD_PARENT_CONFIG_COLUMNS,
|
||||
THREAD_STATE_COLUMNS,
|
||||
THREAD_STATS_COLUMNS,
|
||||
THREADS_BY_PARENT_COLUMNS,
|
||||
type ThreadMemberRow,
|
||||
type ThreadMembersByUserRow,
|
||||
type ThreadOnlyChannelsByGuildRow,
|
||||
type ThreadParentConfigRow,
|
||||
type ThreadStateRow,
|
||||
type ThreadStatsRow,
|
||||
type ThreadsByParentRow,
|
||||
} from '@app/api/database/types/ThreadTypes';
|
||||
import {
|
||||
FAVORITE_MEME_COLUMNS,
|
||||
type FavoriteMemeRow,
|
||||
@@ -577,6 +605,84 @@ export const DmStates = defineTable<DmStateRow, 'hi_user_id' | 'lo_user_id' | 'c
|
||||
columns: DM_STATE_COLUMNS,
|
||||
primaryKey: ['hi_user_id', 'lo_user_id', 'channel_id'],
|
||||
});
|
||||
export const ThreadState = defineTable<ThreadStateRow, 'thread_id'>({
|
||||
name: 'thread_state',
|
||||
columns: THREAD_STATE_COLUMNS,
|
||||
primaryKey: ['thread_id'],
|
||||
partitionKey: ['thread_id'],
|
||||
});
|
||||
export const ThreadStats = defineTable<ThreadStatsRow, 'thread_id'>({
|
||||
name: 'thread_stats',
|
||||
columns: THREAD_STATS_COLUMNS,
|
||||
primaryKey: ['thread_id'],
|
||||
partitionKey: ['thread_id'],
|
||||
});
|
||||
export const ThreadsByParent = defineTable<ThreadsByParentRow, 'parent_id' | 'thread_id', 'parent_id'>({
|
||||
name: 'threads_by_parent',
|
||||
columns: THREADS_BY_PARENT_COLUMNS,
|
||||
primaryKey: ['parent_id', 'thread_id'],
|
||||
partitionKey: ['parent_id'],
|
||||
});
|
||||
export const ActiveThreadsByGuild = defineTable<ActiveThreadsByGuildRow, 'guild_id' | 'thread_id', 'guild_id'>({
|
||||
name: 'active_threads_by_guild',
|
||||
columns: ACTIVE_THREADS_BY_GUILD_COLUMNS,
|
||||
primaryKey: ['guild_id', 'thread_id'],
|
||||
partitionKey: ['guild_id'],
|
||||
});
|
||||
export const ArchivedThreadsByParent = defineTable<
|
||||
ArchivedThreadsByParentRow,
|
||||
'parent_id' | 'is_private' | 'archive_timestamp' | 'thread_id',
|
||||
'parent_id' | 'is_private'
|
||||
>({
|
||||
name: 'archived_threads_by_parent',
|
||||
columns: ARCHIVED_THREADS_BY_PARENT_COLUMNS,
|
||||
primaryKey: ['parent_id', 'is_private', 'archive_timestamp', 'thread_id'],
|
||||
partitionKey: ['parent_id', 'is_private'],
|
||||
});
|
||||
export const ThreadMembers = defineTable<ThreadMemberRow, 'thread_id' | 'user_id', 'thread_id'>({
|
||||
name: 'thread_members',
|
||||
columns: THREAD_MEMBER_COLUMNS,
|
||||
primaryKey: ['thread_id', 'user_id'],
|
||||
partitionKey: ['thread_id'],
|
||||
});
|
||||
export const ThreadMembersByUser = defineTable<
|
||||
ThreadMembersByUserRow,
|
||||
'user_id' | 'guild_id' | 'parent_id' | 'is_private' | 'thread_id',
|
||||
'user_id'
|
||||
>({
|
||||
name: 'thread_members_by_user',
|
||||
columns: THREAD_MEMBERS_BY_USER_COLUMNS,
|
||||
primaryKey: ['user_id', 'guild_id', 'parent_id', 'is_private', 'thread_id'],
|
||||
partitionKey: ['user_id'],
|
||||
});
|
||||
export const ThreadParentConfig = defineTable<ThreadParentConfigRow, 'guild_id' | 'channel_id', 'guild_id'>({
|
||||
name: 'thread_parent_config',
|
||||
columns: THREAD_PARENT_CONFIG_COLUMNS,
|
||||
primaryKey: ['guild_id', 'channel_id'],
|
||||
partitionKey: ['guild_id'],
|
||||
});
|
||||
export const ForumPinnedThread = defineTable<ForumPinnedThreadRow, 'parent_id'>({
|
||||
name: 'forum_pinned_thread',
|
||||
columns: FORUM_PINNED_THREAD_COLUMNS,
|
||||
primaryKey: ['parent_id'],
|
||||
partitionKey: ['parent_id'],
|
||||
});
|
||||
export const ThreadOnlyChannelsByGuild = defineTable<
|
||||
ThreadOnlyChannelsByGuildRow,
|
||||
'guild_id' | 'channel_id',
|
||||
'guild_id'
|
||||
>({
|
||||
name: 'thread_only_channels_by_guild',
|
||||
columns: THREAD_ONLY_CHANNELS_BY_GUILD_COLUMNS,
|
||||
primaryKey: ['guild_id', 'channel_id'],
|
||||
partitionKey: ['guild_id'],
|
||||
});
|
||||
export const GuildThreadState = defineTable<GuildThreadStateRow, 'guild_id'>({
|
||||
name: 'guild_thread_state',
|
||||
columns: GUILD_THREAD_STATE_COLUMNS,
|
||||
primaryKey: ['guild_id'],
|
||||
partitionKey: ['guild_id'],
|
||||
});
|
||||
|
||||
interface PinnedDmRow {
|
||||
user_id: bigint;
|
||||
@@ -591,12 +697,6 @@ export const PinnedDms = defineTable<PinnedDmRow, 'user_id' | 'channel_id'>({
|
||||
primaryKey: ['user_id', 'channel_id'],
|
||||
});
|
||||
|
||||
interface ReadStateRow {
|
||||
user_id: bigint;
|
||||
channel_id: bigint;
|
||||
}
|
||||
|
||||
const READ_STATE_COLUMNS = ['user_id', 'channel_id'] as const satisfies ReadonlyArray<keyof ReadStateRow>;
|
||||
export const ReadStates = defineTable<ReadStateRow, 'user_id' | 'channel_id'>({
|
||||
name: 'read_states',
|
||||
columns: READ_STATE_COLUMNS,
|
||||
@@ -893,6 +993,11 @@ export const MfaBackupCodes = defineTable<MfaBackupCodeRow, 'user_id' | 'code'>(
|
||||
columns: MFA_BACKUP_CODE_COLUMNS,
|
||||
primaryKey: ['user_id', 'code'],
|
||||
});
|
||||
export const UserRecoveryKits = defineTable<UserRecoveryKitRow, 'user_id'>({
|
||||
name: 'user_recovery_kits',
|
||||
columns: USER_RECOVERY_KIT_COLUMNS,
|
||||
primaryKey: ['user_id'],
|
||||
});
|
||||
export const WebAuthnCredentials = defineTable<WebAuthnCredentialRow, 'user_id' | 'credential_id'>({
|
||||
name: 'webauthn_credentials',
|
||||
columns: WEBAUTHN_CREDENTIAL_COLUMNS,
|
||||
|
||||
@@ -96,6 +96,9 @@ type PreHook<E extends Env, P extends string, Target extends keyof ValidationTar
|
||||
c: Context<E, P, V>,
|
||||
target: Target,
|
||||
) => unknown | Promise<unknown>;
|
||||
type SchemaSelector<T extends ZodType, E extends Env, P extends string, V extends Input> = (
|
||||
c: Context<E, P, V>,
|
||||
) => ZodType<output<T>> | null | Promise<ZodType<output<T>> | null>;
|
||||
type ValidatorOptions<
|
||||
T extends ZodType,
|
||||
E extends Env,
|
||||
@@ -104,6 +107,7 @@ type ValidatorOptions<
|
||||
V extends Input,
|
||||
> = {
|
||||
pre?: PreHook<E, P, Target, V>;
|
||||
schemaFor?: SchemaSelector<T, E, P, V>;
|
||||
post?: Hook<T, E, P, Target, V>;
|
||||
};
|
||||
|
||||
@@ -211,8 +215,9 @@ export const Validator = <
|
||||
if (options.pre) {
|
||||
value = await options.pre(value, c, target);
|
||||
}
|
||||
const transformedValue = convertEmptyValuesToNull(value, schema);
|
||||
const result = await schema.safeParseAsync(transformedValue);
|
||||
const activeSchema = (await options.schemaFor?.(c)) ?? (schema as ZodType<output<T>>);
|
||||
const transformedValue = convertEmptyValuesToNull(value, activeSchema);
|
||||
const result = await activeSchema.safeParseAsync(transformedValue);
|
||||
if (options.post) {
|
||||
const hookResult = await options.post({...result, target}, c);
|
||||
if (hookResult) {
|
||||
|
||||
@@ -38,6 +38,7 @@ export const AdminAuditReadActions = {
|
||||
LIST_GUILD_MEMBERS: 'list_guild_members',
|
||||
LIST_GUILD_MEMORY_STATS: 'list_guild_memory_stats',
|
||||
LIST_GUILD_STICKERS: 'list_guild_stickers',
|
||||
LIST_GUILD_THREADS: 'list_guild_threads',
|
||||
LIST_USER_APPLICATIONS: 'list_user_applications',
|
||||
LIST_USER_CHANGE_LOG: 'list_user_change_log',
|
||||
LIST_USER_DM_CHANNELS: 'list_user_dm_channels',
|
||||
|
||||
@@ -4,6 +4,7 @@ import {AdminAuditReadActions} from '@app/api/admin/AdminAuditActions';
|
||||
import {recordAdminRead, recordAdminWrite} from '@app/api/admin/AdminAuditRecorder';
|
||||
import {createUserID} from '@app/api/BrandedTypes';
|
||||
import {Config} from '@app/api/Config';
|
||||
import {enqueueRebuildThreadAutoArchiveQueue, enqueueThreadSearchBackfill} from '@app/api/channel/threads/ThreadJobs';
|
||||
import {
|
||||
type InstancePolicyConfig,
|
||||
REGISTRATION_PENDING_APPROVAL_TRAIT,
|
||||
@@ -14,6 +15,7 @@ import {requireAdminACL} from '@app/api/middleware/AdminMiddleware';
|
||||
import {RateLimitMiddleware} from '@app/api/middleware/RateLimitMiddleware';
|
||||
import {OpenAPI} from '@app/api/middleware/ResponseTypeMiddleware';
|
||||
import {
|
||||
getChannelThreadsConfigPublisher,
|
||||
getGatewayRolloutConfigPublisher,
|
||||
getInstanceConfigRepository,
|
||||
getPushRelayConfigPublisher,
|
||||
@@ -21,6 +23,11 @@ import {
|
||||
import {RateLimitConfigs} from '@app/api/RateLimitConfig';
|
||||
import type {HonoApp, HonoEnv} from '@app/api/types/HonoEnv';
|
||||
import {Validator} from '@app/api/Validator';
|
||||
import {
|
||||
enabledThreadGuildIds,
|
||||
enqueueThreadPermissionSeeds,
|
||||
newlyEnabledThreadGuildIds,
|
||||
} from '@app/api/worker/tasks/SeedThreadPermissions';
|
||||
import {AdminACLs} from '@fluxer/constants/src/AdminACLs';
|
||||
import {InputValidationError} from '@fluxer/errors/src/domains/core/InputValidationError';
|
||||
import {InstancePolicyTransitionNotAllowedError} from '@fluxer/errors/src/domains/core/InstancePolicyTransitionNotAllowedError';
|
||||
@@ -35,6 +42,10 @@ import {
|
||||
PendingRegistrationActionRequest,
|
||||
RegistrationUrlIdParam,
|
||||
} from '@fluxer/schema/src/domains/admin/AdminSchemas';
|
||||
import {
|
||||
applyChannelThreadsConfigUpdate,
|
||||
type ChannelThreadsConfig,
|
||||
} from '@fluxer/schema/src/domains/admin/ChannelThreadsSchemas';
|
||||
import {DomainMigrationConfigSchema} from '@fluxer/schema/src/domains/admin/DomainMigrationSchemas';
|
||||
import {GatewayRolloutConfigSchema} from '@fluxer/schema/src/domains/admin/GatewayRolloutSchemas';
|
||||
import {PlutoniumPageConfigSchema} from '@fluxer/schema/src/domains/admin/PlutoniumPageSchemas';
|
||||
@@ -69,6 +80,7 @@ async function buildInstanceConfigResponse(): Promise<InstanceConfigResponse> {
|
||||
domainMigration,
|
||||
plutoniumPage,
|
||||
captcha,
|
||||
channelThreads,
|
||||
experimentDelivery,
|
||||
registrationConfig,
|
||||
registrationUrls,
|
||||
@@ -80,19 +92,23 @@ async function buildInstanceConfigResponse(): Promise<InstanceConfigResponse> {
|
||||
instanceConfigRepository.getDomainMigrationConfig(),
|
||||
instanceConfigRepository.getPlutoniumPageConfig(),
|
||||
instanceConfigRepository.getCaptchaConfig(),
|
||||
instanceConfigRepository.getChannelThreadsConfig(),
|
||||
instanceConfigRepository.getExperimentDeliveryConfig(),
|
||||
instanceConfigRepository.getRegistrationConfig(),
|
||||
instanceConfigRepository.getRegistrationUrlsForAdmin(),
|
||||
instanceConfigRepository.getPendingRegistrations(),
|
||||
]);
|
||||
const [appPublic, policy, resolvedServices, integrations, media, billing] = await Promise.all([
|
||||
instanceConfigRepository.getAppPublicConfig(),
|
||||
instanceConfigRepository.getInstancePolicyConfig(),
|
||||
instanceConfigRepository.getResolvedServicesConfig(),
|
||||
instanceConfigRepository.getInstanceIntegrationsAdminConfig(),
|
||||
instanceConfigRepository.getInstanceMediaAdminConfig(),
|
||||
instanceConfigRepository.getInstanceBillingAdminConfig(),
|
||||
]);
|
||||
const [appPublic, policy, resolvedServices, integrations, media, billing, accountIdentity, accountIdentityLocked] =
|
||||
await Promise.all([
|
||||
instanceConfigRepository.getAppPublicConfig(),
|
||||
instanceConfigRepository.getInstancePolicyConfig(),
|
||||
instanceConfigRepository.getResolvedServicesConfig(),
|
||||
instanceConfigRepository.getInstanceIntegrationsAdminConfig(),
|
||||
instanceConfigRepository.getInstanceMediaAdminConfig(),
|
||||
instanceConfigRepository.getInstanceBillingAdminConfig(),
|
||||
instanceConfigRepository.getAccountIdentity(),
|
||||
instanceConfigRepository.isAccountIdentityLocked(),
|
||||
]);
|
||||
return {
|
||||
sso: {
|
||||
enabled: ssoConfig.enabled,
|
||||
@@ -115,6 +131,7 @@ async function buildInstanceConfigResponse(): Promise<InstanceConfigResponse> {
|
||||
domain_migration: domainMigration,
|
||||
plutonium_page: plutoniumPage,
|
||||
captcha,
|
||||
channel_threads: channelThreads,
|
||||
experiment_delivery: experimentDelivery,
|
||||
registration: {
|
||||
...registrationConfig,
|
||||
@@ -122,6 +139,11 @@ async function buildInstanceConfigResponse(): Promise<InstanceConfigResponse> {
|
||||
pending_registrations: pendingRegistrations,
|
||||
},
|
||||
self_hosted: Config.instance.selfHosted,
|
||||
account_identity: {
|
||||
mode: accountIdentity.mode,
|
||||
locked: accountIdentityLocked,
|
||||
tag_style: accountIdentity.tagStyle,
|
||||
},
|
||||
app_public: appPublic,
|
||||
policy: {
|
||||
single_community_enabled: policy.single_community_enabled,
|
||||
@@ -410,6 +432,25 @@ export function InstanceConfigAdminController(app: HonoApp) {
|
||||
await instanceConfigRepository.updateCaptchaConfig(patch);
|
||||
}
|
||||
}
|
||||
let channelThreadsConfigVersion: number | undefined;
|
||||
if (data.channel_threads) {
|
||||
const patch = omitUndefinedFields(data.channel_threads);
|
||||
if (Object.keys(patch).length > 0) {
|
||||
let previous: ChannelThreadsConfig | undefined;
|
||||
const landed = await instanceConfigRepository.updateChannelThreadsConfig((current) => {
|
||||
previous = current;
|
||||
return applyChannelThreadsConfigUpdate(current, patch);
|
||||
});
|
||||
channelThreadsConfigVersion = landed.config_version;
|
||||
await enqueueThreadPermissionSeeds(ctx.get('channelRepository').threads, landed);
|
||||
const newlyEnabled = previous ? newlyEnabledThreadGuildIds(previous, landed) : enabledThreadGuildIds(landed);
|
||||
for (const guildId of newlyEnabled) {
|
||||
await enqueueRebuildThreadAutoArchiveQueue(guildId);
|
||||
await enqueueThreadSearchBackfill(guildId);
|
||||
}
|
||||
await getChannelThreadsConfigPublisher().publish(landed);
|
||||
}
|
||||
}
|
||||
if (data.experiment_delivery) {
|
||||
const patch = data.experiment_delivery;
|
||||
await instanceConfigRepository.updateExperimentDeliveryConfig((current) =>
|
||||
@@ -605,6 +646,7 @@ export function InstanceConfigAdminController(app: HonoApp) {
|
||||
action: 'update_instance_config',
|
||||
metadata: {
|
||||
sections: listSuppliedSections(data),
|
||||
channel_threads_config_version: channelThreadsConfigVersion?.toString(),
|
||||
granted_acls: grantedSetupCompleterAdmin ? AdminACLs.WILDCARD : undefined,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -26,7 +26,7 @@ export function SearchAdminController(app: HonoApp) {
|
||||
operationId: 'create_admin_search_index_refresh',
|
||||
summary: 'Refresh a search index',
|
||||
description:
|
||||
'Trigger a full or partial rebuild of the named search index. Creates a background job and returns its refresh ID for status tracking. The channel_messages and guild_members indexes are rebuilt one guild at a time and require guild_id, and favorite_memes requires user_id. Requires GUILD_LOOKUP permission.',
|
||||
'Trigger a full or partial rebuild of the named search index. Creates a background job and returns its refresh ID for status tracking. The channel_messages, guild_members and threads indexes are rebuilt one guild at a time and require guild_id, and favorite_memes requires user_id. Requires GUILD_LOOKUP permission.',
|
||||
responseSchema: RefreshSearchIndexResponse,
|
||||
statusCode: 200,
|
||||
security: 'adminApiKey',
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {AdminAuditReadActions} from '@app/api/admin/AdminAuditActions';
|
||||
import {recordAdminRead, recordAdminWrite} from '@app/api/admin/AdminAuditRecorder';
|
||||
import {createChannelID, createGuildID} from '@app/api/BrandedTypes';
|
||||
import {requireAdminACL} from '@app/api/middleware/AdminMiddleware';
|
||||
import {RateLimitMiddleware} from '@app/api/middleware/RateLimitMiddleware';
|
||||
import {OpenAPI} from '@app/api/middleware/ResponseTypeMiddleware';
|
||||
import {AdminRateLimitConfigs} from '@app/api/rate_limit_configs/AdminRateLimitConfig';
|
||||
import type {HonoApp} from '@app/api/types/HonoEnv';
|
||||
import {Validator} from '@app/api/Validator';
|
||||
import {AdminACLs} from '@fluxer/constants/src/AdminACLs';
|
||||
import {InvalidChannelTypeError} from '@fluxer/errors/src/domains/channel/InvalidChannelTypeError';
|
||||
import {UnknownChannelError} from '@fluxer/errors/src/domains/channel/UnknownChannelError';
|
||||
import {ListGuildThreadsResponse} from '@fluxer/schema/src/domains/admin/AdminThreadSchemas';
|
||||
import {ChannelIdParam, GuildIdParam} from '@fluxer/schema/src/domains/common/CommonParamSchemas';
|
||||
|
||||
export function ThreadAdminController(app: HonoApp) {
|
||||
app.get(
|
||||
'/admin/guilds/:guild_id/threads',
|
||||
RateLimitMiddleware(AdminRateLimitConfigs.ADMIN_LOOKUP),
|
||||
requireAdminACL(AdminACLs.GUILD_LOOKUP),
|
||||
Validator('param', GuildIdParam),
|
||||
OpenAPI({
|
||||
operationId: 'list_admin_guild_threads',
|
||||
summary: 'List guild threads',
|
||||
description:
|
||||
'Lists every thread of a guild, active and archived, whether or not the channel threads experiment is active for it. Requires GUILD_LOOKUP permission.',
|
||||
responseSchema: ListGuildThreadsResponse,
|
||||
statusCode: 200,
|
||||
security: 'adminApiKey',
|
||||
tags: 'Admin',
|
||||
experiment: 'channel_threads',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const guildId = createGuildID(ctx.req.valid('param').guild_id);
|
||||
const threads = await ctx.get('threadService').lists.listGuildThreadsForAdmin(guildId);
|
||||
await recordAdminRead(ctx, {
|
||||
targetType: 'guild',
|
||||
targetId: guildId,
|
||||
action: AdminAuditReadActions.LIST_GUILD_THREADS,
|
||||
metadata: {result_count: threads.length},
|
||||
});
|
||||
return ctx.json({threads});
|
||||
},
|
||||
);
|
||||
app.delete(
|
||||
'/admin/channels/:channel_id',
|
||||
RateLimitMiddleware(AdminRateLimitConfigs.ADMIN_MESSAGE_OPERATION),
|
||||
requireAdminACL(AdminACLs.MESSAGE_DELETE_ALL),
|
||||
Validator('param', ChannelIdParam),
|
||||
OpenAPI({
|
||||
operationId: 'delete_admin_thread_channel',
|
||||
summary: 'Delete a thread',
|
||||
description:
|
||||
'Deletes a thread channel with its messages and memberships. Only public and private threads can be deleted here. Requires MESSAGE_DELETE_ALL permission.',
|
||||
responseSchema: null,
|
||||
statusCode: 204,
|
||||
security: 'adminApiKey',
|
||||
tags: 'Admin',
|
||||
experiment: 'channel_threads',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const channelId = createChannelID(ctx.req.valid('param').channel_id);
|
||||
const thread = await ctx.get('channelRepository').findUnique(channelId);
|
||||
if (!thread) throw new UnknownChannelError();
|
||||
if (!thread.isThread()) throw new InvalidChannelTypeError();
|
||||
const adminUserId = ctx.get('adminUserId');
|
||||
await ctx.get('threadService').deletion.deleteThread({
|
||||
thread,
|
||||
actorId: adminUserId,
|
||||
auditLogReason: ctx.get('auditLogReason'),
|
||||
recordGuildAudit: false,
|
||||
});
|
||||
await recordAdminWrite(ctx, {
|
||||
targetType: 'channel',
|
||||
targetId: channelId,
|
||||
action: 'delete_thread',
|
||||
metadata: {guild_id: thread.guildId?.toString(), parent_id: thread.parentId?.toString(), type: thread.type},
|
||||
});
|
||||
return ctx.body(null, 204);
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -4,6 +4,10 @@ import {AdminAuditReadActions} from '@app/api/admin/AdminAuditActions';
|
||||
import {recordAdminRead} from '@app/api/admin/AdminAuditRecorder';
|
||||
import {mapUserToAdminResponse} from '@app/api/admin/models/UserTypes';
|
||||
import {createUserID} from '@app/api/BrandedTypes';
|
||||
import {
|
||||
RequireEmailAccountIdentity,
|
||||
RequireUsernameAccountIdentity,
|
||||
} from '@app/api/middleware/AccountIdentityMiddleware';
|
||||
import {requireAdminACL} from '@app/api/middleware/AdminMiddleware';
|
||||
import {RateLimitMiddleware} from '@app/api/middleware/RateLimitMiddleware';
|
||||
import {OpenAPI} from '@app/api/middleware/ResponseTypeMiddleware';
|
||||
@@ -16,6 +20,7 @@ import {ListUserGuildsResponse} from '@fluxer/schema/src/domains/admin/AdminGuil
|
||||
import {SearchUsersResponse} from '@fluxer/schema/src/domains/admin/AdminSchemas';
|
||||
import {
|
||||
AdminAclListResponse,
|
||||
AdminPasswordResetLinkResponse,
|
||||
AdminUserAclsRequest,
|
||||
AdminUserBanNoteRequest,
|
||||
AdminUserBanRequest,
|
||||
@@ -632,6 +637,7 @@ export function UserAdminController(app: HonoApp) {
|
||||
'/admin/users/:user_id/email',
|
||||
RateLimitMiddleware(RateLimitConfigs.ADMIN_USER_MODIFY),
|
||||
requireAdminACL(AdminACLs.USER_UPDATE_EMAIL),
|
||||
RequireEmailAccountIdentity,
|
||||
Validator('param', UserIdParam),
|
||||
Validator('json', AdminUserEmailUpdateRequest),
|
||||
OpenAPI({
|
||||
@@ -664,6 +670,7 @@ export function UserAdminController(app: HonoApp) {
|
||||
'/admin/users/:user_id/email-verification',
|
||||
RateLimitMiddleware(RateLimitConfigs.ADMIN_USER_MODIFY),
|
||||
requireAdminACL(AdminACLs.USER_UPDATE_EMAIL),
|
||||
RequireEmailAccountIdentity,
|
||||
Validator('param', UserIdParam),
|
||||
OpenAPI({
|
||||
operationId: 'verify_admin_user_email',
|
||||
@@ -695,6 +702,7 @@ export function UserAdminController(app: HonoApp) {
|
||||
'/admin/users/:user_id/verification-email',
|
||||
RateLimitMiddleware(RateLimitConfigs.ADMIN_USER_MODIFY),
|
||||
requireAdminACL(AdminACLs.USER_UPDATE_EMAIL),
|
||||
RequireEmailAccountIdentity,
|
||||
Validator('param', UserIdParam),
|
||||
OpenAPI({
|
||||
operationId: 'resend_admin_user_verification_email',
|
||||
@@ -723,6 +731,7 @@ export function UserAdminController(app: HonoApp) {
|
||||
'/admin/users/:user_id/password-reset',
|
||||
RateLimitMiddleware(RateLimitConfigs.ADMIN_USER_MODIFY),
|
||||
requireAdminACL(AdminACLs.USER_UPDATE_EMAIL),
|
||||
RequireEmailAccountIdentity,
|
||||
Validator('param', UserIdParam),
|
||||
OpenAPI({
|
||||
operationId: 'send_admin_user_password_reset',
|
||||
@@ -743,6 +752,65 @@ export function UserAdminController(app: HonoApp) {
|
||||
return ctx.body(null, 204);
|
||||
},
|
||||
);
|
||||
app.post(
|
||||
'/admin/users/:user_id/password-reset-link',
|
||||
RateLimitMiddleware(RateLimitConfigs.ADMIN_USER_MODIFY),
|
||||
requireAdminACL(AdminACLs.USER_CREATE_PASSWORD_RESET_LINK),
|
||||
RequireUsernameAccountIdentity,
|
||||
Validator('param', UserIdParam),
|
||||
OpenAPI({
|
||||
operationId: 'create_admin_user_password_reset_link',
|
||||
summary: 'Create user password reset link',
|
||||
responseSchema: AdminPasswordResetLinkResponse,
|
||||
statusCode: 200,
|
||||
security: 'adminApiKey',
|
||||
tags: 'Admin',
|
||||
description:
|
||||
'Create a one-time password reset link on an instance where people sign in with a username. Hand the link to the user yourself. It works once and expires after an hour. Deletes the recovery kit of the account. Creates audit log entry. Requires USER_CREATE_PASSWORD_RESET_LINK permission and every ACL the target account holds. Fails with USERNAME_SIGN_IN_ONLY on email instances.',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const adminService = ctx.get('adminService');
|
||||
const adminUserId = ctx.get('adminUserId');
|
||||
const auditLogReason = ctx.get('auditLogReason');
|
||||
const {user_id: userId} = ctx.req.valid('param');
|
||||
return ctx.json(
|
||||
await adminService.userService.securityService.createPasswordResetLink(
|
||||
{user_id: userId},
|
||||
adminUserId,
|
||||
auditLogReason,
|
||||
ctx.get('adminUserAcls'),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
app.delete(
|
||||
'/admin/users/:user_id/recovery-kit',
|
||||
RateLimitMiddleware(RateLimitConfigs.ADMIN_USER_MODIFY),
|
||||
requireAdminACL(AdminACLs.USER_DELETE_RECOVERY_KIT),
|
||||
RequireUsernameAccountIdentity,
|
||||
Validator('param', UserIdParam),
|
||||
OpenAPI({
|
||||
operationId: 'revoke_admin_user_recovery_kit',
|
||||
summary: 'Revoke user recovery kit',
|
||||
responseSchema: null,
|
||||
statusCode: 204,
|
||||
security: 'adminApiKey',
|
||||
tags: 'Admin',
|
||||
description:
|
||||
'Deletes the recovery kit of an account on an instance where people sign in with a username, so its key stops working. Creates audit log entry. Requires USER_DELETE_RECOVERY_KIT permission and every ACL the target account holds. Fails with USERNAME_SIGN_IN_ONLY on email instances.',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const adminService = ctx.get('adminService');
|
||||
const {user_id: userId} = ctx.req.valid('param');
|
||||
await adminService.userService.securityService.revokeRecoveryKit(
|
||||
{user_id: userId},
|
||||
ctx.get('adminUserId'),
|
||||
ctx.get('auditLogReason'),
|
||||
ctx.get('adminUserAcls'),
|
||||
);
|
||||
return ctx.body(null, 204);
|
||||
},
|
||||
);
|
||||
app.put(
|
||||
'/admin/users/:user_id/ban',
|
||||
RateLimitMiddleware(RateLimitConfigs.ADMIN_USER_MODIFY),
|
||||
|
||||
@@ -19,6 +19,7 @@ import {ReportAdminController} from '@app/api/admin/controllers/ReportAdminContr
|
||||
import {SearchAdminController} from '@app/api/admin/controllers/SearchAdminController';
|
||||
import {StoreBillingAdminController} from '@app/api/admin/controllers/StoreBillingAdminController';
|
||||
import {SystemDmAdminController} from '@app/api/admin/controllers/SystemDmAdminController';
|
||||
import {ThreadAdminController} from '@app/api/admin/controllers/ThreadAdminController';
|
||||
import {UserAdminController} from '@app/api/admin/controllers/UserAdminController';
|
||||
import {VoiceAdminController} from '@app/api/admin/controllers/VoiceAdminController';
|
||||
import type {HonoApp} from '@app/api/types/HonoEnv';
|
||||
@@ -30,6 +31,7 @@ export function registerAdminControllers(app: HonoApp) {
|
||||
StoreBillingAdminController(app);
|
||||
CodesAdminController(app);
|
||||
GuildAdminController(app);
|
||||
ThreadAdminController(app);
|
||||
AssetAdminController(app);
|
||||
BanAdminController(app);
|
||||
InstanceConfigAdminController(app);
|
||||
|
||||
@@ -13,17 +13,19 @@ import {
|
||||
type UserID,
|
||||
} from '@app/api/BrandedTypes';
|
||||
import type {IChannelRepository} from '@app/api/channel/IChannelRepository';
|
||||
import {withThreadContext} from '@app/api/channel/services/ChannelGatewayDispatch';
|
||||
import {
|
||||
enqueueCrosspostFamilyPurgeFromCopies,
|
||||
enqueueCrosspostSourceRemoval,
|
||||
} from '@app/api/channel/services/message/CrosspostPropagation';
|
||||
import {purgeMessageAttachments} from '@app/api/channel/services/message/MessageHelpers';
|
||||
import {decrementThreadMessageCount, purgeMessageAttachments} from '@app/api/channel/services/message/MessageHelpers';
|
||||
import {
|
||||
createMessageResponseDataService,
|
||||
type MessageResponseAccessContext,
|
||||
messageResponseAccessForChannel,
|
||||
messageResponseAccessForGuild,
|
||||
} from '@app/api/channel/services/message/MessageResponseDataService';
|
||||
import {resolveNsfwScopeChannel} from '@app/api/channel/utils/ThreadNsfwScope';
|
||||
import type {NcmecAttachmentStatusResponse, NcmecSubmissionService} from '@app/api/csam/NcmecSubmissionService';
|
||||
import type {IGuildRepositoryAggregate} from '@app/api/guild/repositories/IGuildRepositoryAggregate';
|
||||
import {getPurgeQueue, getStorageService} from '@app/api/middleware/ServiceSingletons';
|
||||
@@ -144,15 +146,16 @@ export class AdminMessageService {
|
||||
message.authorId || createUserID(0n),
|
||||
message.pinnedTimestamp || undefined,
|
||||
);
|
||||
await decrementThreadMessageCount(channelRepository, channel, [messageId]);
|
||||
if (channel) {
|
||||
if (channel.guildId) {
|
||||
await gatewayService.dispatchGuild({
|
||||
guildId: channel.guildId,
|
||||
event: 'MESSAGE_DELETE',
|
||||
data: {
|
||||
data: withThreadContext(channel, {
|
||||
channel_id: channelId.toString(),
|
||||
id: messageId.toString(),
|
||||
},
|
||||
}),
|
||||
});
|
||||
} else {
|
||||
for (const recipientId of channel.recipientIds) {
|
||||
@@ -349,9 +352,12 @@ export class AdminMessageService {
|
||||
guildName: null,
|
||||
};
|
||||
}
|
||||
const guild = await guildRepository.findUnique(channel.guildId);
|
||||
const [guild, scope] = await Promise.all([
|
||||
guildRepository.findUnique(channel.guildId),
|
||||
resolveNsfwScopeChannel(channel, (id) => channelRepository.findUnique(id)),
|
||||
]);
|
||||
return {
|
||||
channelNsfw: channel.isNsfw,
|
||||
channelNsfw: scope.isNsfw,
|
||||
guildNsfwLevel: guild?.nsfwLevel ?? null,
|
||||
channelName: channel.name ?? null,
|
||||
guildId: channel.guildId.toString(),
|
||||
|
||||
@@ -21,9 +21,11 @@ import {
|
||||
messageResponseAccessForChannel,
|
||||
messageResponseAccessForGuild,
|
||||
} from '@app/api/channel/services/message/MessageResponseDataService';
|
||||
import {resolveNsfwScopeChannel} from '@app/api/channel/utils/ThreadNsfwScope';
|
||||
import {SYSTEM_USER_ID} from '@app/api/constants/Core';
|
||||
import type {NcmecAttachmentStatusResponse, NcmecSubmissionService} from '@app/api/csam/NcmecSubmissionService';
|
||||
import type {MessageAttachment} from '@app/api/database/types/MessageTypes';
|
||||
import {SYSTEM_THREAD_VIEWER} from '@app/api/experiment/ChannelThreadsGate';
|
||||
import type {IGuildRepositoryAggregate} from '@app/api/guild/repositories/IGuildRepositoryAggregate';
|
||||
import type {IStorageService} from '@app/api/infrastructure/IStorageService';
|
||||
import type {UserCacheService} from '@app/api/infrastructure/UserCacheService';
|
||||
@@ -36,6 +38,7 @@ import type {ReportService} from '@app/api/report/ReportService';
|
||||
import {getReportSearchService} from '@app/api/SearchFactory';
|
||||
import {isHiddenPartial} from '@app/api/user/ProfileVisibility';
|
||||
import type {UserChannelService} from '@app/api/user/services/UserChannelService';
|
||||
import {formatUserTag} from '@app/api/user/UserTag';
|
||||
import {assertSafeByteSize} from '@app/api/utils/ByteSizeUtils';
|
||||
import {AdminACLs} from '@fluxer/constants/src/AdminACLs';
|
||||
import {FeatureTemporarilyDisabledError} from '@fluxer/errors/src/domains/core/FeatureTemporarilyDisabledError';
|
||||
@@ -204,6 +207,7 @@ export class AdminReportService {
|
||||
});
|
||||
await this.deps.channelService.messages.send.sendMessage({
|
||||
user: systemUser,
|
||||
viewer: SYSTEM_THREAD_VIEWER,
|
||||
channelId: dmChannel.id,
|
||||
data: {
|
||||
content: template.value.body,
|
||||
@@ -522,7 +526,10 @@ export class AdminReportService {
|
||||
return reportNsfwLookupCache.channelNsfwByChannelId.get(channelIdString) ?? null;
|
||||
}
|
||||
const channel = await this.deps.channelRepository.findUnique(channelId);
|
||||
const channelNsfw = channel?.isNsfw ?? null;
|
||||
const scope = channel
|
||||
? await resolveNsfwScopeChannel(channel, (id) => this.deps.channelRepository.findUnique(id))
|
||||
: null;
|
||||
const channelNsfw = scope?.isNsfw ?? null;
|
||||
reportNsfwLookupCache.channelNsfwByChannelId.set(channelIdString, channelNsfw);
|
||||
return channelNsfw;
|
||||
}
|
||||
@@ -629,11 +636,20 @@ export class AdminReportService {
|
||||
const cached = await this.deps.userCacheService.getUserPartialResponse(userId, requestCache);
|
||||
const stored = isHiddenPartial(cached) ? await this.deps.apiContext.services.users.findUnique(userId) : null;
|
||||
const user = stored
|
||||
? {username: stored.username, global_name: stored.globalName, discriminator: stored.discriminator.toString()}
|
||||
? {
|
||||
username: stored.username,
|
||||
global_name: stored.globalName,
|
||||
discriminator: stored.discriminator.toString(),
|
||||
bot: stored.isBot,
|
||||
}
|
||||
: cached;
|
||||
const discriminator = user.discriminator?.padStart(4, '0') ?? '0000';
|
||||
return {
|
||||
tag: `${user.username}#${discriminator}`,
|
||||
tag: formatUserTag({
|
||||
username: user.username,
|
||||
discriminator: Number.parseInt(discriminator, 10),
|
||||
isBot: user.bot ?? false,
|
||||
}),
|
||||
username: user.username,
|
||||
global_name: user.global_name ?? null,
|
||||
discriminator,
|
||||
|
||||
@@ -6,9 +6,11 @@ import {mapUserToAdminResponse} from '@app/api/admin/models/UserTypes';
|
||||
import type {AdminAuditService} from '@app/api/admin/services/AdminAuditService';
|
||||
import {createGuildID, createUserID, type UserID} from '@app/api/BrandedTypes';
|
||||
import {isSyntheticUserId} from '@app/api/constants/Core';
|
||||
import {channelThreadsEnabled} from '@app/api/experiment/ChannelThreadsGate';
|
||||
import type {IGuildRepositoryAggregate} from '@app/api/guild/repositories/IGuildRepositoryAggregate';
|
||||
import {Logger} from '@app/api/Logger';
|
||||
import {getGuildSearchService, getUserSearchService} from '@app/api/SearchFactory';
|
||||
import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes';
|
||||
import {FeatureTemporarilyDisabledError} from '@fluxer/errors/src/domains/core/FeatureTemporarilyDisabledError';
|
||||
import {InputValidationError} from '@fluxer/errors/src/domains/core/InputValidationError';
|
||||
import type {UserSearchFilters} from '@fluxer/schema/src/contracts/search/SearchDocumentTypes';
|
||||
@@ -23,7 +25,8 @@ interface RefreshSearchIndexJobPayload extends WorkerJobPayload {
|
||||
| 'channel_messages'
|
||||
| 'favorite_memes'
|
||||
| 'guild_members'
|
||||
| 'discovery';
|
||||
| 'discovery'
|
||||
| 'threads';
|
||||
admin_user_id: string;
|
||||
audit_log_reason: string | null;
|
||||
job_id: string;
|
||||
@@ -175,7 +178,8 @@ export class AdminSearchService {
|
||||
| 'channel_messages'
|
||||
| 'guild_members'
|
||||
| 'favorite_memes'
|
||||
| 'discovery';
|
||||
| 'discovery'
|
||||
| 'threads';
|
||||
guild_id?: bigint;
|
||||
user_id?: bigint;
|
||||
},
|
||||
@@ -197,6 +201,15 @@ export class AdminSearchService {
|
||||
}
|
||||
payload.guild_id = data.guild_id.toString();
|
||||
}
|
||||
if (data.index_type === 'threads') {
|
||||
if (!channelThreadsEnabled()) {
|
||||
throw InputValidationError.fromCode('index_name', ValidationErrorCodes.INVALID_FORMAT);
|
||||
}
|
||||
if (!data.guild_id) {
|
||||
throw InputValidationError.create('guild_id', 'guild_id is required for the threads index type');
|
||||
}
|
||||
payload.guild_id = data.guild_id.toString();
|
||||
}
|
||||
if (data.index_type === 'guild_members') {
|
||||
if (!data.guild_id) {
|
||||
throw InputValidationError.create('guild_id', 'guild_id is required for the guild_members index type');
|
||||
|
||||
@@ -9,6 +9,7 @@ import type {AdminUserUpdatePropagator} from '@app/api/admin/services/AdminUserU
|
||||
import * as AuthSession from '@app/api/auth/AuthSession';
|
||||
import {createReportID, createUserID, type UserID} from '@app/api/BrandedTypes';
|
||||
import type {BillingRepository} from '@app/api/billing/repositories/BillingRepository';
|
||||
import type {NcmecRepository} from '@app/api/csam/NcmecRepository';
|
||||
import {emitAdminAction} from '@app/api/infrastructure/activity/AccountChangeEvents';
|
||||
import type {KVAccountDeletionQueueService} from '@app/api/infrastructure/KVAccountDeletionQueueService';
|
||||
import {Logger} from '@app/api/Logger';
|
||||
@@ -45,11 +46,28 @@ interface AdminUserDeletionServiceDeps {
|
||||
billingRepository: BillingRepository;
|
||||
oauth2Tokens: Pick<OAuth2TokenRepository, 'deleteAllAccessTokensForUser' | 'deleteAllRefreshTokensForUser'>;
|
||||
storeEntitlementService: StoreEntitlementService;
|
||||
ncmecRepository: Pick<NcmecRepository, 'getUserWorkflow'>;
|
||||
}
|
||||
|
||||
const minUserRequestedDeletionDays = 14;
|
||||
const minStandardDeletionDays = 60;
|
||||
|
||||
const reportResolvingDeletionReasons: ReadonlySet<number> = new Set([
|
||||
DeletionReasons.SPAM,
|
||||
DeletionReasons.CHEATING_OR_EXPLOITATION,
|
||||
DeletionReasons.COORDINATED_RAIDING,
|
||||
DeletionReasons.AUTOMATION_OR_SELFBOT,
|
||||
DeletionReasons.SCAM_OR_SOCIAL_ENGINEERING,
|
||||
DeletionReasons.HARASSMENT_OR_BULLYING,
|
||||
DeletionReasons.BAN_EVASION,
|
||||
DeletionReasons.TOKEN_OR_CREDENTIAL_SCAM,
|
||||
DeletionReasons.HATE_SPEECH_OR_EXTREMIST_CONTENT,
|
||||
DeletionReasons.MALICIOUS_LINKS_OR_MALWARE,
|
||||
DeletionReasons.IMPERSONATION_OR_FAKE_IDENTITY,
|
||||
]);
|
||||
|
||||
const manuallyResolvedReportCategories: ReadonlySet<string> = new Set(['child_safety', 'underage_user', 'self_harm']);
|
||||
|
||||
function describePendingDeletion(user: User, prefix: string): Array<[string, string]> {
|
||||
if (!user.pendingDeletionAt) return [];
|
||||
return [
|
||||
@@ -247,6 +265,8 @@ export class AdminUserDeletionService {
|
||||
let knownIps: ReadonlySet<string> = new Set();
|
||||
if (data.reason_code !== DeletionReasons.USER_REQUESTED) {
|
||||
knownIps = await this.banIdentifiersForScheduledDeletion({user, adminUserId, auditLogReason});
|
||||
}
|
||||
if (reportResolvingDeletionReasons.has(data.reason_code)) {
|
||||
await this.resolvePendingReportsAgainstUser({user, adminUserId, reasonCode: data.reason_code});
|
||||
}
|
||||
await emitAdminAction(adminUserId, userId, 'schedule_deletion', {reasonCode: data.reason_code, ips: knownIps});
|
||||
@@ -389,7 +409,10 @@ export class AdminUserDeletionService {
|
||||
}): Promise<void> {
|
||||
const {user, adminUserId, reasonCode} = params;
|
||||
const outcome = isEnforcementDeletionReason(reasonCode) ? 'actioned' : 'auto_resolved';
|
||||
const {reportService, auditService} = this.deps;
|
||||
const {reportService, auditService, ncmecRepository} = this.deps;
|
||||
if (await ncmecRepository.getUserWorkflow(user.id)) {
|
||||
return;
|
||||
}
|
||||
const reportSearchService = getReportSearchService();
|
||||
if (!reportSearchService) {
|
||||
Logger.warn(
|
||||
@@ -415,6 +438,7 @@ export class AdminUserDeletionService {
|
||||
);
|
||||
if (hits.length === 0) break;
|
||||
for (const hit of hits) {
|
||||
if (manuallyResolvedReportCategories.has(hit.category)) continue;
|
||||
pendingReportIds.add(hit.id);
|
||||
}
|
||||
offset += hits.length;
|
||||
|
||||
@@ -4,7 +4,10 @@ import type {ApiContext} from '@app/api/ApiContext';
|
||||
import {mapUserToAdminResponse} from '@app/api/admin/models/UserTypes';
|
||||
import {createUserID} from '@app/api/BrandedTypes';
|
||||
import {isSyntheticUserId} from '@app/api/constants/Core';
|
||||
import {usesUniqueUsernames} from '@app/api/instance/AccountIdentityModeCache';
|
||||
import {Logger} from '@app/api/Logger';
|
||||
import type {User} from '@app/api/models/User';
|
||||
import {findPersonByLoginHandle, parseLoginHandle} from '@app/api/user/UniqueUsernames';
|
||||
import type {LookupUserRequest} from '@fluxer/schema/src/domains/admin/AdminUserSchemas';
|
||||
|
||||
interface AdminUserLookupServiceDeps {
|
||||
@@ -41,10 +44,17 @@ export class AdminUserLookupService {
|
||||
} else if (query.includes('@')) {
|
||||
user = await userRepository.findByEmail(query);
|
||||
} else {
|
||||
user = await userRepository.findByStripeSubscriptionId(query);
|
||||
user = (await this.findPersonByBareUsername(query)) ?? (await userRepository.findByStripeSubscriptionId(query));
|
||||
}
|
||||
return {
|
||||
users: user ? [await mapUserToAdminResponse(user, cacheService, acls)] : [],
|
||||
};
|
||||
}
|
||||
|
||||
private async findPersonByBareUsername(query: string): Promise<User | null> {
|
||||
if (!usesUniqueUsernames()) return null;
|
||||
const handle = parseLoginHandle(query);
|
||||
if (!handle || handle.discriminator !== null) return null;
|
||||
return await findPersonByLoginHandle(this.deps.apiContext.services.users, handle);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,7 +10,11 @@ import {GuildMemberSearchIndexService} from '@app/api/guild/services/member/Guil
|
||||
import type {IDiscriminatorService} from '@app/api/infrastructure/DiscriminatorService';
|
||||
import type {EntityAssetService, PreparedAssetUpload} from '@app/api/infrastructure/EntityAssetService';
|
||||
import {Logger} from '@app/api/Logger';
|
||||
import {getInstanceConfigRepository} from '@app/api/middleware/ServiceSingletons';
|
||||
import type {User} from '@app/api/models/User';
|
||||
import {enqueueStripeCustomerEmailSync} from '@app/api/stripe/StripeCustomer';
|
||||
import {assertNoDiscriminatorChange, reserveUsername, type UsernameReservation} from '@app/api/user/UniqueUsernames';
|
||||
import {USERNAME_MODE_DISCRIMINATOR} from '@app/api/user/UserTag';
|
||||
import {TagAlreadyTakenError} from '@fluxer/errors/src/domains/user/TagAlreadyTakenError';
|
||||
import {UnknownUserError} from '@fluxer/errors/src/domains/user/UnknownUserError';
|
||||
import type {
|
||||
@@ -149,22 +153,37 @@ export class AdminUserProfileService {
|
||||
if (!user) {
|
||||
throw new UnknownUserError();
|
||||
}
|
||||
const discriminatorResult = await discriminatorService.generateDiscriminator({
|
||||
username: data.username,
|
||||
requestedDiscriminator: data.discriminator,
|
||||
user,
|
||||
});
|
||||
if (!discriminatorResult.available || discriminatorResult.discriminator === -1) {
|
||||
throw new TagAlreadyTakenError();
|
||||
const uniqueUsernames = !user.isBot && (await getInstanceConfigRepository().usesUniqueUsernames());
|
||||
if (uniqueUsernames) {
|
||||
assertNoDiscriminatorChange(data.discriminator, user.discriminator);
|
||||
}
|
||||
const reservation: UsernameReservation | null = uniqueUsernames
|
||||
? await reserveUsername({users: userRepository, cache: cacheService}, data.username, userId)
|
||||
: null;
|
||||
let updatedUser: User;
|
||||
let discriminatorResult: {discriminator: number; available: boolean};
|
||||
try {
|
||||
discriminatorResult = uniqueUsernames
|
||||
? {discriminator: USERNAME_MODE_DISCRIMINATOR, available: true}
|
||||
: await discriminatorService.generateDiscriminator({
|
||||
username: data.username,
|
||||
requestedDiscriminator: data.discriminator,
|
||||
user,
|
||||
});
|
||||
if (!discriminatorResult.available || discriminatorResult.discriminator === -1) {
|
||||
throw new TagAlreadyTakenError();
|
||||
}
|
||||
updatedUser = await userRepository.patchUpsert(
|
||||
userId,
|
||||
{
|
||||
username: data.username,
|
||||
discriminator: discriminatorResult.discriminator,
|
||||
},
|
||||
user.toRow(),
|
||||
);
|
||||
} finally {
|
||||
await reservation?.release();
|
||||
}
|
||||
const updatedUser = await userRepository.patchUpsert(
|
||||
userId,
|
||||
{
|
||||
username: data.username,
|
||||
discriminator: discriminatorResult.discriminator,
|
||||
},
|
||||
user.toRow(),
|
||||
);
|
||||
await updatePropagator.propagateUserUpdate({userId, oldUser: user, updatedUser: updatedUser});
|
||||
await contactChangeLogService.recordDiff({
|
||||
oldUser: user,
|
||||
@@ -200,6 +219,7 @@ export class AdminUserProfileService {
|
||||
users: userRepository,
|
||||
cache: cacheService,
|
||||
contactChangeLog: contactChangeLogService,
|
||||
worker: workerService,
|
||||
} = this.deps.apiContext.services;
|
||||
const {auditService, updatePropagator} = this.deps;
|
||||
const userId = createUserID(data.user_id);
|
||||
@@ -222,6 +242,7 @@ export class AdminUserProfileService {
|
||||
reason: 'admin_action',
|
||||
actorUserId: adminUserId,
|
||||
});
|
||||
await enqueueStripeCustomerEmailSync(workerService, user, updatedUser);
|
||||
await auditService.createAuditLog({
|
||||
adminUserId,
|
||||
targetType: 'user',
|
||||
|
||||
@@ -9,12 +9,15 @@ import * as AuthMfa from '@app/api/auth/AuthMfa';
|
||||
import * as AuthSession from '@app/api/auth/AuthSession';
|
||||
import * as AuthUtility from '@app/api/auth/AuthUtility';
|
||||
import {visibleWebAuthnCredentials} from '@app/api/auth/services/PasskeyRelyingParty';
|
||||
import {RecoveryKitRepository} from '@app/api/auth/services/RecoveryKitRepository';
|
||||
import {createPasswordResetToken, createUserID, type UserID} from '@app/api/BrandedTypes';
|
||||
import {Config} from '@app/api/Config';
|
||||
import {emitAdminAction} from '@app/api/infrastructure/activity/AccountChangeEvents';
|
||||
import {Logger} from '@app/api/Logger';
|
||||
import {getInstanceConfigRepository} from '@app/api/middleware/ServiceSingletons';
|
||||
import {User} from '@app/api/models/User';
|
||||
import {clearNewConversationLimit} from '@app/api/user/NewConversationLimit';
|
||||
import {PASSWORD_RESET_TOKEN_TTL_SECONDS} from '@app/api/user/repositories/auth/TokenRepository';
|
||||
import {mapWebAuthnCredentialToResponse} from '@app/api/user/UserMappers';
|
||||
import {resolveAssignedTraits} from '@app/api/user/UserTraits';
|
||||
import {getIpAddressReverse, getLocationLabelFromIp} from '@app/api/utils/IpUtils';
|
||||
@@ -29,6 +32,7 @@ import {MissingACLError} from '@fluxer/errors/src/domains/core/MissingACLError';
|
||||
import {ServiceUnavailableError} from '@fluxer/errors/src/domains/core/ServiceUnavailableError';
|
||||
import {UnknownUserError} from '@fluxer/errors/src/domains/user/UnknownUserError';
|
||||
import type {
|
||||
AdminPasswordResetLinkResponse,
|
||||
DeleteWebAuthnCredentialRequest,
|
||||
DisableMfaRequest,
|
||||
ListWebAuthnCredentialsRequest,
|
||||
@@ -262,6 +266,68 @@ export class AdminUserSecurityService {
|
||||
});
|
||||
}
|
||||
|
||||
async createPasswordResetLink(
|
||||
data: SendPasswordResetRequest,
|
||||
adminUserId: UserID,
|
||||
auditLogReason: string | null,
|
||||
acls: ReadonlySet<string>,
|
||||
): Promise<AdminPasswordResetLinkResponse> {
|
||||
const {users: userRepository} = this.deps.apiContext.services;
|
||||
const {apiContext, auditService} = this.deps;
|
||||
const userId = createUserID(data.user_id);
|
||||
const user = await userRepository.findUnique(userId);
|
||||
if (!user) {
|
||||
throw new UnknownUserError();
|
||||
}
|
||||
AuthUtility.assertNonBotUser(apiContext, user);
|
||||
assertCallerHoldsTargetAcls(user.acls, acls);
|
||||
const token = createPasswordResetToken(await AuthUtility.generateSecureToken(apiContext));
|
||||
const expiresAt = new Date(Date.now() + PASSWORD_RESET_TOKEN_TTL_SECONDS * 1000);
|
||||
await userRepository.deleteAllPasswordResetTokens(userId);
|
||||
await new RecoveryKitRepository().delete(userId);
|
||||
await userRepository.createPasswordResetToken({
|
||||
token_: token,
|
||||
user_id: userId,
|
||||
email: null,
|
||||
});
|
||||
await auditService.createAuditLog({
|
||||
adminUserId,
|
||||
targetType: 'user',
|
||||
targetId: BigInt(userId),
|
||||
action: 'create_password_reset_link',
|
||||
auditLogReason,
|
||||
metadata: new Map(),
|
||||
});
|
||||
return {
|
||||
url: `${Config.email.appBaseUrl}/reset#token=${token}`,
|
||||
expires_at: expiresAt.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
async revokeRecoveryKit(
|
||||
data: SendPasswordResetRequest,
|
||||
adminUserId: UserID,
|
||||
auditLogReason: string | null,
|
||||
acls: ReadonlySet<string>,
|
||||
): Promise<void> {
|
||||
const {users: userRepository} = this.deps.apiContext.services;
|
||||
const userId = createUserID(data.user_id);
|
||||
const user = await userRepository.findUnique(userId);
|
||||
if (!user) {
|
||||
throw new UnknownUserError();
|
||||
}
|
||||
assertCallerHoldsTargetAcls(user.acls, acls);
|
||||
await new RecoveryKitRepository().delete(userId);
|
||||
await this.deps.auditService.createAuditLog({
|
||||
adminUserId,
|
||||
targetType: 'user',
|
||||
targetId: BigInt(userId),
|
||||
action: 'revoke_recovery_kit',
|
||||
auditLogReason,
|
||||
metadata: new Map(),
|
||||
});
|
||||
}
|
||||
|
||||
async resendVerificationEmail(
|
||||
data: ResendVerificationEmailRequest,
|
||||
adminUserId: UserID,
|
||||
@@ -573,3 +639,11 @@ export class AdminUserSecurityService {
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function assertCallerHoldsTargetAcls(targetAcls: ReadonlySet<string>, callerAcls: ReadonlySet<string>): void {
|
||||
if (callerAcls.has(AdminACLs.WILDCARD)) return;
|
||||
const missing = [...targetAcls].find((acl) => !callerAcls.has(acl));
|
||||
if (missing !== undefined) {
|
||||
throw new MissingACLError(missing);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import {AdminUserSecurityService} from '@app/api/admin/services/AdminUserSecurit
|
||||
import {AdminUserUpdatePropagator} from '@app/api/admin/services/AdminUserUpdatePropagator';
|
||||
import {createChannelID, createUserID, type UserID} from '@app/api/BrandedTypes';
|
||||
import type {IChannelRepository} from '@app/api/channel/IChannelRepository';
|
||||
import {NcmecRepository} from '@app/api/csam/NcmecRepository';
|
||||
import type {IGuildRepositoryAggregate} from '@app/api/guild/repositories/IGuildRepositoryAggregate';
|
||||
import type {IDiscriminatorService} from '@app/api/infrastructure/DiscriminatorService';
|
||||
import type {EntityAssetService} from '@app/api/infrastructure/EntityAssetService';
|
||||
@@ -112,6 +113,7 @@ export class AdminUserService {
|
||||
billingRepository: getBillingRepository(),
|
||||
oauth2Tokens: new OAuth2TokenRepository(),
|
||||
storeEntitlementService: deps.storeEntitlementService,
|
||||
ncmecRepository: new NcmecRepository(),
|
||||
});
|
||||
this.contactChangeLogService = contactChangeLog;
|
||||
}
|
||||
|
||||
@@ -39,7 +39,7 @@ export class AdminGuildLookupService {
|
||||
return {guild: null};
|
||||
}
|
||||
const [channels, roles, ownerUser] = await Promise.all([
|
||||
channelRepository.listGuildChannels(guildId),
|
||||
channelRepository.listGuildChannels(guildId, 'maintenance'),
|
||||
guildRepository.listRoles(guildId),
|
||||
userRepository.findUnique(guild.ownerId),
|
||||
]);
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {createTestAccount, setUserACLs} from '@app/api/auth/tests/AuthTestUtils';
|
||||
import {createChannel, createGuild} from '@app/api/channel/tests/ChannelTestUtils';
|
||||
import {
|
||||
ALL_THREADS_ACTIVE,
|
||||
resetChannelThreadsConfig,
|
||||
setChannelThreadsConfig,
|
||||
threadsRequest,
|
||||
} from '@app/api/channel/tests/ThreadTestUtils';
|
||||
import {ensureSessionStarted, sendMessage} from '@app/api/message/tests/MessageTestUtils';
|
||||
import {type ApiTestHarness, createApiTestHarness} from '@app/api/test/ApiTestHarness';
|
||||
import {createBuilder} from '@app/api/test/TestRequestBuilder';
|
||||
import {AdminACLs} from '@fluxer/constants/src/AdminACLs';
|
||||
import {ChannelTypes, MessageTypes} from '@fluxer/constants/src/ChannelConstants';
|
||||
import {ServerMessageFlags} from '@fluxer/constants/src/ThreadConstants';
|
||||
import type {ThreadChannelResponse} from '@fluxer/schema/src/domains/channel/ThreadRequestSchemas';
|
||||
import type {MessageResponse} from '@fluxer/schema/src/domains/message/MessageResponseSchemas';
|
||||
import {afterAll, beforeAll, beforeEach, describe, expect, it} from 'vitest';
|
||||
|
||||
interface BrowseResponse {
|
||||
messages: Array<{id: string; channel_id: string}>;
|
||||
message_responses?: Array<MessageResponse>;
|
||||
}
|
||||
|
||||
describe('admin thread browse', () => {
|
||||
let harness: ApiTestHarness;
|
||||
|
||||
beforeAll(async () => {
|
||||
harness = await createApiTestHarness();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await harness.reset();
|
||||
resetChannelThreadsConfig();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
resetChannelThreadsConfig();
|
||||
await harness.shutdown();
|
||||
});
|
||||
|
||||
it('returns thread messages and thread artifacts unmasked', async () => {
|
||||
await setChannelThreadsConfig(ALL_THREADS_ACTIVE);
|
||||
const owner = await createTestAccount(harness);
|
||||
await ensureSessionStarted(harness, owner.token);
|
||||
const guild = await createGuild(harness, owner.token, 'browse');
|
||||
const channel = await createChannel(harness, owner.token, guild.id, 'general');
|
||||
const source = await sendMessage(harness, owner.token, channel.id, 'source');
|
||||
await threadsRequest(harness, owner.token)
|
||||
.post(`/channels/${channel.id}/messages/${source.id}/threads`)
|
||||
.body({name: 'from source'})
|
||||
.expect(201)
|
||||
.execute();
|
||||
const standalone = await threadsRequest<ThreadChannelResponse>(harness, owner.token)
|
||||
.post(`/channels/${channel.id}/threads`)
|
||||
.body({name: 'standalone', type: ChannelTypes.PUBLIC_THREAD})
|
||||
.expect(201)
|
||||
.execute();
|
||||
const inThread = await threadsRequest<MessageResponse>(harness, owner.token)
|
||||
.post(`/channels/${standalone.id}/messages`)
|
||||
.body({content: 'inside'})
|
||||
.expect(200)
|
||||
.execute();
|
||||
const admin = await setUserACLs(harness, await createTestAccount(harness), [
|
||||
AdminACLs.AUTHENTICATE,
|
||||
AdminACLs.MESSAGE_LOOKUP,
|
||||
]);
|
||||
const parent = await createBuilder<BrowseResponse>(harness, admin.token)
|
||||
.get(`/admin/channels/${channel.id}/messages?limit=50`)
|
||||
.expect(200)
|
||||
.execute();
|
||||
const responses = parent.message_responses ?? [];
|
||||
const sourceResponse = responses.find((message) => message.id === source.id);
|
||||
expect((sourceResponse?.flags ?? 0) & ServerMessageFlags.HAS_THREAD).toBe(ServerMessageFlags.HAS_THREAD);
|
||||
expect(responses.some((message) => message.type === MessageTypes.THREAD_CREATED)).toBe(true);
|
||||
const thread = await createBuilder<BrowseResponse>(harness, admin.token)
|
||||
.get(`/admin/channels/${standalone.id}/messages?limit=50`)
|
||||
.expect(200)
|
||||
.execute();
|
||||
expect(thread.messages.map((message) => message.id)).toContain(inThread.id);
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user