mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-09 04:02:41 +09:00
Compare commits
76
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
002502a7fa | ||
|
|
1997850d55 | ||
|
|
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 | ||
|
|
05257d6439 | ||
|
|
532e828fe6 | ||
|
|
12a407aca8 | ||
|
|
5ca458dada | ||
|
|
0aeff01c2d | ||
|
|
2ac164d5b8 | ||
|
|
14d475df9a | ||
|
|
f3c777b244 | ||
|
|
1544e58e76 | ||
|
|
5d0c9c7cbe | ||
|
|
8f58fcc4c4 | ||
|
|
5799ef705d | ||
|
|
0de7dde1ce | ||
|
|
7b39e5a79d | ||
|
|
583c791016 | ||
|
|
bc5dcdfe21 | ||
|
|
973aaced96 | ||
|
|
3e9ee908f8 | ||
|
|
e7347b582c | ||
|
|
71b7cffabc | ||
|
|
da9e9ff0be | ||
|
|
c6941d5905 | ||
|
|
a3cf960660 | ||
|
|
7eebfca20b | ||
|
|
e9167d96ec | ||
|
|
1664050ef7 | ||
|
|
7fa00c0e89 | ||
|
|
81d69c41f5 | ||
|
|
4e6b837ccc | ||
|
|
a9f7a23c0d | ||
|
|
07301adc6d | ||
|
|
c6630008b5 | ||
|
|
cdcaba34ce | ||
|
|
09b9a57e38 | ||
|
|
79d7c85832 | ||
|
|
eb0e8366bc | ||
|
|
cf9752db4f | ||
|
|
d6fb3b2c50 | ||
|
|
b04fdc68df | ||
|
|
706c41aad9 | ||
|
|
db9ec0605e | ||
|
|
a95172bf88 | ||
|
|
597116a0b4 | ||
|
|
811341bc2f | ||
|
|
98fa41dcf0 |
@@ -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
|
||||
@@ -16,4 +16,10 @@ Every commit made by a contributor must include the [Developer Certificate of Or
|
||||
|
||||
## Name and marks
|
||||
|
||||
The AGPL does not grant permission to use the Fluxer name, logo or other branding. Forks must use a distinct name and branding unless Fluxer Platform AB grants permission otherwise.
|
||||
Fluxer and the Fluxer logo are trademarks of Fluxer Platform AB. Neither the AGPL nor the CC BY-SA 4.0 licence on Fluxer artwork grants trademark rights. Fluxer Platform AB grants everyone the following permissions.
|
||||
|
||||
- You may distribute unmodified builds of Fluxer, or builds with light patches, under the Fluxer name and logo. Light patches are changes for packaging, portability, security and bug fixes, configuration defaults and translations. Linux distributions, nixpkgs, Flathub and container images are all covered.
|
||||
- A self-hosted instance running such a build may show the Fluxer name and logo under the instance's own name and domain, as long as it does not imply affiliation with or endorsement by Fluxer Platform AB.
|
||||
- You may refer to Fluxer by name to describe compatibility, for example "works with Fluxer".
|
||||
|
||||
Forks with substantive functional changes must use their own name and logo. Any other use needs permission from Fluxer Platform AB. Contact support@fluxer.com.
|
||||
|
||||
@@ -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,10 +23,13 @@
|
||||
|
||||
# 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">
|
||||
<img src="./fluxer_static/marketing/screenshots/desktop-readme-1920w.png" alt="Fluxer running side by side on a desktop monitor and a phone" width="640">
|
||||
<img src="https://fluxer.app/static/img/screenshots-desktop-readme-1920w.70cb6ce340007e0a.png" alt="Fluxer running side by side on a desktop monitor and a phone" width="640">
|
||||
</p>
|
||||
|
||||
## Download
|
||||
@@ -143,14 +146,13 @@ Full setup notes, including canary, are in the [Linux repositories documentation
|
||||
|
||||
The source is licensed under the [AGPL-3.0-or-later](./LICENSE) license.
|
||||
|
||||
Fluxer branding, icons, default avatars, badge artwork, screenshots and marketing
|
||||
imagery are copyright Fluxer, all rights reserved, as set out in
|
||||
[fluxer_static/LICENSE](./fluxer_static/LICENSE). Third-party material keeps its own
|
||||
terms, listed in
|
||||
Fluxer artwork, such as the logo, icons, badges and default avatars, is
|
||||
licensed under [CC BY-SA 4.0](./fluxer_static/LICENSE). Third-party material
|
||||
keeps its own terms, listed in
|
||||
[fluxer_static/THIRD_PARTY_LICENSES.md](./fluxer_static/THIRD_PARTY_LICENSES.md).
|
||||
|
||||
Public availability of this repository does not grant trademark, brand, or
|
||||
endorsement rights.
|
||||
Use of the Fluxer name and logo is covered by the
|
||||
[name and marks policy](./.github/GOVERNANCE.md#name-and-marks).
|
||||
|
||||
[win-setup-x64]: https://pkgs.fluxer.com/desktop/stable/win32/x64/latest/setup
|
||||
[win-setup-arm64]: https://pkgs.fluxer.com/desktop/stable/win32/arm64/latest/setup
|
||||
|
||||
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
|
||||
|
||||
@@ -138,6 +138,7 @@ MEILI_MASTER_KEY=CHANGE_ME
|
||||
#FLUXER_STRIPE_PRICES={}
|
||||
#FLUXER_STRIPE_LEGACY_PRICES={}
|
||||
#FLUXER_API_DONATION_PROXY_KEY=
|
||||
#FLUXER_API_TRUSTED_CALLERS=[]
|
||||
#FLUXER_VISIONARIES_GUILD_ID=
|
||||
#FLUXER_VISIONARIES_GUILD_VISIONARY_ROLE_ID=
|
||||
|
||||
@@ -183,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
|
||||
@@ -217,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.
|
||||
@@ -258,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=
|
||||
@@ -305,10 +310,12 @@ 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]
|
||||
FLUXER_EMAIL_FROM_NAME=Fluxer
|
||||
#[email protected]
|
||||
FLUXER_EMAIL_APP_BASE_URL=
|
||||
FLUXER_EMAIL_SMTP_HOST=
|
||||
FLUXER_EMAIL_SMTP_PORT=587
|
||||
@@ -331,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
|
||||
|
||||
@@ -356,9 +367,8 @@ FLUXER_DISCOVERY_ENABLED=true
|
||||
#FLUXER_GIFT_ENDPOINT=
|
||||
#FLUXER_MEDIA_PROXY_UPLOAD_RELAY_ENDPOINT=
|
||||
#PUBLIC_BOOTSTRAP_API_PUBLIC_ENDPOINT=
|
||||
# These follow FLUXER_STATIC_CDN_ENDPOINT first, then the public origin.
|
||||
# This follows FLUXER_STATIC_CDN_ENDPOINT first, then the public origin.
|
||||
#FLUXER_GATEWAY_STATIC_CDN_ENDPOINT=
|
||||
#FLUXER_UNFURL_STATIC_CDN_ENDPOINT=
|
||||
# These follow FLUXER_MEDIA_ENDPOINT first, then the public origin.
|
||||
#FLUXER_MEDIA_PROXY_PUBLIC_ENDPOINT=
|
||||
#FLUXER_GATEWAY_MEDIA_PROXY_ENDPOINT=
|
||||
@@ -411,7 +421,7 @@ FLUXER_DISCOVERY_ENABLED=true
|
||||
#FLUXER_POSTGRES_MEMORY_RESERVATION=3gb
|
||||
#FLUXER_VALKEY_MEMORY_LIMIT=256mb
|
||||
#FLUXER_NATS_MEMORY_LIMIT=256mb
|
||||
#FLUXER_MEILISEARCH_MEMORY_LIMIT=768mb
|
||||
#FLUXER_MEILISEARCH_MEMORY_LIMIT=1536mb
|
||||
#FLUXER_SEAWEEDFS_MEMORY_LIMIT=2gb
|
||||
#FLUXER_SEAWEEDFS_INIT_MEMORY_LIMIT=128mb
|
||||
#FLUXER_LIVEKIT_MEMORY_LIMIT=512mb
|
||||
@@ -437,8 +447,11 @@ FLUXER_DISCOVERY_ENABLED=true
|
||||
#FLUXER_UNFURL_SHARD_MEMORY_LIMIT=256mb
|
||||
#FLUXER_ADMIN_MEMORY_LIMIT=256mb
|
||||
|
||||
# Meilisearch indexing memory. Keep it well under the container limit above.
|
||||
#FLUXER_MEILISEARCH_MAX_INDEXING_MEMORY=384mb
|
||||
# Meilisearch indexing memory and threads. Each indexing thread needs its own
|
||||
# buffers on top of the indexing memory, so raise the threads only together with
|
||||
# the container limit above.
|
||||
#FLUXER_MEILISEARCH_MAX_INDEXING_MEMORY=256mb
|
||||
#FLUXER_MEILISEARCH_MAX_INDEXING_THREADS=2
|
||||
#FLUXER_MEILISEARCH_ENV=production
|
||||
#FLUXER_MEILISEARCH_NO_ANALYTICS=true
|
||||
|
||||
|
||||
@@ -42,7 +42,7 @@
|
||||
reverse_proxy admin:8080
|
||||
}
|
||||
|
||||
@staticAssets path /web/* /emoji/* /libs/* /avatars/* /badges/* /desktop/* /embeds/*
|
||||
@staticAssets path /web/* /emoji/* /libs/* /avatars/* /badges/* /desktop/*
|
||||
handle @staticAssets {
|
||||
reverse_proxy static-proxy:8080
|
||||
}
|
||||
|
||||
@@ -113,6 +113,7 @@ x-fluxer-env: &fluxer-env
|
||||
FLUXER_EMAIL_PROVIDER: ${FLUXER_EMAIL_PROVIDER:-}
|
||||
FLUXER_EMAIL_FROM_EMAIL: ${FLUXER_EMAIL_FROM_EMAIL:-noreply@localhost}
|
||||
FLUXER_EMAIL_FROM_NAME: ${FLUXER_EMAIL_FROM_NAME:-}
|
||||
FLUXER_EMAIL_REPLY_TO_EMAIL: ${FLUXER_EMAIL_REPLY_TO_EMAIL:-}
|
||||
FLUXER_EMAIL_APP_BASE_URL: ${FLUXER_EMAIL_APP_BASE_URL:-}
|
||||
FLUXER_EMAIL_WEBHOOK_SECRET: ${FLUXER_EMAIL_WEBHOOK_SECRET:-}
|
||||
FLUXER_EMAIL_SMTP_HOST: ${FLUXER_EMAIL_SMTP_HOST:-}
|
||||
@@ -127,6 +128,7 @@ x-fluxer-env: &fluxer-env
|
||||
FLUXER_STRIPE_PRICES: ${FLUXER_STRIPE_PRICES:-}
|
||||
FLUXER_STRIPE_LEGACY_PRICES: ${FLUXER_STRIPE_LEGACY_PRICES:-}
|
||||
FLUXER_API_DONATION_PROXY_KEY: ${FLUXER_API_DONATION_PROXY_KEY:-}
|
||||
FLUXER_API_TRUSTED_CALLERS: ${FLUXER_API_TRUSTED_CALLERS:-}
|
||||
FLUXER_VISIONARIES_GUILD_ID: ${FLUXER_VISIONARIES_GUILD_ID:-}
|
||||
FLUXER_VISIONARIES_GUILD_VISIONARY_ROLE_ID: ${FLUXER_VISIONARIES_GUILD_VISIONARY_ROLE_ID:-}
|
||||
|
||||
@@ -150,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:-}
|
||||
@@ -173,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}
|
||||
@@ -329,12 +334,13 @@ services:
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: ${FLUXER_MEILISEARCH_MEMORY_LIMIT:-768mb}
|
||||
memory: ${FLUXER_MEILISEARCH_MEMORY_LIMIT:-1536mb}
|
||||
environment:
|
||||
MEILI_ENV: ${FLUXER_MEILISEARCH_ENV:-production}
|
||||
MEILI_NO_ANALYTICS: "${FLUXER_MEILISEARCH_NO_ANALYTICS:-true}"
|
||||
MEILI_UPGRADE_DB: "true"
|
||||
MEILI_MAX_INDEXING_MEMORY: ${FLUXER_MEILISEARCH_MAX_INDEXING_MEMORY:-384mb}
|
||||
MEILI_MAX_INDEXING_MEMORY: ${FLUXER_MEILISEARCH_MAX_INDEXING_MEMORY:-256mb}
|
||||
MEILI_MAX_INDEXING_THREADS: ${FLUXER_MEILISEARCH_MAX_INDEXING_THREADS:-2}
|
||||
MEILI_MASTER_KEY: ${MEILI_MASTER_KEY:?set MEILI_MASTER_KEY in .env}
|
||||
volumes:
|
||||
- meilisearch-data:/meili_data
|
||||
@@ -609,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:-}
|
||||
@@ -664,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
|
||||
@@ -843,8 +851,6 @@ services:
|
||||
FLUXER_SVC_MODE: shard
|
||||
FLUXER_SVC_SHARD_ID: "0"
|
||||
FLUXER_MEDIA_PROXY_ENDPOINT: http://media-proxy:8080
|
||||
FLUXER_UNFURL_STATIC_CDN_ENDPOINT: ${FLUXER_UNFURL_STATIC_CDN_ENDPOINT:-}
|
||||
FLUXER_STATIC_CDN_ENDPOINT: ${FLUXER_STATIC_CDN_ENDPOINT:-${FLUXER_PUBLIC_ORIGIN:-${FLUXER_PUBLIC_SCHEME:-https}://${FLUXER_DOMAIN}}}
|
||||
healthcheck: *fluxer-svc-healthcheck
|
||||
depends_on:
|
||||
nats: {condition: service_healthy}
|
||||
|
||||
@@ -600,7 +600,7 @@ fn select_faces(package_dir: &Path) -> Vec<Face> {
|
||||
}
|
||||
assert!(
|
||||
face["unicodeRange"].is_null(),
|
||||
"{wanted} face {} carries a unicode-range; Latin-core faces must not",
|
||||
"{wanted} face {} has a unicode-range; Latin-core faces must not",
|
||||
face["file"]
|
||||
);
|
||||
faces.push(Face {
|
||||
|
||||
+975
-308
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";
|
||||
@@ -88,7 +90,6 @@ pub const USER_VIEW_DOB: &str = "user:view:dob";
|
||||
pub const USER_VIEW_EMAIL: &str = "user:view:email";
|
||||
pub const USER_VIEW_IP: &str = "user:view:ip";
|
||||
pub const USER_TEMP_BAN: &str = "user:temp_ban";
|
||||
pub const USER_UPDATE_BOT_STATUS: &str = "user:update:bot_status";
|
||||
pub const USER_UPDATE_DOB: &str = "user:update:dob";
|
||||
pub const USER_UPDATE_EMAIL: &str = "user:update:email";
|
||||
pub const USER_UPDATE_FLAGS: &str = "user:update:flags";
|
||||
@@ -181,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,
|
||||
@@ -193,7 +196,6 @@ pub const ALL_ACLS: &[&str] = &[
|
||||
USER_VIEW_EMAIL,
|
||||
USER_VIEW_IP,
|
||||
USER_TEMP_BAN,
|
||||
USER_UPDATE_BOT_STATUS,
|
||||
USER_UPDATE_DOB,
|
||||
USER_UPDATE_EMAIL,
|
||||
USER_UPDATE_FLAGS,
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
use crate::api::generated::{snowflake, types as generated_types};
|
||||
|
||||
use super::client::{AdminApiClient, ApiError, ApiResult};
|
||||
use super::types::{BanAvatarResult, BanCheckResult, BulkBanResult};
|
||||
use super::types::{BanAvatarResult, BanCheckResult, BlocklistEntryPage, BulkBanResult};
|
||||
|
||||
impl AdminApiClient {
|
||||
pub async fn ban_email(&self, email: &str, audit_log_reason: Option<&str>) -> ApiResult<()> {
|
||||
@@ -28,11 +28,23 @@ impl AdminApiClient {
|
||||
self.check_blocklist_entry("email", email, None).await
|
||||
}
|
||||
|
||||
pub async fn ban_ip(&self, ip: &str, audit_log_reason: Option<&str>) -> ApiResult<()> {
|
||||
pub async fn ban_ip(
|
||||
&self,
|
||||
ip: &str,
|
||||
duration_hours: u32,
|
||||
audit_log_reason: Option<&str>,
|
||||
) -> ApiResult<()> {
|
||||
self.create_blocklist_entry(
|
||||
"ip",
|
||||
generated_types::AdminBlocklistEntryCreateRequest::from(
|
||||
generated_types::BanIpRequest { ip: ip.to_owned() },
|
||||
generated_types::BanIpRequest {
|
||||
duration_hours: Some(
|
||||
i32::try_from(duration_hours)
|
||||
.map_err(|e| ApiError::Parse(e.to_string()))?
|
||||
.into(),
|
||||
),
|
||||
ip: ip.to_owned(),
|
||||
},
|
||||
),
|
||||
audit_log_reason,
|
||||
)
|
||||
@@ -136,6 +148,19 @@ impl AdminApiClient {
|
||||
self.check_blocklist_entry("url-domain", domain, None).await
|
||||
}
|
||||
|
||||
pub async fn list_url_domain_entries(
|
||||
&self,
|
||||
after: Option<&str>,
|
||||
) -> ApiResult<BlocklistEntryPage> {
|
||||
let list_type = blocklist_list_type("url-domain")?;
|
||||
let response = self
|
||||
.generated()
|
||||
.list_admin_blocklist_entries(list_type, after, Some(BLOCKLIST_PAGE_SIZE), None)
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
self.generated_value(response.into_inner())
|
||||
}
|
||||
|
||||
pub async fn ban_file_sha(
|
||||
&self,
|
||||
sha256_hex: &str,
|
||||
@@ -323,6 +348,8 @@ impl AdminApiClient {
|
||||
|
||||
const PROFILE_SUBSTRING_LIST: &str = "profile-substring";
|
||||
|
||||
const BLOCKLIST_PAGE_SIZE: &str = "200";
|
||||
|
||||
fn blocklist_list_type(list_type: &str) -> ApiResult<generated_types::AdminBlocklistListType> {
|
||||
generated_types::AdminBlocklistListType::try_from(list_type)
|
||||
.map_err(|e| ApiError::Parse(e.to_string()))
|
||||
|
||||
@@ -432,7 +432,7 @@ mod tests {
|
||||
use serde_json::{Value, json};
|
||||
|
||||
#[test]
|
||||
fn audit_log_reason_header_carries_utf8_bytes() {
|
||||
fn audit_log_reason_header_keeps_utf8_bytes() {
|
||||
let reason = "§ 3 Regel – wiederholt 日本";
|
||||
let value = audit_log_reason_header(reason).expect("valid reason header");
|
||||
assert_eq!(value.as_bytes(), reason.as_bytes());
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -255,9 +255,30 @@ pub enum FlashLevel {
|
||||
pub struct BanCheckResult {
|
||||
pub banned: bool,
|
||||
#[serde(default)]
|
||||
pub expires_at: Option<String>,
|
||||
#[serde(default)]
|
||||
pub entries: Vec<serde_json::Value>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
pub struct BlocklistEntry {
|
||||
pub value: String,
|
||||
#[serde(default)]
|
||||
pub match_subdomains: Option<bool>,
|
||||
#[serde(default)]
|
||||
pub category: Option<String>,
|
||||
#[serde(default)]
|
||||
pub created_at: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
pub struct BlocklistEntryPage {
|
||||
pub items: Vec<BlocklistEntry>,
|
||||
pub has_more: bool,
|
||||
#[serde(default)]
|
||||
pub next_after: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
pub struct BulkBanResult {
|
||||
pub job_id: String,
|
||||
|
||||
@@ -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 {
|
||||
@@ -305,28 +306,6 @@ impl AdminApiClient {
|
||||
Ok(resp.user)
|
||||
}
|
||||
|
||||
pub async fn set_bot_status(&self, user_id: &str, is_bot: bool) -> ApiResult<AdminUser> {
|
||||
let body = generated_types::AdminUserBotStatusRequest { bot: is_bot };
|
||||
let response = self
|
||||
.generated()
|
||||
.set_admin_user_bot_status(&snowflake(user_id), &body)
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
let resp: UserMutationResponse = self.generated_value(response.into_inner())?;
|
||||
Ok(resp.user)
|
||||
}
|
||||
|
||||
pub async fn set_system_status(&self, user_id: &str, is_system: bool) -> ApiResult<AdminUser> {
|
||||
let body = generated_types::AdminUserSystemStatusRequest { system: is_system };
|
||||
let response = self
|
||||
.generated()
|
||||
.set_admin_user_system_status(&snowflake(user_id), &body)
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
let resp: UserMutationResponse = self.generated_value(response.into_inner())?;
|
||||
Ok(resp.user)
|
||||
}
|
||||
|
||||
pub async fn change_username(
|
||||
&self,
|
||||
user_id: &str,
|
||||
@@ -495,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;
|
||||
|
||||
+107
-44
@@ -1,7 +1,10 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use crate::{
|
||||
api::client::AdminApiClient,
|
||||
api::{
|
||||
client::{AdminApiClient, ApiError},
|
||||
types::FlashMessage,
|
||||
},
|
||||
middleware::{auth::AuthContext, csrf, htmx},
|
||||
state::AppState,
|
||||
templates,
|
||||
@@ -13,10 +16,12 @@ use axum::{
|
||||
response::{Html, IntoResponse, Response},
|
||||
routing::get,
|
||||
};
|
||||
use serde::Deserialize;
|
||||
|
||||
use super::ActionQuery;
|
||||
use super::bans_actions::{
|
||||
BanFormData, custom_flash, execute_ban, extract_value, flash_response, render_inline_flash,
|
||||
to_flash,
|
||||
};
|
||||
|
||||
pub fn router() -> Router<AppState> {
|
||||
@@ -43,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(
|
||||
@@ -61,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
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -90,17 +120,18 @@ 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.hashes.as_deref(),
|
||||
form.sha256_list.as_deref(),
|
||||
form.audit_log_reason.as_deref(),
|
||||
let (level, msg) = execute_ban(&client, ban_key, action, &value, form).await;
|
||||
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,
|
||||
)
|
||||
.await;
|
||||
flash_response(config, auth, is_htmx, level, &msg, ban_cfg, csrf_token)
|
||||
}
|
||||
|
||||
macro_rules! ban_post {
|
||||
@@ -118,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,
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -141,16 +176,56 @@ ban_post!(url_bans_post, "url-bans");
|
||||
ban_post!(file_sha_bans_post, "file-sha-bans");
|
||||
ban_post!(avatar_hash_bans_post, "avatar-hash-bans");
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct UrlDomainListQuery {
|
||||
after: Option<String>,
|
||||
}
|
||||
|
||||
async fn render_url_domain_page(
|
||||
state: &AppState,
|
||||
auth: &AuthContext,
|
||||
flash: Option<&FlashMessage>,
|
||||
csrf_token: &str,
|
||||
after: Option<&str>,
|
||||
) -> Response {
|
||||
let config = state.config();
|
||||
let client = AdminApiClient::new(state.http_client(), config, &auth.session);
|
||||
let entries = match client.list_url_domain_entries(after).await {
|
||||
Ok(page) => Some(page),
|
||||
Err(error) => {
|
||||
tracing::warn!(%error, "admin API request failed: list URL domain blocklist");
|
||||
None
|
||||
}
|
||||
};
|
||||
let markup = templates::pages::url_domain_bans::url_domain_bans_page(
|
||||
config,
|
||||
auth,
|
||||
flash,
|
||||
csrf_token,
|
||||
entries.as_ref(),
|
||||
);
|
||||
Html(markup.into_string()).into_response()
|
||||
}
|
||||
|
||||
fn ban_url_domain_error(domain: &str, error: &ApiError) -> String {
|
||||
match error {
|
||||
ApiError::Http { status: 400, .. } => {
|
||||
format!("Failed to ban {domain}: not a valid domain, or the pattern is too broad")
|
||||
}
|
||||
_ => format!("Failed to ban {domain}"),
|
||||
}
|
||||
}
|
||||
|
||||
async fn url_domain_bans(
|
||||
State(state): State<AppState>,
|
||||
auth: axum::Extension<AuthContext>,
|
||||
request: Request,
|
||||
) -> Response {
|
||||
let config = state.config();
|
||||
let csrf_token = csrf::get_csrf_token(&request);
|
||||
let markup =
|
||||
templates::pages::url_domain_bans::url_domain_bans_page(config, &auth.0, None, &csrf_token);
|
||||
Html(markup.into_string()).into_response()
|
||||
let Query(query): Query<UrlDomainListQuery> =
|
||||
Query::try_from_uri(request.uri()).unwrap_or(Query(UrlDomainListQuery { after: None }));
|
||||
let after = query.after.as_deref().filter(|value| !value.is_empty());
|
||||
render_url_domain_page(&state, &auth.0, None, &csrf_token, after).await
|
||||
}
|
||||
|
||||
async fn url_domain_bans_post(
|
||||
@@ -181,10 +256,10 @@ async fn url_domain_bans_post(
|
||||
.ban_url_domain(&domain, m_sub, form.audit_log_reason.as_deref())
|
||||
.await
|
||||
{
|
||||
Ok(()) => ("success", format!("Domain {domain} banned successfully")),
|
||||
Ok(()) => ("success", format!("{domain} banned successfully")),
|
||||
Err(error) => {
|
||||
tracing::warn!(%error, domain, "admin API request failed: ban URL domain");
|
||||
("error", format!("Failed to ban domain {domain}"))
|
||||
("error", ban_url_domain_error(&domain, &error))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -192,15 +267,15 @@ async fn url_domain_bans_post(
|
||||
.unban_url_domain(&domain, form.audit_log_reason.as_deref())
|
||||
.await
|
||||
{
|
||||
Ok(()) => ("success", format!("Domain {domain} unbanned")),
|
||||
Ok(()) => ("success", format!("{domain} unbanned")),
|
||||
Err(error) => {
|
||||
tracing::warn!(%error, domain, "admin API request failed: unban URL domain");
|
||||
("error", format!("Failed to unban domain {domain}"))
|
||||
("error", format!("Failed to unban {domain}"))
|
||||
}
|
||||
},
|
||||
"check" => match client.check_url_domain_ban(&domain).await {
|
||||
Ok(r) if r.banned => ("info", format!("Domain {domain} is banned")),
|
||||
Ok(_) => ("info", format!("Domain {domain} is NOT banned")),
|
||||
Ok(r) if r.banned => ("info", format!("{domain} is blocked")),
|
||||
Ok(_) => ("info", format!("{domain} is NOT blocked")),
|
||||
Err(error) => {
|
||||
tracing::warn!(%error, domain, "admin API request failed: check URL domain ban");
|
||||
("error", "Error checking ban status".into())
|
||||
@@ -208,15 +283,11 @@ async fn url_domain_bans_post(
|
||||
},
|
||||
_ => ("error", "Unknown action".into()),
|
||||
};
|
||||
custom_flash(
|
||||
config,
|
||||
&auth.0,
|
||||
is_htmx,
|
||||
level,
|
||||
&msg,
|
||||
&csrf_token,
|
||||
"url-domain",
|
||||
)
|
||||
if is_htmx {
|
||||
return render_inline_flash(level, &msg);
|
||||
}
|
||||
let flash = to_flash(level, &msg);
|
||||
render_url_domain_page(&state, &auth.0, Some(&flash), &csrf_token, None).await
|
||||
}
|
||||
|
||||
async fn profile_substring_bans(
|
||||
@@ -288,13 +359,5 @@ async fn profile_substring_bans_post(
|
||||
},
|
||||
_ => ("error", "Unknown action".into()),
|
||||
};
|
||||
custom_flash(
|
||||
config,
|
||||
&auth.0,
|
||||
is_htmx,
|
||||
level,
|
||||
&msg,
|
||||
&csrf_token,
|
||||
"profile-substring",
|
||||
)
|
||||
custom_flash(config, &auth.0, is_htmx, level, &msg, &csrf_token)
|
||||
}
|
||||
|
||||
@@ -34,6 +34,8 @@ pub struct BanFormData {
|
||||
#[serde(default)]
|
||||
pub substring: Option<String>,
|
||||
#[serde(default)]
|
||||
pub duration_hours: Option<String>,
|
||||
#[serde(default)]
|
||||
pub audit_log_reason: Option<String>,
|
||||
#[serde(default)]
|
||||
pub _csrf: Option<String>,
|
||||
@@ -58,10 +60,12 @@ pub async fn execute_ban(
|
||||
ban_type: &str,
|
||||
action: &str,
|
||||
value: &str,
|
||||
bulk_hashes: Option<&str>,
|
||||
bulk_sha256_list: Option<&str>,
|
||||
audit_log_reason: Option<&str>,
|
||||
form: &BanFormData,
|
||||
) -> (&'static str, String) {
|
||||
let bulk_hashes = form.hashes.as_deref();
|
||||
let bulk_sha256_list = form.sha256_list.as_deref();
|
||||
let duration_hours = form.duration_hours.as_deref();
|
||||
let audit_log_reason = form.audit_log_reason.as_deref();
|
||||
if (action == "bulk-ban" || action == "bulk-ban-files") && ban_type == "file-sha-bans" {
|
||||
let raw_hashes = if action == "bulk-ban-files" {
|
||||
bulk_sha256_list
|
||||
@@ -74,6 +78,9 @@ pub async fn execute_ban(
|
||||
return ("error", "Value is required".into());
|
||||
}
|
||||
match action {
|
||||
"ban" if ban_type == "ip-bans" => {
|
||||
execute_ip_ban(client, value, duration_hours, audit_log_reason).await
|
||||
}
|
||||
"ban" => execute_single_ban(client, ban_type, value, audit_log_reason).await,
|
||||
"unban" => execute_single_unban(client, ban_type, value, audit_log_reason).await,
|
||||
"check" => execute_check(client, ban_type, value).await,
|
||||
@@ -107,6 +114,34 @@ async fn execute_bulk_ban(
|
||||
}
|
||||
}
|
||||
|
||||
async fn execute_ip_ban(
|
||||
client: &AdminApiClient,
|
||||
value: &str,
|
||||
duration_hours: Option<&str>,
|
||||
audit_log_reason: Option<&str>,
|
||||
) -> (&'static str, String) {
|
||||
let duration_hours = match duration_hours.map(str::trim).filter(|v| !v.is_empty()) {
|
||||
None => 0,
|
||||
Some(raw) => match raw.parse::<u32>() {
|
||||
Ok(hours) => hours,
|
||||
Err(_) => return ("error", "Invalid ban duration".into()),
|
||||
},
|
||||
};
|
||||
let success_message = if duration_hours == 0 {
|
||||
format!("{value} banned permanently")
|
||||
} else {
|
||||
format!(
|
||||
"{value} banned for {}",
|
||||
crate::templates::pages::bans::ip_ban_duration_label(duration_hours)
|
||||
)
|
||||
};
|
||||
ban_action_result(
|
||||
client.ban_ip(value, duration_hours, audit_log_reason).await,
|
||||
success_message,
|
||||
format!("Failed to ban {value}"),
|
||||
)
|
||||
}
|
||||
|
||||
async fn execute_single_ban(
|
||||
client: &AdminApiClient,
|
||||
ban_type: &str,
|
||||
@@ -114,7 +149,6 @@ async fn execute_single_ban(
|
||||
audit_log_reason: Option<&str>,
|
||||
) -> (&'static str, String) {
|
||||
let result = match ban_type {
|
||||
"ip-bans" => client.ban_ip(value, audit_log_reason).await,
|
||||
"email-bans" => client.ban_email(value, audit_log_reason).await,
|
||||
"phrase-bans" => client.ban_phrase(value, audit_log_reason).await,
|
||||
"url-bans" => client.ban_url(value, audit_log_reason).await,
|
||||
@@ -166,7 +200,10 @@ async fn execute_check(
|
||||
_ => return ("error", "Unknown ban type".into()),
|
||||
};
|
||||
match result {
|
||||
Ok(r) if r.banned => ("info", format!("{value} is banned")),
|
||||
Ok(r) if r.banned => match r.expires_at {
|
||||
Some(expires_at) => ("info", format!("{value} is banned until {expires_at}")),
|
||||
None => ("info", format!("{value} is banned")),
|
||||
},
|
||||
Ok(_) => ("info", format!("{value} is NOT banned")),
|
||||
Err(error) => {
|
||||
tracing::warn!(%error, ban_type, value, "admin API request failed: check ban status");
|
||||
@@ -189,6 +226,7 @@ fn ban_action_result(
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn flash_response(
|
||||
config: &crate::config::AdminConfig,
|
||||
auth: &AuthContext,
|
||||
@@ -197,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()
|
||||
}
|
||||
}
|
||||
@@ -243,25 +288,16 @@ pub fn custom_flash(
|
||||
level: &str,
|
||||
message: &str,
|
||||
csrf_token: &str,
|
||||
page_type: &str,
|
||||
) -> Response {
|
||||
if is_htmx {
|
||||
return render_inline_flash(level, message);
|
||||
}
|
||||
let flash = to_flash(level, message);
|
||||
let markup = match page_type {
|
||||
"url-domain" => templates::pages::url_domain_bans::url_domain_bans_page(
|
||||
config,
|
||||
auth,
|
||||
Some(&flash),
|
||||
csrf_token,
|
||||
),
|
||||
_ => templates::pages::profile_substring_bans::profile_substring_bans_page(
|
||||
config,
|
||||
auth,
|
||||
Some(&flash),
|
||||
csrf_token,
|
||||
),
|
||||
};
|
||||
let markup = templates::pages::profile_substring_bans::profile_substring_bans_page(
|
||||
config,
|
||||
auth,
|
||||
Some(&flash),
|
||||
csrf_token,
|
||||
);
|
||||
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,11 @@ 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" => instance_config_result(
|
||||
client
|
||||
.update_instance_config(&build_channel_threads_update(&form))
|
||||
.await,
|
||||
),
|
||||
"update_experiment_delivery" => match build_experiment_delivery_update(&form) {
|
||||
Ok(update) => instance_config_result(client.update_instance_config(&update).await),
|
||||
Err(message) => FlashData::error(message),
|
||||
@@ -671,6 +677,28 @@ fn build_captcha_update(form: &MultiValueForm) -> Result<InstanceConfigUpdateReq
|
||||
})
|
||||
}
|
||||
|
||||
fn build_channel_threads_update(form: &MultiValueForm) -> InstanceConfigUpdateRequest {
|
||||
let channel_threads = if form.bool_value("channel_threads_everyone") {
|
||||
ChannelThreadsConfigUpdateRequest {
|
||||
enabled: Some(true),
|
||||
guild_basis_points: Some(EXPERIMENT_ROLLOUT_BASIS_POINTS_MAX),
|
||||
user_basis_points: Some(EXPERIMENT_ROLLOUT_BASIS_POINTS_MAX),
|
||||
disabled_guild_ids: Some(Vec::new()),
|
||||
excluded_user_ids: Some(Vec::new()),
|
||||
..Default::default()
|
||||
}
|
||||
} else {
|
||||
ChannelThreadsConfigUpdateRequest {
|
||||
enabled: Some(false),
|
||||
..Default::default()
|
||||
}
|
||||
};
|
||||
InstanceConfigUpdateRequest {
|
||||
channel_threads: Some(channel_threads),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn build_experiment_delivery_update(
|
||||
form: &MultiValueForm,
|
||||
) -> Result<InstanceConfigUpdateRequest, String> {
|
||||
@@ -838,7 +866,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 +1225,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 +1464,30 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_channel_threads_update_turns_threads_on_for_everyone() {
|
||||
let form = MultiValueForm::parse(b"_csrf=token&channel_threads_everyone=true");
|
||||
assert_eq!(
|
||||
serde_json::to_value(build_channel_threads_update(&form)).expect("serializable update"),
|
||||
serde_json::json!({"channel_threads": {
|
||||
"enabled": true,
|
||||
"guild_basis_points": 10000,
|
||||
"user_basis_points": 10000,
|
||||
"disabled_guild_ids": [],
|
||||
"excluded_user_ids": [],
|
||||
}})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_channel_threads_update_only_turns_threads_off_when_unchecked() {
|
||||
let form = MultiValueForm::parse(b"_csrf=token");
|
||||
assert_eq!(
|
||||
serde_json::to_value(build_channel_threads_update(&form)).expect("serializable update"),
|
||||
serde_json::json!({"channel_threads": {"enabled": false}})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_push_relay_update_reads_the_consent_checkbox() {
|
||||
let unchecked = build_push_relay_update(&MultiValueForm::parse(b"_csrf=token"));
|
||||
|
||||
@@ -178,22 +178,6 @@ pub async fn dispatch(
|
||||
"Failed to clear user fields",
|
||||
)
|
||||
}
|
||||
"set_bot_status" => {
|
||||
let val = form.bool_value("bot");
|
||||
DispatchOutcome::from_result(
|
||||
client.set_bot_status(user_id, val).await,
|
||||
"Bot status updated successfully",
|
||||
"Failed to update bot status",
|
||||
)
|
||||
}
|
||||
"set_system_status" => {
|
||||
let val = form.bool_value("system");
|
||||
DispatchOutcome::from_result(
|
||||
client.set_system_status(user_id, val).await,
|
||||
"System status updated successfully",
|
||||
"Failed to update system status",
|
||||
)
|
||||
}
|
||||
"change_username" => {
|
||||
let Some(username) = get("username") else {
|
||||
return DispatchOutcome::error("Username is required");
|
||||
@@ -259,8 +243,11 @@ pub async fn dispatch(
|
||||
let Some(ip) = get("ip") else {
|
||||
return DispatchOutcome::error("IP address is required");
|
||||
};
|
||||
let Ok(duration) = form.parse_value::<u32>("duration_hours") else {
|
||||
return DispatchOutcome::error("Invalid ban duration");
|
||||
};
|
||||
DispatchOutcome::from_result(
|
||||
client.ban_ip(&ip, None).await,
|
||||
client.ban_ip(&ip, duration.unwrap_or(0), None).await,
|
||||
"IP banned successfully",
|
||||
"Failed to ban IP",
|
||||
)
|
||||
@@ -380,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,
|
||||
},
|
||||
};
|
||||
@@ -20,6 +20,24 @@ pub struct BanConfig {
|
||||
pub entity_name: &'static str,
|
||||
pub active_page: &'static str,
|
||||
pub show_bulk_tools: bool,
|
||||
pub show_duration: bool,
|
||||
}
|
||||
|
||||
const IP_BAN_DURATIONS: &[(u32, &str)] = &[
|
||||
(24, "1 day"),
|
||||
(168, "7 days"),
|
||||
(720, "30 days"),
|
||||
(0, "Permanent"),
|
||||
];
|
||||
|
||||
pub fn ip_ban_duration_label(hours: u32) -> String {
|
||||
IP_BAN_DURATIONS
|
||||
.iter()
|
||||
.find(|(value, _)| *value == hours)
|
||||
.map_or_else(
|
||||
|| format!("{hours} hours"),
|
||||
|(_, label)| (*label).to_owned(),
|
||||
)
|
||||
}
|
||||
|
||||
pub const BAN_CONFIGS: &[BanConfig] = &[
|
||||
@@ -33,6 +51,7 @@ pub const BAN_CONFIGS: &[BanConfig] = &[
|
||||
entity_name: "IP/CIDR",
|
||||
active_page: "ip-bans",
|
||||
show_bulk_tools: false,
|
||||
show_duration: true,
|
||||
},
|
||||
BanConfig {
|
||||
title: "Email Bans",
|
||||
@@ -44,6 +63,7 @@ pub const BAN_CONFIGS: &[BanConfig] = &[
|
||||
entity_name: "Email",
|
||||
active_page: "email-bans",
|
||||
show_bulk_tools: false,
|
||||
show_duration: false,
|
||||
},
|
||||
BanConfig {
|
||||
title: "Phrase Bans",
|
||||
@@ -55,6 +75,7 @@ pub const BAN_CONFIGS: &[BanConfig] = &[
|
||||
entity_name: "Phrase",
|
||||
active_page: "phrase-bans",
|
||||
show_bulk_tools: false,
|
||||
show_duration: false,
|
||||
},
|
||||
BanConfig {
|
||||
title: "URL Blocklist",
|
||||
@@ -66,6 +87,7 @@ pub const BAN_CONFIGS: &[BanConfig] = &[
|
||||
entity_name: "URL",
|
||||
active_page: "url-bans",
|
||||
show_bulk_tools: false,
|
||||
show_duration: false,
|
||||
},
|
||||
BanConfig {
|
||||
title: "File SHA Blocklist",
|
||||
@@ -77,6 +99,7 @@ pub const BAN_CONFIGS: &[BanConfig] = &[
|
||||
entity_name: "SHA-256",
|
||||
active_page: "file-sha-bans",
|
||||
show_bulk_tools: true,
|
||||
show_duration: false,
|
||||
},
|
||||
BanConfig {
|
||||
title: "Avatar Hash Blocklist",
|
||||
@@ -88,6 +111,7 @@ pub const BAN_CONFIGS: &[BanConfig] = &[
|
||||
entity_name: "Avatar Hash",
|
||||
active_page: "avatar-hash-bans",
|
||||
show_bulk_tools: false,
|
||||
show_duration: false,
|
||||
},
|
||||
BanConfig {
|
||||
title: "URL Domain Blocklist",
|
||||
@@ -99,6 +123,7 @@ pub const BAN_CONFIGS: &[BanConfig] = &[
|
||||
entity_name: "Domain",
|
||||
active_page: "url-domain-bans",
|
||||
show_bulk_tools: false,
|
||||
show_duration: false,
|
||||
},
|
||||
BanConfig {
|
||||
title: "Profile Substring Blocklist",
|
||||
@@ -110,6 +135,7 @@ pub const BAN_CONFIGS: &[BanConfig] = &[
|
||||
entity_name: "Substring",
|
||||
active_page: "profile-substring-bans",
|
||||
show_bulk_tools: false,
|
||||
show_duration: false,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -123,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))
|
||||
@@ -163,6 +195,9 @@ fn ban_card(base: &str, cfg: &BanConfig, csrf_token: &str) -> Markup {
|
||||
(csrf_input(csrf_token))
|
||||
div class="space-y-4" {
|
||||
(form_field(cfg.input_name, cfg.input_label, cfg.input_type, cfg.placeholder, true))
|
||||
@if cfg.show_duration {
|
||||
(duration_field())
|
||||
}
|
||||
(form_field("audit_log_reason", "Private reason (audit log, optional)", "text", "Why is this ban being applied?", false))
|
||||
(submit_btn("Ban", cfg.entity_name, false))
|
||||
}
|
||||
@@ -394,6 +429,24 @@ fn form_field(
|
||||
}
|
||||
}
|
||||
|
||||
fn duration_field() -> Markup {
|
||||
html! {
|
||||
div class="space-y-1" {
|
||||
label for="duration_hours" class="block text-sm font-medium text-neutral-700" {
|
||||
"Duration"
|
||||
}
|
||||
select id="duration_hours" name="duration_hours"
|
||||
class="block w-full rounded-md border border-neutral-300 px-3 py-2 text-sm \
|
||||
shadow-sm focus:border-brand-primary focus:outline-none focus:ring-1 \
|
||||
focus:ring-brand-primary" {
|
||||
@for &(value, label) in IP_BAN_DURATIONS {
|
||||
option value=(value) { (label) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn textarea_field(name: &str, label: &str, required: bool) -> Markup {
|
||||
html! {
|
||||
div class="space-y-1" {
|
||||
|
||||
@@ -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,14 +2,15 @@
|
||||
|
||||
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, 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,
|
||||
@@ -111,6 +112,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 +163,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(
|
||||
@@ -181,8 +190,13 @@ pub fn instance_config_page(
|
||||
"Gateway rollout behavior and the limit rules applied to users and guilds.",
|
||||
html! {
|
||||
(gateway_rollout_section(base, csrf_token, &instance_config.gateway_rollout))
|
||||
(domain_migration_section(base, csrf_token, &instance_config.domain_migration))
|
||||
(plutonium_page_section(base, csrf_token, &instance_config.plutonium_page))
|
||||
@if !instance_config.self_hosted {
|
||||
(domain_migration_section(base, csrf_token, &instance_config.domain_migration))
|
||||
}
|
||||
(channel_threads_section(base, csrf_token, &instance_config.channel_threads))
|
||||
@if !instance_config.self_hosted {
|
||||
(plutonium_page_section(base, csrf_token, &instance_config.plutonium_page))
|
||||
}
|
||||
(experiment_delivery_section(base, csrf_token, &instance_config.experiment_delivery))
|
||||
@if let Some(limit_config) = limit_config {
|
||||
(limit_config_section(base, limit_config))
|
||||
@@ -500,10 +514,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 +605,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 +1489,48 @@ fn captcha_section(base: &str, csrf_token: &str, captcha: &CaptchaConfigResponse
|
||||
)
|
||||
}
|
||||
|
||||
fn channel_threads_available_to_everyone(channel_threads: &ChannelThreadsConfigResponse) -> bool {
|
||||
channel_threads.enabled
|
||||
&& channel_threads.guild_basis_points >= 10_000
|
||||
&& channel_threads.user_basis_points >= 10_000
|
||||
&& channel_threads.disabled_guild_ids.is_empty()
|
||||
&& channel_threads.excluded_user_ids.is_empty()
|
||||
}
|
||||
|
||||
fn channel_threads_section(
|
||||
base: &str,
|
||||
csrf_token: &str,
|
||||
channel_threads: &ChannelThreadsConfigResponse,
|
||||
) -> Markup {
|
||||
let everyone = channel_threads_available_to_everyone(channel_threads);
|
||||
section_card_with_description(
|
||||
"Threads and forums",
|
||||
"Threads, forum channels and media channels in every community.",
|
||||
html! {
|
||||
form method="post" action={(base) "/instance-config?action=update_channel_threads"} {
|
||||
(csrf_input(csrf_token))
|
||||
div class="space-y-6" {
|
||||
(checkbox(
|
||||
"channel_threads_everyone",
|
||||
"true",
|
||||
"Available to everyone",
|
||||
everyone,
|
||||
true,
|
||||
))
|
||||
@if channel_threads.enabled && !everyone {
|
||||
p class="text-xs text-neutral-500" {
|
||||
"Currently on for part of this instance. Saving applies the setting above to everyone."
|
||||
}
|
||||
}
|
||||
(form_actions(html! {
|
||||
(submit_button("Save"))
|
||||
}))
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
fn experiment_delivery_section(
|
||||
base: &str,
|
||||
csrf_token: &str,
|
||||
@@ -2032,6 +2146,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 +2274,38 @@ mod tests {
|
||||
assert!(!markup.contains("at the cap"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn channel_threads_section_is_a_single_everyone_toggle() {
|
||||
let everyone = ChannelThreadsConfigResponse {
|
||||
enabled: true,
|
||||
guild_basis_points: 10_000,
|
||||
user_basis_points: 10_000,
|
||||
..ChannelThreadsConfigResponse::default()
|
||||
};
|
||||
let on = channel_threads_section("/admin", "csrf", &everyone).into_string();
|
||||
assert!(on.contains("action=update_channel_threads"));
|
||||
assert!(on.contains("name=\"channel_threads_everyone\""));
|
||||
assert!(on.contains("checked"));
|
||||
assert!(!on.contains("basis_points"));
|
||||
assert!(!on.contains("part of this instance"));
|
||||
|
||||
let partial = ChannelThreadsConfigResponse {
|
||||
enabled: true,
|
||||
enabled_guild_ids: vec!["1600000000000000001".to_owned()],
|
||||
user_basis_points: 10_000,
|
||||
..ChannelThreadsConfigResponse::default()
|
||||
};
|
||||
let partial = channel_threads_section("/admin", "csrf", &partial).into_string();
|
||||
assert!(!partial.contains("checked"));
|
||||
assert!(partial.contains("part of this instance"));
|
||||
|
||||
let off =
|
||||
channel_threads_section("/admin", "csrf", &ChannelThreadsConfigResponse::default())
|
||||
.into_string();
|
||||
assert!(!off.contains("checked"));
|
||||
assert!(!off.contains("part of this instance"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plutonium_page_section_shows_the_rollout_and_list_counts() {
|
||||
let plutonium_page = PlutoniumPageConfigResponse {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use crate::{
|
||||
api::types::{BlocklistEntry, BlocklistEntryPage},
|
||||
config::AdminConfig,
|
||||
middleware::auth::AuthContext,
|
||||
templates::{
|
||||
components::{form::checkbox, page_container::page_header},
|
||||
components::{
|
||||
badge::{BadgeVariant, badge},
|
||||
form::{checkbox, csrf_input},
|
||||
page_container::page_header,
|
||||
table::{data_table, empty_state, table_cell, table_row},
|
||||
},
|
||||
layout::admin_layout,
|
||||
pages::blocklist_helpers::{
|
||||
BlocklistActionVariant, blocklist_action_card, blocklist_text_field,
|
||||
@@ -13,15 +19,21 @@ use crate::{
|
||||
};
|
||||
use maud::{Markup, html};
|
||||
|
||||
const PAGE_DESCRIPTION: &str = "A domain entry blocks that host and, when it matches subdomains, every host under it. \
|
||||
A pattern such as *shop*.example.com matches the one label left of a registrable domain, so it blocks \
|
||||
shop.example.com and my-shop-2.example.com but never example.com itself. Patterns are matched against the \
|
||||
ASCII form of a host.";
|
||||
|
||||
pub fn url_domain_bans_page(
|
||||
config: &AdminConfig,
|
||||
auth: &AuthContext,
|
||||
flash: Option<&crate::api::types::FlashMessage>,
|
||||
csrf_token: &str,
|
||||
entries: Option<&BlocklistEntryPage>,
|
||||
) -> Markup {
|
||||
let base = &config.base_path;
|
||||
let content = html! {
|
||||
(page_header("URL Domain Blocklist", None))
|
||||
(page_header("URL Domain Blocklist", Some(PAGE_DESCRIPTION)))
|
||||
div class="grid gap-6 lg:grid-cols-2" {
|
||||
(ban_card(base, csrf_token))
|
||||
(check_card(base, csrf_token))
|
||||
@@ -29,6 +41,9 @@ pub fn url_domain_bans_page(
|
||||
div class="mt-6" {
|
||||
(unban_card(base, csrf_token))
|
||||
}
|
||||
div class="mt-6" {
|
||||
(entries_card(base, csrf_token, entries))
|
||||
}
|
||||
};
|
||||
admin_layout(
|
||||
config,
|
||||
@@ -43,15 +58,15 @@ pub fn url_domain_bans_page(
|
||||
fn ban_card(base: &str, csrf_token: &str) -> Markup {
|
||||
let action_url = format!("{base}/url-domain-bans?action=ban&_csrf={csrf_token}");
|
||||
blocklist_action_card(
|
||||
"Ban URL Domain",
|
||||
"Ban URL Domain or Pattern",
|
||||
&action_url,
|
||||
csrf_token,
|
||||
html! {
|
||||
(blocklist_text_field("domain", "Domain", "example.com", true))
|
||||
(blocklist_text_field("domain", "Domain or pattern", "example.com or *shop*.example.com", true))
|
||||
(checkbox("match_subdomains", "true", "Match subdomains (e.g. sub.example.com)", true, true))
|
||||
(blocklist_text_field("audit_log_reason", "Private reason (audit log, optional)", "Why is this ban being applied?", false))
|
||||
},
|
||||
"Ban Domain",
|
||||
"Ban",
|
||||
BlocklistActionVariant::Primary,
|
||||
)
|
||||
}
|
||||
@@ -59,13 +74,13 @@ fn ban_card(base: &str, csrf_token: &str) -> Markup {
|
||||
fn check_card(base: &str, csrf_token: &str) -> Markup {
|
||||
let action_url = format!("{base}/url-domain-bans?action=check&_csrf={csrf_token}");
|
||||
blocklist_action_card(
|
||||
"Check Domain Ban Status",
|
||||
"Test a Host or URL",
|
||||
&action_url,
|
||||
csrf_token,
|
||||
html! {
|
||||
(blocklist_text_field("domain", "Domain", "example.com", true))
|
||||
(blocklist_text_field("domain", "Host or URL", "shop-2.example.com or https://shop.example.com/x", true))
|
||||
},
|
||||
"Check Status",
|
||||
"Test",
|
||||
BlocklistActionVariant::Primary,
|
||||
)
|
||||
}
|
||||
@@ -73,14 +88,131 @@ fn check_card(base: &str, csrf_token: &str) -> Markup {
|
||||
fn unban_card(base: &str, csrf_token: &str) -> Markup {
|
||||
let action_url = format!("{base}/url-domain-bans?action=unban&_csrf={csrf_token}");
|
||||
blocklist_action_card(
|
||||
"Remove Domain Ban",
|
||||
"Remove Domain or Pattern",
|
||||
&action_url,
|
||||
csrf_token,
|
||||
html! {
|
||||
(blocklist_text_field("domain", "Domain", "example.com", true))
|
||||
(blocklist_text_field("domain", "Domain or pattern", "example.com or *shop*.example.com", true))
|
||||
(blocklist_text_field("audit_log_reason", "Private reason (audit log, optional)", "Why is this ban being removed?", false))
|
||||
},
|
||||
"Unban Domain",
|
||||
"Unban",
|
||||
BlocklistActionVariant::Danger,
|
||||
)
|
||||
}
|
||||
|
||||
fn entries_card(base: &str, csrf_token: &str, entries: Option<&BlocklistEntryPage>) -> Markup {
|
||||
html! {
|
||||
div class="rounded-lg border border-neutral-200 bg-white p-4 shadow-sm sm:p-6" {
|
||||
div class="mb-4 flex items-center justify-between gap-4" {
|
||||
h3 class="text-base font-medium text-neutral-900" { "Blocked Domains and Patterns" }
|
||||
a href={(base) "/url-domain-bans"} class="text-sm text-brand-primary hover:underline" { "Refresh" }
|
||||
}
|
||||
@match entries {
|
||||
None => {
|
||||
p class="text-sm text-red-700" { "Failed to load the blocklist entries" }
|
||||
}
|
||||
Some(page) => {
|
||||
(entries_table(base, csrf_token, page))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn entries_table(base: &str, csrf_token: &str, page: &BlocklistEntryPage) -> Markup {
|
||||
if page.items.is_empty() {
|
||||
return empty_state("No domains or patterns are blocked");
|
||||
}
|
||||
let next_after = page.next_after.as_deref().filter(|_| page.has_more);
|
||||
html! {
|
||||
(data_table(
|
||||
&["Value", "Kind", "Subdomains", "Category", "Added", ""],
|
||||
html! {
|
||||
@for entry in &page.items {
|
||||
(entry_row(base, csrf_token, entry))
|
||||
}
|
||||
},
|
||||
))
|
||||
@if let Some(next) = next_after {
|
||||
div class="mt-4" {
|
||||
a href={(base) "/url-domain-bans?after=" (urlencoding::encode(next))}
|
||||
class="text-sm text-brand-primary hover:underline" {
|
||||
"Next page"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn entry_row(base: &str, csrf_token: &str, entry: &BlocklistEntry) -> Markup {
|
||||
let action_url = format!("{base}/url-domain-bans?action=unban&_csrf={csrf_token}");
|
||||
let is_pattern = entry.value.contains('*');
|
||||
table_row(html! {
|
||||
(table_cell(false, html! { code class="break-all" { (entry.value) } }))
|
||||
(table_cell(false, html! {
|
||||
@if is_pattern {
|
||||
(badge("Pattern", BadgeVariant::Info))
|
||||
} @else {
|
||||
(badge("Domain", BadgeVariant::Default))
|
||||
}
|
||||
}))
|
||||
(table_cell(true, html! {
|
||||
@if entry.match_subdomains.unwrap_or(true) { "Yes" } @else { "No" }
|
||||
}))
|
||||
(table_cell(true, html! { (entry.category.as_deref().unwrap_or("")) }))
|
||||
(table_cell(true, html! { (entry.created_at.as_deref().unwrap_or("")) }))
|
||||
(table_cell(false, html! {
|
||||
form method="post" action=(action_url) {
|
||||
(csrf_input(csrf_token))
|
||||
input type="hidden" name="domain" value=(entry.value);
|
||||
button type="submit" class="text-sm font-medium text-red-600 hover:text-red-700" {
|
||||
"Remove"
|
||||
}
|
||||
}
|
||||
}))
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn entry(value: &str, match_subdomains: bool) -> BlocklistEntry {
|
||||
BlocklistEntry {
|
||||
value: value.to_owned(),
|
||||
match_subdomains: Some(match_subdomains),
|
||||
category: Some("manual".to_owned()),
|
||||
created_at: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn entries_table_lists_patterns_with_remove_forms() {
|
||||
let page = BlocklistEntryPage {
|
||||
items: vec![
|
||||
entry("*shop*.example.com", false),
|
||||
entry("store.example.com", true),
|
||||
],
|
||||
has_more: true,
|
||||
next_after: Some("store.example.com".to_owned()),
|
||||
};
|
||||
let markup = entries_table("/admin", "token", &page).into_string();
|
||||
assert!(markup.contains("*shop*.example.com"));
|
||||
assert!(markup.contains(">Pattern</span>"));
|
||||
assert!(markup.contains(">Domain</span>"));
|
||||
assert!(markup.contains(r#"name="domain" value="*shop*.example.com""#));
|
||||
assert!(markup.contains("/admin/url-domain-bans?action=unban&_csrf=token"));
|
||||
assert!(markup.contains("/admin/url-domain-bans?after=store.example.com"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn entries_table_reports_an_empty_list() {
|
||||
let page = BlocklistEntryPage {
|
||||
items: Vec::new(),
|
||||
has_more: false,
|
||||
next_after: None,
|
||||
};
|
||||
let markup = entries_table("/admin", "token", &page).into_string();
|
||||
assert!(markup.contains("No domains or patterns are blocked"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,28 +22,113 @@ 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))
|
||||
(user_status_card(base, user, csrf_token))
|
||||
(security_actions_card(base, user, csrf_token))
|
||||
(webauthn_credentials_card(base, user, webauthn_credentials, csrf_token))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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" {
|
||||
@@ -47,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:" }
|
||||
@@ -153,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))
|
||||
}
|
||||
}
|
||||
}))
|
||||
}
|
||||
@@ -204,19 +310,6 @@ fn clear_fields_card(base: &str, user: &AdminUser, csrf_token: &str) -> Markup {
|
||||
}
|
||||
}
|
||||
|
||||
fn user_status_card(base: &str, user: &AdminUser, csrf_token: &str) -> Markup {
|
||||
let is_bot = user.bot;
|
||||
let is_sys = user.system;
|
||||
html! {
|
||||
(card_with_header("User Status", html! {
|
||||
div class="grid grid-cols-1 gap-4 md:grid-cols-2" {
|
||||
(status_toggle(base, &user.id, "set_bot_status", is_bot, "bot", csrf_token))
|
||||
(status_toggle(base, &user.id, "set_system_status", is_sys, "system", csrf_token))
|
||||
}
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
fn security_actions_card(base: &str, user: &AdminUser, csrf_token: &str) -> Markup {
|
||||
html! {
|
||||
(card_with_header("Security Actions", html! {
|
||||
@@ -357,40 +450,3 @@ fn action_form(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn status_toggle(
|
||||
base: &str,
|
||||
uid: &str,
|
||||
action: &str,
|
||||
active: bool,
|
||||
kind: &str,
|
||||
csrf: &str,
|
||||
) -> Markup {
|
||||
let status_val = if active { "false" } else { "true" };
|
||||
let label = format!(
|
||||
"{} {} Status",
|
||||
if active { "Remove" } else { "Set" },
|
||||
capitalize(kind)
|
||||
);
|
||||
let action_url = format!("{base}/users/{uid}?action={action}&status={status_val}&tab=account");
|
||||
html! {
|
||||
form method="post"
|
||||
action=(&action_url)
|
||||
hx-post=(&action_url)
|
||||
hx-target="#flash-container"
|
||||
hx-swap="none"
|
||||
hx-push-url="false" {
|
||||
(csrf_input(csrf))
|
||||
input type="hidden" name=(kind) value=(status_val);
|
||||
button type="submit" class=(BTN_CLS) { (label) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn capitalize(s: &str) -> String {
|
||||
let mut c = s.chars();
|
||||
match c.next() {
|
||||
None => String::new(),
|
||||
Some(f) => f.to_uppercase().chain(c).collect(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,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)
|
||||
@@ -431,11 +437,6 @@ fn acls_card(
|
||||
(flag_checkbox("acls[]", item.to_string(), item, checked, true))
|
||||
}
|
||||
}
|
||||
@for item in &user.acls {
|
||||
@if !acl::ALL_ACLS.iter().any(|known| known == &item.as_str()) {
|
||||
input type="hidden" name="acls[]" value=(item);
|
||||
}
|
||||
}
|
||||
(form_actions(html! {
|
||||
(submit_button("Save ACLs"))
|
||||
}))
|
||||
@@ -578,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(
|
||||
@@ -859,6 +939,14 @@ fn deserialize_ban_check_response() {
|
||||
assert!(resp.banned);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deserialize_ban_check_response_with_expiry() {
|
||||
let json = r#"{"banned": true, "expires_at": "2026-10-04T12:00:00.000Z"}"#;
|
||||
let resp: types::BanCheckResult = serde_json::from_str(json).unwrap();
|
||||
assert!(resp.banned);
|
||||
assert_eq!(resp.expires_at.as_deref(), Some("2026-10-04T12:00:00.000Z"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deserialize_codes_response() {
|
||||
let json = r#"{"codes": ["ABC-DEF", "GHI-JKL"]}"#;
|
||||
@@ -1061,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,50 @@ 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("Threads and forums"), "{body}");
|
||||
assert!(body.contains("Available to everyone"), "{body}");
|
||||
assert!(
|
||||
body.contains(r#"name="channel_threads_everyone""#),
|
||||
"{body}"
|
||||
);
|
||||
assert!(
|
||||
!body.contains("channel_threads_guild_basis_points"),
|
||||
"{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 in [
|
||||
format!("_csrf={csrf_token}&channel_threads_everyone=true"),
|
||||
format!("_csrf={csrf_token}"),
|
||||
] {
|
||||
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("Instance config updated"), "{toast}");
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn instance_config_registration_tables_show_copyable_urls_and_compact_pending_actions() {
|
||||
let app = setup().await;
|
||||
@@ -1193,6 +1238,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,
|
||||
|
||||
@@ -195,7 +195,7 @@ async fn post_form(app: &TestApp, uri: &str, body: &str) -> StatusCode {
|
||||
let csrf = body
|
||||
.split('&')
|
||||
.find_map(|pair| pair.strip_prefix("_csrf="))
|
||||
.expect("form carries a csrf token");
|
||||
.expect("form has a csrf token");
|
||||
let response = app
|
||||
.router
|
||||
.clone()
|
||||
|
||||
@@ -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(),
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -163,7 +163,7 @@ async fn post_form(app: &TestApp, uri: &str, body: &str) -> StatusCode {
|
||||
let csrf = body
|
||||
.split('&')
|
||||
.find_map(|pair| pair.strip_prefix("_csrf="))
|
||||
.expect("form carries a csrf token");
|
||||
.expect("form has a csrf token");
|
||||
let response = app
|
||||
.router
|
||||
.clone()
|
||||
|
||||
@@ -79,6 +79,7 @@
|
||||
"sharp": "catalog:",
|
||||
"stripe": "catalog:",
|
||||
"tempy": "catalog:",
|
||||
"tldts": "catalog:",
|
||||
"transliteration": "catalog:",
|
||||
"tsx": "catalog:",
|
||||
"uint8array-extras": "catalog:",
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ export interface EmailConfig {
|
||||
enabled: boolean;
|
||||
fromEmail: string;
|
||||
fromName: string;
|
||||
replyTo?: string | null;
|
||||
appBaseUrl: string;
|
||||
marketingBaseUrl: string;
|
||||
}
|
||||
@@ -14,6 +15,7 @@ export interface EmailMessage {
|
||||
email: string;
|
||||
name: string;
|
||||
};
|
||||
replyTo?: string;
|
||||
subject: string;
|
||||
text: string;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {EmailI18nService} from '@pkgs/email/src/EmailI18nService';
|
||||
import type {EmailConfig, EmailMessage, IEmailProvider} from '@pkgs/email/src/EmailProviderTypes';
|
||||
import {EmailService} from '@pkgs/email/src/EmailService';
|
||||
import {describe, expect, it} from 'vitest';
|
||||
|
||||
const CONFIG: EmailConfig = {
|
||||
enabled: true,
|
||||
fromEmail: '[email protected]',
|
||||
fromName: 'Fluxer',
|
||||
appBaseUrl: 'https://example.com',
|
||||
marketingBaseUrl: 'https://example.com',
|
||||
};
|
||||
|
||||
async function sendWith(config: EmailConfig): Promise<EmailMessage> {
|
||||
const sent: Array<EmailMessage> = [];
|
||||
const provider: IEmailProvider = {
|
||||
sendEmail: async (message) => {
|
||||
sent.push(message);
|
||||
return true;
|
||||
},
|
||||
};
|
||||
const service = new EmailService(config, new EmailI18nService(), provider);
|
||||
await expect(service.sendRegistrationApprovedEmail('[email protected]', 'testuser', 'en-US')).resolves.toBe(true);
|
||||
expect(sent).toHaveLength(1);
|
||||
return sent[0];
|
||||
}
|
||||
|
||||
describe('EmailService reply-to', () => {
|
||||
it('sets the configured reply-to address on every message', async () => {
|
||||
const message = await sendWith({...CONFIG, replyTo: '[email protected]'});
|
||||
expect(message.replyTo).toBe('[email protected]');
|
||||
expect(message.from).toEqual({email: '[email protected]', name: 'Fluxer'});
|
||||
});
|
||||
|
||||
it.each([undefined, null, ''])('omits the reply-to address when it is %j', async (replyTo) => {
|
||||
const message = await sendWith({...CONFIG, replyTo});
|
||||
expect(message).not.toHaveProperty('replyTo');
|
||||
});
|
||||
});
|
||||
@@ -375,6 +375,7 @@ export class EmailService implements IEmailService {
|
||||
return this.provider.sendEmail({
|
||||
to: email,
|
||||
from: {email: this.config.fromEmail, name: this.config.fromName},
|
||||
...(this.config.replyTo ? {replyTo: this.config.replyTo} : {}),
|
||||
subject,
|
||||
text: body,
|
||||
});
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {SmtpEmailProvider} from '@pkgs/email/src/SmtpEmailProvider';
|
||||
import {beforeEach, describe, expect, it, vi} from 'vitest';
|
||||
|
||||
const {sendMail} = vi.hoisted(() => ({sendMail: vi.fn()}));
|
||||
|
||||
vi.mock('nodemailer', () => ({
|
||||
default: {createTransport: () => ({sendMail, verify: vi.fn()})},
|
||||
}));
|
||||
|
||||
const MESSAGE = {
|
||||
to: '[email protected]',
|
||||
from: {email: '[email protected]', name: 'Fluxer'},
|
||||
subject: 'Subject',
|
||||
text: 'Body',
|
||||
};
|
||||
|
||||
function createProvider(): SmtpEmailProvider {
|
||||
return new SmtpEmailProvider({host: 'smtp.example.com', port: 587, username: 'user', password: 'pass'});
|
||||
}
|
||||
|
||||
describe('SmtpEmailProvider', () => {
|
||||
beforeEach(() => {
|
||||
sendMail.mockReset();
|
||||
sendMail.mockResolvedValue({});
|
||||
});
|
||||
|
||||
it('passes the reply-to address to nodemailer', async () => {
|
||||
await expect(createProvider().sendEmail({...MESSAGE, replyTo: '[email protected]'})).resolves.toBe(true);
|
||||
expect(sendMail).toHaveBeenCalledWith({
|
||||
to: '[email protected]',
|
||||
from: 'Fluxer <[email protected]>',
|
||||
replyTo: '[email protected]',
|
||||
subject: 'Subject',
|
||||
text: 'Body',
|
||||
});
|
||||
});
|
||||
|
||||
it('omits the reply-to address when the message has none', async () => {
|
||||
await expect(createProvider().sendEmail(MESSAGE)).resolves.toBe(true);
|
||||
expect(sendMail.mock.calls[0][0]).not.toHaveProperty('replyTo');
|
||||
});
|
||||
});
|
||||
@@ -45,6 +45,7 @@ export class SmtpEmailProvider implements IEmailProvider {
|
||||
await this.transporter.sendMail({
|
||||
to: message.to,
|
||||
from: `${message.from.name} <${message.from.email}>`,
|
||||
...(message.replyTo ? {replyTo: message.replyTo} : {}),
|
||||
subject: message.subject,
|
||||
text: message.text,
|
||||
});
|
||||
|
||||
@@ -80,7 +80,7 @@ describe('reconstructOriginalUrl', () => {
|
||||
).toBe('https://static.klipy.com/ii/c8/28/HkAKKCzZ.webp?v=query_param&goes=here');
|
||||
});
|
||||
|
||||
it('does not double the question mark when the query segment carries one', () => {
|
||||
it('does not double the question mark when the query segment has one', () => {
|
||||
const decoded = reconstructOriginalUrl('%3Fa%3D1/https/example.com/x.png');
|
||||
expect(decoded).toBe('https://example.com/x.png?a=1');
|
||||
expect(decoded).not.toContain('??');
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -43,13 +43,13 @@ describe('buildAPIServerOptions', () => {
|
||||
expect(server.requestTimeout).toBe(120_000);
|
||||
});
|
||||
|
||||
test('carries the operator header timeout from the environment into the server', async () => {
|
||||
test('passes the operator header timeout from the environment into the server', async () => {
|
||||
const server = await listenWithEnv({FLUXER_API_HEADERS_TIMEOUT_MS: '45000'});
|
||||
expect(server.headersTimeout).toBe(45_000);
|
||||
expect(server.requestTimeout).toBe(120_000);
|
||||
});
|
||||
|
||||
test('carries the operator request timeout from the environment into the server', async () => {
|
||||
test('passes the operator request timeout from the environment into the server', async () => {
|
||||
const server = await listenWithEnv({FLUXER_API_REQUEST_TIMEOUT_MS: '600000'});
|
||||
expect(server.headersTimeout).toBe(30_000);
|
||||
expect(server.requestTimeout).toBe(600_000);
|
||||
@@ -134,7 +134,7 @@ describe('buildAPIConfigFromMaster stripe legacy prices', () => {
|
||||
master = await loadConfig();
|
||||
});
|
||||
|
||||
it('carries the retired stripe price map from master config onto the api config', () => {
|
||||
it('copies the retired stripe price map from master config onto the api config', () => {
|
||||
const legacyPrices = {
|
||||
monthly_brl: ['price_retired_monthly_brl'],
|
||||
yearly_brl: ['price_retired_yearly_brl_a', 'price_retired_yearly_brl_b'],
|
||||
@@ -145,7 +145,7 @@ describe('buildAPIConfigFromMaster stripe legacy prices', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('carries the retired price map even when no live prices are configured', () => {
|
||||
it('copies the retired price map even when no live prices are configured', () => {
|
||||
const withoutPrices: MasterConfig = {
|
||||
...master,
|
||||
integrations: {
|
||||
@@ -211,3 +211,75 @@ describe('buildAPIConfigFromMaster optional outbound lookups', () => {
|
||||
expect(config.breachedPasswordCheck.enabled).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
async function trustedCallersFromEnv(env: Record<string, string>) {
|
||||
for (const [key, value] of Object.entries(env)) {
|
||||
vi.stubEnv(key, value);
|
||||
}
|
||||
resetConfig();
|
||||
return buildAPIConfigFromMaster(await loadConfig()).internal.trustedCallers;
|
||||
}
|
||||
|
||||
describe('buildAPIConfigFromMaster trusted callers', () => {
|
||||
const bugsKey = 'b'.repeat(32);
|
||||
const donationKey = 'd'.repeat(32);
|
||||
|
||||
test('reads callers from FLUXER_API_TRUSTED_CALLERS', async () => {
|
||||
const callers = await trustedCallersFromEnv({
|
||||
FLUXER_API_TRUSTED_CALLERS: JSON.stringify([
|
||||
{name: 'bugs', key: bugsKey, buckets: ['oauth:token', 'oauth:revoke']},
|
||||
]),
|
||||
});
|
||||
expect(callers).toEqual([{name: 'bugs', key: bugsKey, buckets: ['oauth:token', 'oauth:revoke']}]);
|
||||
});
|
||||
|
||||
test('turns FLUXER_API_DONATION_PROXY_KEY into a caller scoped to the donation buckets', async () => {
|
||||
const callers = await trustedCallersFromEnv({FLUXER_API_DONATION_PROXY_KEY: donationKey});
|
||||
expect(callers).toEqual([
|
||||
{
|
||||
name: 'donation',
|
||||
key: donationKey,
|
||||
buckets: ['donation:request_link', 'donation:manage', 'donation:checkout'],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('keeps both forms side by side', async () => {
|
||||
const callers = await trustedCallersFromEnv({
|
||||
FLUXER_API_DONATION_PROXY_KEY: donationKey,
|
||||
FLUXER_API_TRUSTED_CALLERS: JSON.stringify([{name: 'bugs', key: bugsKey, buckets: ['oauth:token']}]),
|
||||
});
|
||||
expect(callers.map((caller) => caller.name)).toEqual(['bugs', 'donation']);
|
||||
});
|
||||
|
||||
test('tolerates bucket names and fields this build does not know', async () => {
|
||||
const callers = await trustedCallersFromEnv({
|
||||
FLUXER_API_TRUSTED_CALLERS: JSON.stringify([
|
||||
{name: 'bugs', key: bugsKey, buckets: ['oauth:token', 'future:bucket'], note: 'added later'},
|
||||
]),
|
||||
});
|
||||
expect(callers).toEqual([{name: 'bugs', key: bugsKey, buckets: ['oauth:token', 'future:bucket']}]);
|
||||
});
|
||||
|
||||
test('fails at boot on a short key', async () => {
|
||||
await expect(
|
||||
trustedCallersFromEnv({
|
||||
FLUXER_API_TRUSTED_CALLERS: JSON.stringify([{name: 'bugs', key: 'short', buckets: ['oauth:token']}]),
|
||||
}),
|
||||
).rejects.toThrow('FLUXER_API_TRUSTED_CALLERS entry 1 key must be at least 32 characters');
|
||||
});
|
||||
|
||||
test('fails at boot on an entry with no buckets', async () => {
|
||||
await expect(
|
||||
trustedCallersFromEnv({
|
||||
FLUXER_API_TRUSTED_CALLERS: JSON.stringify([{name: 'bugs', key: bugsKey, buckets: []}]),
|
||||
}),
|
||||
).rejects.toThrow('FLUXER_API_TRUSTED_CALLERS entry 1 buckets must be a non-empty list of bucket names');
|
||||
});
|
||||
|
||||
test('fails at boot on a value that is not a JSON array', async () => {
|
||||
await expect(trustedCallersFromEnv({FLUXER_API_TRUSTED_CALLERS: '{"name":"bugs"}'})).rejects.toThrow(
|
||||
'FLUXER_API_TRUSTED_CALLERS must be a JSON array',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import type {APIConfig, BlueskyOAuthConfig} from '@app/api/config/APIConfig';
|
||||
import type {APIConfig, BlueskyOAuthConfig, TrustedCallerConfig} from '@app/api/config/APIConfig';
|
||||
import {DonationRateLimitConfigs} from '@app/api/rate_limit_configs/DonationRateLimitConfig';
|
||||
import {parseIpBanEntry} from '@app/api/utils/IpRangeUtils';
|
||||
import type {WorkerTaskName} from '@app/api/worker/WorkerLaneConfig';
|
||||
import type {MasterConfig} from '@fluxer/config/src/MasterConfig';
|
||||
@@ -99,6 +100,52 @@ function mapApnsApps(
|
||||
});
|
||||
}
|
||||
|
||||
const TRUSTED_CALLER_MIN_KEY_LENGTH = 32;
|
||||
|
||||
function parseTrustedCaller(entry: unknown, index: number): TrustedCallerConfig {
|
||||
const label = `FLUXER_API_TRUSTED_CALLERS entry ${index + 1}`;
|
||||
if (typeof entry !== 'object' || entry === null || Array.isArray(entry)) {
|
||||
throw new Error(`${label} must be a JSON object`);
|
||||
}
|
||||
const name = Reflect.get(entry, 'name');
|
||||
if (typeof name !== 'string' || name.trim().length === 0) {
|
||||
throw new Error(`${label} must have a name`);
|
||||
}
|
||||
const key = Reflect.get(entry, 'key');
|
||||
if (typeof key !== 'string' || key.trim().length < TRUSTED_CALLER_MIN_KEY_LENGTH) {
|
||||
throw new Error(`${label} key must be at least ${TRUSTED_CALLER_MIN_KEY_LENGTH} characters`);
|
||||
}
|
||||
const buckets = Reflect.get(entry, 'buckets');
|
||||
if (
|
||||
!Array.isArray(buckets) ||
|
||||
buckets.length === 0 ||
|
||||
!buckets.every((bucket) => typeof bucket === 'string' && bucket.trim().length > 0)
|
||||
) {
|
||||
throw new Error(`${label} buckets must be a non-empty list of bucket names`);
|
||||
}
|
||||
return {
|
||||
name: name.trim(),
|
||||
key: key.trim(),
|
||||
buckets: buckets.map((bucket: string) => bucket.trim()),
|
||||
};
|
||||
}
|
||||
|
||||
function buildTrustedCallers(master: MasterConfig): Array<TrustedCallerConfig> {
|
||||
const trustedCallers = (master.services.api.trusted_callers ?? []).map(parseTrustedCaller);
|
||||
const donationProxyKey = (master.services.api.donation_proxy_key ?? '').trim();
|
||||
if (donationProxyKey.length > 0 && donationProxyKey.length < TRUSTED_CALLER_MIN_KEY_LENGTH) {
|
||||
throw new Error(`FLUXER_API_DONATION_PROXY_KEY must be at least ${TRUSTED_CALLER_MIN_KEY_LENGTH} characters`);
|
||||
}
|
||||
if (donationProxyKey.length > 0) {
|
||||
trustedCallers.push({
|
||||
name: 'donation',
|
||||
key: donationProxyKey,
|
||||
buckets: Object.values(DonationRateLimitConfigs).map((routeConfig) => routeConfig.bucket),
|
||||
});
|
||||
}
|
||||
return trustedCallers;
|
||||
}
|
||||
|
||||
export function buildAPIConfigFromMaster(master: MasterConfig): APIConfig {
|
||||
if (!master.internal) {
|
||||
throw new Error('internal configuration is required for the API');
|
||||
@@ -118,10 +165,7 @@ export function buildAPIConfigFromMaster(master: MasterConfig): APIConfig {
|
||||
if (Buffer.from(uploadRelaySecretBase64, 'base64').length < 32) {
|
||||
throw new Error('FLUXER_MEDIA_PROXY_UPLOAD_RELAY_SECRET_BASE64 must decode to at least 32 bytes');
|
||||
}
|
||||
const donationProxyKey = (master.services.api.donation_proxy_key ?? '').trim();
|
||||
if (donationProxyKey.length > 0 && donationProxyKey.length < 32) {
|
||||
throw new Error('FLUXER_API_DONATION_PROXY_KEY must be at least 32 characters');
|
||||
}
|
||||
const trustedCallers = buildTrustedCallers(master);
|
||||
if (!s3Config) {
|
||||
throw new Error('S3 configuration is required for the API');
|
||||
}
|
||||
@@ -143,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(',') ?? '',
|
||||
@@ -227,7 +272,7 @@ export function buildAPIConfigFromMaster(master: MasterConfig): APIConfig {
|
||||
},
|
||||
internal: {
|
||||
gatewayRpcAuthToken: master.services.gateway.rpc_auth_token ?? '',
|
||||
donationProxyKey,
|
||||
trustedCallers,
|
||||
},
|
||||
hosts: {
|
||||
marketing: extractHostname(master.endpoints.marketing),
|
||||
@@ -248,6 +293,7 @@ export function buildAPIConfigFromMaster(master: MasterConfig): APIConfig {
|
||||
webhookSecret: master.integrations.email.webhook_secret ?? undefined,
|
||||
fromEmail: master.integrations.email.from_email,
|
||||
fromName: master.integrations.email.from_name,
|
||||
replyToEmail: master.integrations.email.reply_to_email,
|
||||
appBaseUrl: resolveEmailAppBaseUrl(master),
|
||||
smtp: master.integrations.email.smtp
|
||||
? {
|
||||
@@ -346,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,
|
||||
@@ -367,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,
|
||||
@@ -384,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,
|
||||
@@ -445,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,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user