mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-08 11:42:32 +09:00
Compare commits
55
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
@@ -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
|
||||
|
||||
|
||||
Generated
+2
@@ -1986,11 +1986,13 @@ dependencies = [
|
||||
"fluxer-svc",
|
||||
"fluxer_common",
|
||||
"futures",
|
||||
"hmac 0.13.0",
|
||||
"moka",
|
||||
"rmp-serde",
|
||||
"scylla",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2 0.11.0",
|
||||
"tokio",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
@@ -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,6 +310,7 @@ FLUXER_KLIPY_API_KEY=
|
||||
# Hosts the api never unfurls, comma separated.
|
||||
#FLUXER_API_UNFURL_IGNORED_HOSTS=
|
||||
|
||||
# Email delivery. Only an instance where members sign in with email needs it.
|
||||
FLUXER_EMAIL_ENABLED=false
|
||||
FLUXER_EMAIL_PROVIDER=none
|
||||
FLUXER_EMAIL_FROM_EMAIL=[email protected]
|
||||
@@ -332,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
|
||||
|
||||
@@ -357,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=
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -128,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:-}
|
||||
|
||||
@@ -151,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:-}
|
||||
@@ -174,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}
|
||||
@@ -611,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:-}
|
||||
@@ -845,8 +850,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 {
|
||||
|
||||
+220
-167
@@ -280,7 +280,7 @@
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
|
||||
}
|
||||
},
|
||||
"description": "Renames an API key or replaces the access control lists (ACLs) it carries. The key may only carry permissions the acting admin already holds. Omitted fields are left unchanged and the key material is never rotated or returned.",
|
||||
"description": "Renames an API key or replaces the access control lists (ACLs) it has. The key may only hold permissions the acting admin already holds. Omitted fields are left unchanged and the key material is never rotated or returned.",
|
||||
"security": [{"adminApiKey": []}],
|
||||
"parameters": [
|
||||
{
|
||||
@@ -1108,7 +1108,7 @@
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
|
||||
}
|
||||
},
|
||||
"description": "List every blocklist this instance maintains, the request field that carries an entry value, the extra fields its entries accept, and which of the bulk and update operations it supports.",
|
||||
"description": "List every blocklist this instance maintains, the request field that holds an entry value, the extra fields its entries accept, and which of the bulk and update operations it supports.",
|
||||
"security": [{"adminApiKey": []}]
|
||||
}
|
||||
},
|
||||
@@ -1451,7 +1451,7 @@
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
|
||||
}
|
||||
},
|
||||
"description": "Report whether a value is currently blocked by a blocklist. The value is percent-encoded in the path. An IP address can still match a broader stored CIDR entry, and a URL can match a banned domain. The profile-substring blocklist requires a scope.",
|
||||
"description": "Report whether a value is currently blocked by a blocklist. The value is percent-encoded in the path. An IP address can still match a broader stored CIDR entry, and a url-domain value can be a hostname or an http(s) URL that a stored domain or pattern covers. The profile-substring blocklist requires a scope.",
|
||||
"security": [{"adminApiKey": []}],
|
||||
"parameters": [
|
||||
{
|
||||
@@ -1529,7 +1529,7 @@
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
|
||||
}
|
||||
},
|
||||
"description": "Rewrite the stored fields of a blocklist entry without removing and re-adding it. The stored metadata is replaced by the supplied fields, so fields left out fall back to their defaults. Only blocklists whose entries carry fields accept this operation, reported as supports_update by GET /admin/blocklists.",
|
||||
"description": "Rewrite the stored fields of a blocklist entry without removing and re-adding it. The stored metadata is replaced by the supplied fields, so fields left out fall back to their defaults. Only blocklists whose entries have fields accept this operation, reported as supports_update by GET /admin/blocklists.",
|
||||
"security": [{"adminApiKey": []}],
|
||||
"parameters": [
|
||||
{
|
||||
@@ -4680,7 +4680,7 @@
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
|
||||
}
|
||||
},
|
||||
"description": "Searches the messages of a channel by content, or resolves a single message by its ID or by one of its attachments. Passing message_id returns that message with the messages surrounding it; passing attachment_id together with filename returns the message carrying that attachment with its surrounding context. Requires MESSAGE_LOOKUP permission.",
|
||||
"description": "Searches the messages of a channel by content, or resolves a single message by its ID or by one of its attachments. Passing message_id returns that message with the messages surrounding it; passing attachment_id together with filename returns the message with that attachment with its surrounding context. Requires MESSAGE_LOOKUP permission.",
|
||||
"security": [{"adminApiKey": []}],
|
||||
"parameters": [
|
||||
{
|
||||
@@ -4715,10 +4715,10 @@
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"description": "Return the single message carrying this attachment together with its surrounding context; requires filename",
|
||||
"description": "Return the single message with this attachment together with its surrounding context; requires filename",
|
||||
"allOf": [{"$ref": "#/components/schemas/SnowflakeType"}]
|
||||
},
|
||||
"description": "Return the single message carrying this attachment together with its surrounding context; requires filename"
|
||||
"description": "Return the single message with this attachment together with its surrounding context; requires filename"
|
||||
},
|
||||
{
|
||||
"name": "filename",
|
||||
@@ -6137,72 +6137,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/admin/users/{user_id}/bot-status": {
|
||||
"put": {
|
||||
"operationId": "set_admin_user_bot_status",
|
||||
"summary": "Set user bot status",
|
||||
"tags": ["Admin"],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success",
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/UserMutationResponse"}}}
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad Request - The request was malformed or contained invalid data",
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
|
||||
},
|
||||
"401": {
|
||||
"description": "Unauthorized - Authentication is required or the token is invalid",
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
|
||||
},
|
||||
"403": {
|
||||
"description": "Forbidden - You do not have permission to perform this action",
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
|
||||
},
|
||||
"429": {
|
||||
"description": "Too Many Requests - You are being rate limited",
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/ThrottledError"}}},
|
||||
"headers": {
|
||||
"Retry-After": {
|
||||
"description": "Number of seconds to wait before retrying (only on 429)",
|
||||
"schema": {"type": "integer"}
|
||||
},
|
||||
"X-RateLimit-Limit": {
|
||||
"description": "The number of requests that can be made in the current window",
|
||||
"schema": {"type": "integer"}
|
||||
},
|
||||
"X-RateLimit-Remaining": {
|
||||
"description": "The number of remaining requests that can be made",
|
||||
"schema": {"type": "integer"}
|
||||
},
|
||||
"X-RateLimit-Reset": {
|
||||
"description": "Unix timestamp when the rate limit resets",
|
||||
"schema": {"type": "integer"}
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal Server Error - An unexpected error occurred",
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
|
||||
}
|
||||
},
|
||||
"description": "Mark or unmark a user account as a bot. Controls bot badge visibility and API permissions. Creates audit log entry. Requires USER_UPDATE_BOT_STATUS permission.",
|
||||
"security": [{"adminApiKey": []}],
|
||||
"parameters": [
|
||||
{
|
||||
"name": "user_id",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {"description": "The ID of the user", "allOf": [{"$ref": "#/components/schemas/SnowflakeType"}]},
|
||||
"description": "The ID of the user"
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/AdminUserBotStatusRequest"}}}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/admin/users/{user_id}/change-log": {
|
||||
"get": {
|
||||
"operationId": "list_admin_user_change_log",
|
||||
@@ -7190,6 +7124,68 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"/admin/users/{user_id}/password-reset-link": {
|
||||
"post": {
|
||||
"operationId": "create_admin_user_password_reset_link",
|
||||
"summary": "Create user password reset link",
|
||||
"tags": ["Admin"],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success",
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/AdminPasswordResetLinkResponse"}}}
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad Request - The request was malformed or contained invalid data",
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
|
||||
},
|
||||
"401": {
|
||||
"description": "Unauthorized - Authentication is required or the token is invalid",
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
|
||||
},
|
||||
"403": {
|
||||
"description": "Forbidden - You do not have permission to perform this action",
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
|
||||
},
|
||||
"429": {
|
||||
"description": "Too Many Requests - You are being rate limited",
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/ThrottledError"}}},
|
||||
"headers": {
|
||||
"Retry-After": {
|
||||
"description": "Number of seconds to wait before retrying (only on 429)",
|
||||
"schema": {"type": "integer"}
|
||||
},
|
||||
"X-RateLimit-Limit": {
|
||||
"description": "The number of requests that can be made in the current window",
|
||||
"schema": {"type": "integer"}
|
||||
},
|
||||
"X-RateLimit-Remaining": {
|
||||
"description": "The number of remaining requests that can be made",
|
||||
"schema": {"type": "integer"}
|
||||
},
|
||||
"X-RateLimit-Reset": {
|
||||
"description": "Unix timestamp when the rate limit resets",
|
||||
"schema": {"type": "integer"}
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal Server Error - An unexpected error occurred",
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
|
||||
}
|
||||
},
|
||||
"description": "Create a one-time password reset link on an instance where people sign in with a username. Hand the link to the user yourself. It works once and expires after an hour. Deletes the recovery kit of the account. Creates audit log entry. Requires USER_CREATE_PASSWORD_RESET_LINK permission and every ACL the target account holds. Fails with USERNAME_SIGN_IN_ONLY on email instances.",
|
||||
"security": [{"adminApiKey": []}],
|
||||
"parameters": [
|
||||
{
|
||||
"name": "user_id",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {"description": "The ID of the user", "allOf": [{"$ref": "#/components/schemas/SnowflakeType"}]},
|
||||
"description": "The ID of the user"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"/admin/users/{user_id}/premium-flags": {
|
||||
"patch": {
|
||||
"operationId": "update_admin_user_premium_flags",
|
||||
@@ -7324,6 +7320,65 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/admin/users/{user_id}/recovery-kit": {
|
||||
"delete": {
|
||||
"operationId": "revoke_admin_user_recovery_kit",
|
||||
"summary": "Revoke user recovery kit",
|
||||
"tags": ["Admin"],
|
||||
"responses": {
|
||||
"204": {"description": "No Content"},
|
||||
"400": {
|
||||
"description": "Bad Request - The request was malformed or contained invalid data",
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
|
||||
},
|
||||
"401": {
|
||||
"description": "Unauthorized - Authentication is required or the token is invalid",
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
|
||||
},
|
||||
"403": {
|
||||
"description": "Forbidden - You do not have permission to perform this action",
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
|
||||
},
|
||||
"429": {
|
||||
"description": "Too Many Requests - You are being rate limited",
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/ThrottledError"}}},
|
||||
"headers": {
|
||||
"Retry-After": {
|
||||
"description": "Number of seconds to wait before retrying (only on 429)",
|
||||
"schema": {"type": "integer"}
|
||||
},
|
||||
"X-RateLimit-Limit": {
|
||||
"description": "The number of requests that can be made in the current window",
|
||||
"schema": {"type": "integer"}
|
||||
},
|
||||
"X-RateLimit-Remaining": {
|
||||
"description": "The number of remaining requests that can be made",
|
||||
"schema": {"type": "integer"}
|
||||
},
|
||||
"X-RateLimit-Reset": {
|
||||
"description": "Unix timestamp when the rate limit resets",
|
||||
"schema": {"type": "integer"}
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal Server Error - An unexpected error occurred",
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
|
||||
}
|
||||
},
|
||||
"description": "Deletes the recovery kit of an account on an instance where people sign in with a username, so its key stops working. Creates audit log entry. Requires USER_DELETE_RECOVERY_KIT permission and every ACL the target account holds. Fails with USERNAME_SIGN_IN_ONLY on email instances.",
|
||||
"security": [{"adminApiKey": []}],
|
||||
"parameters": [
|
||||
{
|
||||
"name": "user_id",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {"description": "The ID of the user", "allOf": [{"$ref": "#/components/schemas/SnowflakeType"}]},
|
||||
"description": "The ID of the user"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"/admin/users/{user_id}/relationships": {
|
||||
"get": {
|
||||
"operationId": "list_admin_user_relationships",
|
||||
@@ -7783,72 +7838,6 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"/admin/users/{user_id}/system-status": {
|
||||
"put": {
|
||||
"operationId": "set_admin_user_system_status",
|
||||
"summary": "Set user system status",
|
||||
"tags": ["Admin"],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success",
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/UserMutationResponse"}}}
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad Request - The request was malformed or contained invalid data",
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
|
||||
},
|
||||
"401": {
|
||||
"description": "Unauthorized - Authentication is required or the token is invalid",
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
|
||||
},
|
||||
"403": {
|
||||
"description": "Forbidden - You do not have permission to perform this action",
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
|
||||
},
|
||||
"429": {
|
||||
"description": "Too Many Requests - You are being rate limited",
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/ThrottledError"}}},
|
||||
"headers": {
|
||||
"Retry-After": {
|
||||
"description": "Number of seconds to wait before retrying (only on 429)",
|
||||
"schema": {"type": "integer"}
|
||||
},
|
||||
"X-RateLimit-Limit": {
|
||||
"description": "The number of requests that can be made in the current window",
|
||||
"schema": {"type": "integer"}
|
||||
},
|
||||
"X-RateLimit-Remaining": {
|
||||
"description": "The number of remaining requests that can be made",
|
||||
"schema": {"type": "integer"}
|
||||
},
|
||||
"X-RateLimit-Reset": {
|
||||
"description": "Unix timestamp when the rate limit resets",
|
||||
"schema": {"type": "integer"}
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal Server Error - An unexpected error occurred",
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
|
||||
}
|
||||
},
|
||||
"description": "Mark or unmark a user as a system account. System accounts have special permissions for automated operations. Creates audit log entry. Requires USER_UPDATE_BOT_STATUS permission.",
|
||||
"security": [{"adminApiKey": []}],
|
||||
"parameters": [
|
||||
{
|
||||
"name": "user_id",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {"description": "The ID of the user", "allOf": [{"$ref": "#/components/schemas/SnowflakeType"}]},
|
||||
"description": "The ID of the user"
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/AdminUserSystemStatusRequest"}}}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/admin/users/{user_id}/traits": {
|
||||
"put": {
|
||||
"operationId": "set_admin_user_traits",
|
||||
@@ -9295,13 +9284,6 @@
|
||||
},
|
||||
"required": ["traits"]
|
||||
},
|
||||
"AdminUserSystemStatusRequest": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"system": {"type": "boolean", "description": "Whether the user should be marked as a system user"}
|
||||
},
|
||||
"required": ["system"]
|
||||
},
|
||||
"AdminStorePurchaseListResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -9456,6 +9438,20 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"AdminPasswordResetLinkResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"url": {"type": "string", "description": "Password reset link to hand to the user. It is shown only once"},
|
||||
"expires_at": {
|
||||
"type": "string",
|
||||
"format": "date-time",
|
||||
"pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z))$",
|
||||
"description": "ISO 8601 timestamp when the link stops working"
|
||||
}
|
||||
},
|
||||
"required": ["url", "expires_at"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"DeleteAllUserMessagesResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -9752,11 +9748,6 @@
|
||||
"required": ["entries", "next_page_token"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"AdminUserBotStatusRequest": {
|
||||
"type": "object",
|
||||
"properties": {"bot": {"type": "boolean", "description": "Whether the user should be marked as a bot"}},
|
||||
"required": ["bot"]
|
||||
},
|
||||
"AdminUserBanNoteRequest": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -9871,7 +9862,7 @@
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"acls": {
|
||||
"maxItems": 104,
|
||||
"maxItems": 105,
|
||||
"type": "array",
|
||||
"items": {"$ref": "#/components/schemas/AdminAclType"},
|
||||
"description": "List of access control permissions to assign"
|
||||
@@ -9999,6 +9990,17 @@
|
||||
"default": true,
|
||||
"description": "Whether to notify the reporter by system DM and email",
|
||||
"type": "boolean"
|
||||
},
|
||||
"resolution": {
|
||||
"description": "How the report was resolved",
|
||||
"x-enumNames": ["actioned", "no_violation", "duplicate"],
|
||||
"x-enumDescriptions": [
|
||||
"The report was valid and action was taken",
|
||||
"The report was reviewed and no violation was found",
|
||||
"The report repeats one that was already handled"
|
||||
],
|
||||
"enum": ["actioned", "no_violation", "duplicate"],
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": ["status"]
|
||||
@@ -10669,6 +10671,19 @@
|
||||
"additionalProperties": false
|
||||
},
|
||||
"self_hosted": {"type": "boolean"},
|
||||
"account_identity": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"mode": {
|
||||
"description": "Sign-in method in effect on this instance",
|
||||
"allOf": [{"$ref": "#/components/schemas/AccountIdentityModeSchema"}]
|
||||
},
|
||||
"locked": {"type": "boolean", "description": "Whether the sign-in method can no longer change"},
|
||||
"tag_style": {"allOf": [{"$ref": "#/components/schemas/TagStyleSchema"}]}
|
||||
},
|
||||
"required": ["mode", "locked", "tag_style"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"app_public": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -10976,6 +10991,7 @@
|
||||
"experiment_delivery",
|
||||
"registration",
|
||||
"self_hosted",
|
||||
"account_identity",
|
||||
"app_public",
|
||||
"policy",
|
||||
"integrations",
|
||||
@@ -12325,8 +12341,15 @@
|
||||
},
|
||||
"BanCheckResponseSchema": {
|
||||
"type": "object",
|
||||
"properties": {"banned": {"type": "boolean"}},
|
||||
"required": ["banned"],
|
||||
"properties": {
|
||||
"banned": {"type": "boolean"},
|
||||
"expires_at": {
|
||||
"nullable": true,
|
||||
"description": "ISO 8601 timestamp when the matching ban expires. Null when the ban is permanent, when nothing matches, and on every blocklist other than ip.",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": ["banned", "expires_at"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"AdminBlocklistBulkDeleteRequest": {
|
||||
@@ -12482,7 +12505,7 @@
|
||||
},
|
||||
"value_field": {
|
||||
"type": "string",
|
||||
"description": "The request body field that carries the entry value when adding to this blocklist"
|
||||
"description": "The request body field that holds the entry value when adding to this blocklist"
|
||||
},
|
||||
"fields": {
|
||||
"maxItems": 8,
|
||||
@@ -12729,7 +12752,7 @@
|
||||
},
|
||||
"acls": {
|
||||
"description": "Replacement list of access control permissions for the key",
|
||||
"maxItems": 104,
|
||||
"maxItems": 105,
|
||||
"type": "array",
|
||||
"items": {"$ref": "#/components/schemas/AdminAclType"}
|
||||
}
|
||||
@@ -12747,7 +12770,7 @@
|
||||
"type": "string"
|
||||
},
|
||||
"acls": {
|
||||
"maxItems": 104,
|
||||
"maxItems": 105,
|
||||
"type": "array",
|
||||
"items": {"type": "string"},
|
||||
"description": "List of access control permissions for the key"
|
||||
@@ -12777,7 +12800,7 @@
|
||||
"maximum": 365
|
||||
},
|
||||
"acls": {
|
||||
"maxItems": 104,
|
||||
"maxItems": 105,
|
||||
"type": "array",
|
||||
"items": {"$ref": "#/components/schemas/AdminAclType"},
|
||||
"description": "List of access control permissions for the key"
|
||||
@@ -12798,7 +12821,7 @@
|
||||
"type": "string"
|
||||
},
|
||||
"acls": {
|
||||
"maxItems": 104,
|
||||
"maxItems": 105,
|
||||
"type": "array",
|
||||
"items": {"type": "string"},
|
||||
"description": "List of access control permissions for the key"
|
||||
@@ -12811,7 +12834,7 @@
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"acls": {
|
||||
"maxItems": 104,
|
||||
"maxItems": 105,
|
||||
"type": "array",
|
||||
"items": {"type": "string", "minLength": 1, "maxLength": 64},
|
||||
"description": "Every admin access control permission the admin API recognises"
|
||||
@@ -12899,6 +12922,8 @@
|
||||
"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",
|
||||
@@ -12911,7 +12936,6 @@
|
||||
"user:view:email",
|
||||
"user:view:ip",
|
||||
"user:temp_ban",
|
||||
"user:update:bot_status",
|
||||
"user:update:dob",
|
||||
"user:update:email",
|
||||
"user:update:flags",
|
||||
@@ -13106,10 +13130,13 @@
|
||||
"BanUrlDomainRequest": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"domain": {"description": "Domain to ban (e.g. example.com)", "type": "string"},
|
||||
"domain": {
|
||||
"description": "Domain to ban (e.g. example.com), or a pattern whose leftmost label contains * under a registrable domain (e.g. *shop*.example.com). Internationalized names are stored in ASCII form.",
|
||||
"type": "string"
|
||||
},
|
||||
"match_subdomains": {
|
||||
"default": true,
|
||||
"description": "If true, any subdomain rooted at this domain is also banned",
|
||||
"description": "If true, any subdomain rooted at this domain, or at a host the pattern matches, is also banned",
|
||||
"type": "boolean"
|
||||
},
|
||||
"category": {"description": "Category / source slug (defaults to \"manual\")", "type": "string"},
|
||||
@@ -13162,7 +13189,15 @@
|
||||
},
|
||||
"BanIpRequest": {
|
||||
"type": "object",
|
||||
"properties": {"ip": {"description": "IPv4/IPv6 address or CIDR range to ban", "type": "string"}},
|
||||
"properties": {
|
||||
"ip": {"description": "IPv4/IPv6 address or CIDR range to ban", "type": "string"},
|
||||
"duration_hours": {
|
||||
"description": "Hours until the ban expires and its entry is removed. Omit it or use 0 for a permanent ban.",
|
||||
"type": "integer",
|
||||
"minimum": 0,
|
||||
"maximum": 8760
|
||||
}
|
||||
},
|
||||
"required": ["ip"]
|
||||
},
|
||||
"EmailBlocklistEntryType": {"type": "string"},
|
||||
@@ -13205,7 +13240,7 @@
|
||||
"properties": {
|
||||
"match_subdomains": {
|
||||
"default": true,
|
||||
"description": "If true, any subdomain rooted at this domain is also banned",
|
||||
"description": "If true, any subdomain rooted at this domain, or at a host the pattern matches, is also banned",
|
||||
"type": "boolean"
|
||||
},
|
||||
"category": {"description": "Category / source slug (defaults to \"manual\")", "type": "string"},
|
||||
@@ -13362,6 +13397,7 @@
|
||||
"description": "Bot requires manual approval for friend requests"
|
||||
},
|
||||
{"name": "SPAMMER", "value": "64", "description": "User is flagged as a spammer"},
|
||||
{"name": "PROFILE_HIDDEN", "value": "128", "description": "User profile details are hidden from other users"},
|
||||
{"name": "DELETED", "value": "17179869184", "description": "User account has been deleted"},
|
||||
{"name": "SELF_DELETED", "value": "68719476736", "description": "User account was self-deleted"},
|
||||
{"name": "DISABLED", "value": "274877906944", "description": "User account is disabled"},
|
||||
@@ -13542,7 +13578,7 @@
|
||||
"additionalProperties": false
|
||||
},
|
||||
"referenced_message": {
|
||||
"description": "The reply target. Present and populated when the target resolved, present and null when the target is gone, absent when this message carries no default reference. Clients must tell null apart from absent by key presence.",
|
||||
"description": "The reply target. Present and populated when the target resolved, present and null when the target is gone, absent when this message has no default reference. Clients must tell null apart from absent by key presence.",
|
||||
"nullable": true,
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -15346,6 +15382,23 @@
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"TagStyleSchema": {
|
||||
"description": "How usernames are tagged",
|
||||
"x-enumNames": ["NONE", "RANDOM"],
|
||||
"x-enumDescriptions": ["Usernames are unique and shown without a tag", "Every account gets a random tag"],
|
||||
"enum": ["none", "random"],
|
||||
"type": "string"
|
||||
},
|
||||
"AccountIdentityModeSchema": {
|
||||
"description": "How people identify themselves when they sign in",
|
||||
"x-enumNames": ["EMAIL", "USERNAME"],
|
||||
"x-enumDescriptions": [
|
||||
"People sign in with an email address",
|
||||
"People sign in with a username and no email is collected"
|
||||
],
|
||||
"enum": ["email", "username"],
|
||||
"type": "string"
|
||||
},
|
||||
"ExperimentDeliveryConfigResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -15629,7 +15682,7 @@
|
||||
"description": "ISO 8601 timestamp when the pending deletion was scheduled",
|
||||
"type": "string"
|
||||
},
|
||||
"acls": {"maxItems": 104, "type": "array", "items": {"type": "string"}},
|
||||
"acls": {"maxItems": 105, "type": "array", "items": {"type": "string"}},
|
||||
"traits": {"maxItems": 100, "type": "array", "items": {"type": "string"}},
|
||||
"has_totp": {"type": "boolean"},
|
||||
"authenticator_types": {"maxItems": 10, "type": "array", "items": {"$ref": "#/components/schemas/Int32Type"}},
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
@@ -34,6 +36,79 @@ pub struct InstanceConfigResponse {
|
||||
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)]
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -508,6 +511,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(),
|
||||
|
||||
@@ -838,7 +838,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 +1197,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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -31,6 +31,7 @@ pub const GUILD_TABS: &[(&str, &str)] = &[
|
||||
("reports", "Reports"),
|
||||
];
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn guild_detail_with_tab(
|
||||
config: &AdminConfig,
|
||||
auth: &AuthContext,
|
||||
@@ -39,9 +40,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 +66,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 +74,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 +101,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 +121,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"}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -12,7 +12,7 @@ pub mod reports;
|
||||
pub mod settings;
|
||||
pub mod stickers;
|
||||
|
||||
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 +21,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()
|
||||
|
||||
@@ -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)"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
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(
|
||||
@@ -500,10 +509,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 +600,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" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2032,6 +2099,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 =
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -441,6 +441,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",
|
||||
@@ -601,9 +602,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));
|
||||
@@ -859,6 +866,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 +1076,11 @@ 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);
|
||||
}
|
||||
|
||||
@@ -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:",
|
||||
|
||||
@@ -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('??');
|
||||
|
||||
@@ -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),
|
||||
@@ -347,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,
|
||||
@@ -368,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,
|
||||
@@ -385,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,
|
||||
|
||||
@@ -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,
|
||||
@@ -893,6 +895,11 @@ export const MfaBackupCodes = defineTable<MfaBackupCodeRow, 'user_id' | 'code'>(
|
||||
columns: MFA_BACKUP_CODE_COLUMNS,
|
||||
primaryKey: ['user_id', 'code'],
|
||||
});
|
||||
export const UserRecoveryKits = defineTable<UserRecoveryKitRow, 'user_id'>({
|
||||
name: 'user_recovery_kits',
|
||||
columns: USER_RECOVERY_KIT_COLUMNS,
|
||||
primaryKey: ['user_id'],
|
||||
});
|
||||
export const WebAuthnCredentials = defineTable<WebAuthnCredentialRow, 'user_id' | 'credential_id'>({
|
||||
name: 'webauthn_credentials',
|
||||
columns: WEBAUTHN_CREDENTIAL_COLUMNS,
|
||||
|
||||
@@ -96,6 +96,9 @@ type PreHook<E extends Env, P extends string, Target extends keyof ValidationTar
|
||||
c: Context<E, P, V>,
|
||||
target: Target,
|
||||
) => unknown | Promise<unknown>;
|
||||
type SchemaSelector<T extends ZodType, E extends Env, P extends string, V extends Input> = (
|
||||
c: Context<E, P, V>,
|
||||
) => ZodType<output<T>> | null | Promise<ZodType<output<T>> | null>;
|
||||
type ValidatorOptions<
|
||||
T extends ZodType,
|
||||
E extends Env,
|
||||
@@ -104,6 +107,7 @@ type ValidatorOptions<
|
||||
V extends Input,
|
||||
> = {
|
||||
pre?: PreHook<E, P, Target, V>;
|
||||
schemaFor?: SchemaSelector<T, E, P, V>;
|
||||
post?: Hook<T, E, P, Target, V>;
|
||||
};
|
||||
|
||||
@@ -211,8 +215,9 @@ export const Validator = <
|
||||
if (options.pre) {
|
||||
value = await options.pre(value, c, target);
|
||||
}
|
||||
const transformedValue = convertEmptyValuesToNull(value, schema);
|
||||
const result = await schema.safeParseAsync(transformedValue);
|
||||
const activeSchema = (await options.schemaFor?.(c)) ?? (schema as ZodType<output<T>>);
|
||||
const transformedValue = convertEmptyValuesToNull(value, activeSchema);
|
||||
const result = await activeSchema.safeParseAsync(transformedValue);
|
||||
if (options.post) {
|
||||
const hookResult = await options.post({...result, target}, c);
|
||||
if (hookResult) {
|
||||
|
||||
@@ -161,38 +161,43 @@ export class AdminRepository implements IAdminRepository {
|
||||
return false;
|
||||
}
|
||||
|
||||
async banIp(ip: string): Promise<void> {
|
||||
if (isIpBanExempt(ip)) {
|
||||
return;
|
||||
}
|
||||
const canonicalIp = canonicalizeBannedIpEntry(ip);
|
||||
await upsertOne(
|
||||
BannedIps.insert({
|
||||
ip: canonicalIp,
|
||||
ban_kind: 'permanent',
|
||||
reason: 'platform_admin_enforcement',
|
||||
expires_at: null,
|
||||
created_at: new Date(),
|
||||
}),
|
||||
);
|
||||
async banIp(ip: string, ttlSeconds: number | null = null): Promise<void> {
|
||||
await this.writeIpBan(ip, 'platform_admin_enforcement', ttlSeconds);
|
||||
}
|
||||
|
||||
async banIpTemp(ip: string, ttlSeconds: number): Promise<void> {
|
||||
if (!Number.isInteger(ttlSeconds) || ttlSeconds <= 0) {
|
||||
await this.writeIpBan(ip, 'abusive_api_access_patterns', ttlSeconds);
|
||||
}
|
||||
|
||||
private async writeIpBan(ip: string, reason: string, ttlSeconds: number | null): Promise<void> {
|
||||
if (ttlSeconds !== null && (!Number.isInteger(ttlSeconds) || ttlSeconds <= 0)) {
|
||||
throw new RangeError('Temporary IP ban TTL must be a positive integer');
|
||||
}
|
||||
if (isIpBanExempt(ip)) {
|
||||
return;
|
||||
}
|
||||
const canonicalIp = canonicalizeBannedIpEntry(ip);
|
||||
const createdAt = new Date();
|
||||
if (ttlSeconds === null) {
|
||||
await upsertOne(
|
||||
BannedIps.insert({
|
||||
ip: canonicalIp,
|
||||
ban_kind: 'permanent',
|
||||
reason,
|
||||
expires_at: null,
|
||||
created_at: createdAt,
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
await upsertOne(
|
||||
BannedIps.insertWithTtl(
|
||||
{
|
||||
ip: canonicalIp,
|
||||
ban_kind: 'temporary_24h',
|
||||
reason: 'abusive_api_access_patterns',
|
||||
expires_at: new Date(Date.now() + ttlSeconds * 1000),
|
||||
created_at: new Date(),
|
||||
reason,
|
||||
expires_at: new Date(createdAt.getTime() + ttlSeconds * 1000),
|
||||
created_at: createdAt,
|
||||
},
|
||||
ttlSeconds,
|
||||
),
|
||||
@@ -228,13 +233,16 @@ export class AdminRepository implements IAdminRepository {
|
||||
expires_at?: Date | null;
|
||||
created_at?: Date | null;
|
||||
}>(LOAD_ALL_BANNED_IPS_QUERY.bind({}));
|
||||
return rows.map((row) => ({
|
||||
ip: row.ip,
|
||||
kind: parseBannedIpKind(row.ban_kind),
|
||||
reason: row.reason ?? null,
|
||||
expiresAt: row.expires_at ?? null,
|
||||
createdAt: row.created_at ?? null,
|
||||
}));
|
||||
const now = Date.now();
|
||||
return rows
|
||||
.filter((row) => !row.expires_at || row.expires_at.getTime() > now)
|
||||
.map((row) => ({
|
||||
ip: row.ip,
|
||||
kind: parseBannedIpKind(row.ban_kind),
|
||||
reason: row.reason ?? null,
|
||||
expiresAt: row.expires_at ?? null,
|
||||
createdAt: row.created_at ?? null,
|
||||
}));
|
||||
}
|
||||
|
||||
async isEmailBanned(email: string): Promise<boolean> {
|
||||
|
||||
@@ -43,7 +43,7 @@ export abstract class IAdminRepository {
|
||||
|
||||
abstract isIpBanned(ip: string): Promise<boolean>;
|
||||
|
||||
abstract banIp(ip: string): Promise<void>;
|
||||
abstract banIp(ip: string, ttlSeconds?: number | null): Promise<void>;
|
||||
|
||||
abstract banIpTemp(ip: string, ttlSeconds: number): Promise<void>;
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ import {OpenAPI} from '@app/api/middleware/ResponseTypeMiddleware';
|
||||
import {RateLimitConfigs} from '@app/api/RateLimitConfig';
|
||||
import type {HonoApp} from '@app/api/types/HonoEnv';
|
||||
import {Validator} from '@app/api/Validator';
|
||||
import {AdminACLs} from '@fluxer/constants/src/AdminACLs';
|
||||
import {AdminACLs, filterKnownAdminACLs} from '@fluxer/constants/src/AdminACLs';
|
||||
import {
|
||||
AdminApiKeyListResponse,
|
||||
CreateAdminApiKeyRequest,
|
||||
@@ -30,7 +30,7 @@ function toApiKeyResponse(key: AdminApiKeyView): ListAdminApiKeyResponseType {
|
||||
last_used_at: key.lastUsedAt?.toISOString() ?? null,
|
||||
expires_at: key.expiresAt?.toISOString() ?? null,
|
||||
created_by_user_id: String(key.createdById),
|
||||
acls: Array.from(key.acls),
|
||||
acls: filterKnownAdminACLs(key.acls),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -62,7 +62,7 @@ export function AdminApiKeyAdminController(app: HonoApp) {
|
||||
name: result.apiKey.name,
|
||||
created_at: result.apiKey.createdAt.toISOString(),
|
||||
expires_at: result.apiKey.expiresAt?.toISOString() ?? null,
|
||||
acls: Array.from(result.apiKey.acls),
|
||||
acls: filterKnownAdminACLs(result.apiKey.acls),
|
||||
};
|
||||
await recordAdminWrite(ctx, {
|
||||
targetType: 'admin_api_key',
|
||||
@@ -146,7 +146,7 @@ export function AdminApiKeyAdminController(app: HonoApp) {
|
||||
security: ['adminApiKey'],
|
||||
tags: ['Admin'],
|
||||
description:
|
||||
'Renames an API key or replaces the access control lists (ACLs) it carries. The key may only carry permissions the acting admin already holds. Omitted fields are left unchanged and the key material is never rotated or returned.',
|
||||
'Renames an API key or replaces the access control lists (ACLs) it has. The key may only hold permissions the acting admin already holds. Omitted fields are left unchanged and the key material is never rotated or returned.',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const adminApiKeyService = ctx.get('adminApiKeyService');
|
||||
|
||||
@@ -57,9 +57,9 @@ const BLOCKLIST_CATALOG = [
|
||||
{
|
||||
list_type: 'ip' as const,
|
||||
description:
|
||||
'IPv4/IPv6 addresses and CIDR ranges denied service. Applies to live connections and can be applied retroactively.',
|
||||
'IPv4/IPv6 addresses and CIDR ranges denied service. Applies to live connections and can be applied retroactively. An entry can carry an expiry, after which it stops applying and is removed.',
|
||||
value_field: 'ip',
|
||||
fields: [],
|
||||
fields: ['duration_hours'],
|
||||
scoped: false,
|
||||
supports_bulk_create: false,
|
||||
supports_bulk_delete: false,
|
||||
@@ -99,7 +99,8 @@ const BLOCKLIST_CATALOG = [
|
||||
},
|
||||
{
|
||||
list_type: 'url-domain' as const,
|
||||
description: 'Domains blocked from being linked, optionally covering every subdomain rooted at the domain.',
|
||||
description:
|
||||
'Domains blocked from being linked, optionally covering every subdomain rooted at the domain. A value whose leftmost label contains * is a pattern that matches that one label under a registrable domain.',
|
||||
value_field: 'domain',
|
||||
fields: ['match_subdomains', 'category', 'severity', 'source_url', 'notes'],
|
||||
scoped: false,
|
||||
@@ -232,7 +233,7 @@ async function checkBlocklistEntry(
|
||||
listType: AdminBlocklistListType,
|
||||
entryValue: string,
|
||||
scope: ProfileSubstringScope | undefined,
|
||||
): Promise<{banned: boolean}> {
|
||||
): Promise<{banned: boolean; expires_at?: string | null}> {
|
||||
switch (listType) {
|
||||
case 'ip':
|
||||
return bans.checkIpBan({ip: entryValue});
|
||||
@@ -269,7 +270,7 @@ export function BanAdminController(app: HonoApp) {
|
||||
security: ['adminApiKey'],
|
||||
tags: ['Admin'],
|
||||
description:
|
||||
'List every blocklist this instance maintains, the request field that carries an entry value, the extra fields its entries accept, and which of the bulk and update operations it supports.',
|
||||
'List every blocklist this instance maintains, the request field that holds an entry value, the extra fields its entries accept, and which of the bulk and update operations it supports.',
|
||||
}),
|
||||
async (ctx) => {
|
||||
await recordAdminRead(ctx, {
|
||||
@@ -488,7 +489,7 @@ export function BanAdminController(app: HonoApp) {
|
||||
security: ['adminApiKey'],
|
||||
tags: ['Admin'],
|
||||
description:
|
||||
'Report whether a value is currently blocked by a blocklist. The value is percent-encoded in the path. An IP address can still match a broader stored CIDR entry, and a URL can match a banned domain. The profile-substring blocklist requires a scope.',
|
||||
'Report whether a value is currently blocked by a blocklist. The value is percent-encoded in the path. An IP address can still match a broader stored CIDR entry, and a url-domain value can be a hostname or an http(s) URL that a stored domain or pattern covers. The profile-substring blocklist requires a scope.',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const adminService = ctx.get('adminService');
|
||||
@@ -507,7 +508,7 @@ export function BanAdminController(app: HonoApp) {
|
||||
banned: result.banned,
|
||||
},
|
||||
});
|
||||
return ctx.json(result);
|
||||
return ctx.json({banned: result.banned, expires_at: result.expires_at ?? null});
|
||||
},
|
||||
);
|
||||
app.patch(
|
||||
@@ -524,7 +525,7 @@ export function BanAdminController(app: HonoApp) {
|
||||
tags: ['Admin'],
|
||||
requestSchema: AdminBlocklistEntryUpdateRequest,
|
||||
description:
|
||||
'Rewrite the stored fields of a blocklist entry without removing and re-adding it. The stored metadata is replaced by the supplied fields, so fields left out fall back to their defaults. Only blocklists whose entries carry fields accept this operation, reported as supports_update by GET /admin/blocklists.',
|
||||
'Rewrite the stored fields of a blocklist entry without removing and re-adding it. The stored metadata is replaced by the supplied fields, so fields left out fall back to their defaults. Only blocklists whose entries have fields accept this operation, reported as supports_update by GET /admin/blocklists.',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const adminService = ctx.get('adminService');
|
||||
|
||||
@@ -85,14 +85,17 @@ async function buildInstanceConfigResponse(): Promise<InstanceConfigResponse> {
|
||||
instanceConfigRepository.getRegistrationUrlsForAdmin(),
|
||||
instanceConfigRepository.getPendingRegistrations(),
|
||||
]);
|
||||
const [appPublic, policy, resolvedServices, integrations, media, billing] = await Promise.all([
|
||||
instanceConfigRepository.getAppPublicConfig(),
|
||||
instanceConfigRepository.getInstancePolicyConfig(),
|
||||
instanceConfigRepository.getResolvedServicesConfig(),
|
||||
instanceConfigRepository.getInstanceIntegrationsAdminConfig(),
|
||||
instanceConfigRepository.getInstanceMediaAdminConfig(),
|
||||
instanceConfigRepository.getInstanceBillingAdminConfig(),
|
||||
]);
|
||||
const [appPublic, policy, resolvedServices, integrations, media, billing, accountIdentity, accountIdentityLocked] =
|
||||
await Promise.all([
|
||||
instanceConfigRepository.getAppPublicConfig(),
|
||||
instanceConfigRepository.getInstancePolicyConfig(),
|
||||
instanceConfigRepository.getResolvedServicesConfig(),
|
||||
instanceConfigRepository.getInstanceIntegrationsAdminConfig(),
|
||||
instanceConfigRepository.getInstanceMediaAdminConfig(),
|
||||
instanceConfigRepository.getInstanceBillingAdminConfig(),
|
||||
instanceConfigRepository.getAccountIdentity(),
|
||||
instanceConfigRepository.isAccountIdentityLocked(),
|
||||
]);
|
||||
return {
|
||||
sso: {
|
||||
enabled: ssoConfig.enabled,
|
||||
@@ -122,6 +125,11 @@ async function buildInstanceConfigResponse(): Promise<InstanceConfigResponse> {
|
||||
pending_registrations: pendingRegistrations,
|
||||
},
|
||||
self_hosted: Config.instance.selfHosted,
|
||||
account_identity: {
|
||||
mode: accountIdentity.mode,
|
||||
locked: accountIdentityLocked,
|
||||
tag_style: accountIdentity.tagStyle,
|
||||
},
|
||||
app_public: appPublic,
|
||||
policy: {
|
||||
single_community_enabled: policy.single_community_enabled,
|
||||
|
||||
@@ -49,7 +49,7 @@ export function MessageAdminController(app: HonoApp) {
|
||||
operationId: 'search_admin_messages',
|
||||
summary: 'Search messages',
|
||||
description:
|
||||
'Searches the messages of a channel by content, or resolves a single message by its ID or by one of its attachments. Passing message_id returns that message with the messages surrounding it; passing attachment_id together with filename returns the message carrying that attachment with its surrounding context. Requires MESSAGE_LOOKUP permission.',
|
||||
'Searches the messages of a channel by content, or resolves a single message by its ID or by one of its attachments. Passing message_id returns that message with the messages surrounding it; passing attachment_id together with filename returns the message with that attachment with its surrounding context. Requires MESSAGE_LOOKUP permission.',
|
||||
responseSchema: AdminMessageSearchResponse,
|
||||
statusCode: 200,
|
||||
security: 'adminApiKey',
|
||||
|
||||
@@ -178,7 +178,7 @@ export function ReportAdminController(app: HonoApp) {
|
||||
const adminUserId = ctx.get('adminUserId');
|
||||
const auditLogReason = ctx.get('auditLogReason');
|
||||
const {report_id} = ctx.req.valid('param');
|
||||
const {public_comment, notify_reporter} = ctx.req.valid('json');
|
||||
const {public_comment, notify_reporter, resolution} = ctx.req.valid('json');
|
||||
return ctx.json(
|
||||
await adminService.reportServiceAggregate.resolveReport(
|
||||
createReportID(report_id),
|
||||
@@ -186,6 +186,7 @@ export function ReportAdminController(app: HonoApp) {
|
||||
public_comment || null,
|
||||
auditLogReason,
|
||||
notify_reporter,
|
||||
resolution,
|
||||
),
|
||||
);
|
||||
},
|
||||
|
||||
@@ -4,6 +4,10 @@ import {AdminAuditReadActions} from '@app/api/admin/AdminAuditActions';
|
||||
import {recordAdminRead} from '@app/api/admin/AdminAuditRecorder';
|
||||
import {mapUserToAdminResponse} from '@app/api/admin/models/UserTypes';
|
||||
import {createUserID} from '@app/api/BrandedTypes';
|
||||
import {
|
||||
RequireEmailAccountIdentity,
|
||||
RequireUsernameAccountIdentity,
|
||||
} from '@app/api/middleware/AccountIdentityMiddleware';
|
||||
import {requireAdminACL} from '@app/api/middleware/AdminMiddleware';
|
||||
import {RateLimitMiddleware} from '@app/api/middleware/RateLimitMiddleware';
|
||||
import {OpenAPI} from '@app/api/middleware/ResponseTypeMiddleware';
|
||||
@@ -16,10 +20,10 @@ import {ListUserGuildsResponse} from '@fluxer/schema/src/domains/admin/AdminGuil
|
||||
import {SearchUsersResponse} from '@fluxer/schema/src/domains/admin/AdminSchemas';
|
||||
import {
|
||||
AdminAclListResponse,
|
||||
AdminPasswordResetLinkResponse,
|
||||
AdminUserAclsRequest,
|
||||
AdminUserBanNoteRequest,
|
||||
AdminUserBanRequest,
|
||||
AdminUserBotStatusRequest,
|
||||
AdminUserChangeLogQuery,
|
||||
AdminUserClearFieldsRequest,
|
||||
AdminUserDeletionCancelRequest,
|
||||
@@ -34,7 +38,6 @@ import {
|
||||
AdminUserPremiumFlagsUpdateRequest,
|
||||
AdminUserRelationshipCategoryQuery,
|
||||
AdminUserRelationshipParam,
|
||||
AdminUserSystemStatusRequest,
|
||||
AdminUsersMeResponse,
|
||||
AdminUserTraitsRequest,
|
||||
AdminUserUnbanRequest,
|
||||
@@ -598,70 +601,6 @@ export function UserAdminController(app: HonoApp) {
|
||||
);
|
||||
},
|
||||
);
|
||||
app.put(
|
||||
'/admin/users/:user_id/bot-status',
|
||||
RateLimitMiddleware(RateLimitConfigs.ADMIN_USER_MODIFY),
|
||||
requireAdminACL(AdminACLs.USER_UPDATE_BOT_STATUS),
|
||||
Validator('param', UserIdParam),
|
||||
Validator('json', AdminUserBotStatusRequest),
|
||||
OpenAPI({
|
||||
operationId: 'set_admin_user_bot_status',
|
||||
summary: 'Set user bot status',
|
||||
responseSchema: UserMutationResponse,
|
||||
statusCode: 200,
|
||||
security: 'adminApiKey',
|
||||
tags: 'Admin',
|
||||
description:
|
||||
'Mark or unmark a user account as a bot. Controls bot badge visibility and API permissions. Creates audit log entry. Requires USER_UPDATE_BOT_STATUS permission.',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const adminService = ctx.get('adminService');
|
||||
const adminUserId = ctx.get('adminUserId');
|
||||
const auditLogReason = ctx.get('auditLogReason');
|
||||
const adminUserAcls = ctx.get('adminUserAcls');
|
||||
const {user_id: userId} = ctx.req.valid('param');
|
||||
return ctx.json(
|
||||
await adminService.userService.profileService.setUserBotStatus(
|
||||
{user_id: userId, ...ctx.req.valid('json')},
|
||||
adminUserId,
|
||||
auditLogReason,
|
||||
adminUserAcls,
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
app.put(
|
||||
'/admin/users/:user_id/system-status',
|
||||
RateLimitMiddleware(RateLimitConfigs.ADMIN_USER_MODIFY),
|
||||
requireAdminACL(AdminACLs.USER_UPDATE_BOT_STATUS),
|
||||
Validator('param', UserIdParam),
|
||||
Validator('json', AdminUserSystemStatusRequest),
|
||||
OpenAPI({
|
||||
operationId: 'set_admin_user_system_status',
|
||||
summary: 'Set user system status',
|
||||
responseSchema: UserMutationResponse,
|
||||
statusCode: 200,
|
||||
security: 'adminApiKey',
|
||||
tags: 'Admin',
|
||||
description:
|
||||
'Mark or unmark a user as a system account. System accounts have special permissions for automated operations. Creates audit log entry. Requires USER_UPDATE_BOT_STATUS permission.',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const adminService = ctx.get('adminService');
|
||||
const adminUserId = ctx.get('adminUserId');
|
||||
const auditLogReason = ctx.get('auditLogReason');
|
||||
const adminUserAcls = ctx.get('adminUserAcls');
|
||||
const {user_id: userId} = ctx.req.valid('param');
|
||||
return ctx.json(
|
||||
await adminService.userService.profileService.setUserSystemStatus(
|
||||
{user_id: userId, ...ctx.req.valid('json')},
|
||||
adminUserId,
|
||||
auditLogReason,
|
||||
adminUserAcls,
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
app.patch(
|
||||
'/admin/users/:user_id/username',
|
||||
RateLimitMiddleware(RateLimitConfigs.ADMIN_USER_MODIFY),
|
||||
@@ -698,6 +637,7 @@ export function UserAdminController(app: HonoApp) {
|
||||
'/admin/users/:user_id/email',
|
||||
RateLimitMiddleware(RateLimitConfigs.ADMIN_USER_MODIFY),
|
||||
requireAdminACL(AdminACLs.USER_UPDATE_EMAIL),
|
||||
RequireEmailAccountIdentity,
|
||||
Validator('param', UserIdParam),
|
||||
Validator('json', AdminUserEmailUpdateRequest),
|
||||
OpenAPI({
|
||||
@@ -730,6 +670,7 @@ export function UserAdminController(app: HonoApp) {
|
||||
'/admin/users/:user_id/email-verification',
|
||||
RateLimitMiddleware(RateLimitConfigs.ADMIN_USER_MODIFY),
|
||||
requireAdminACL(AdminACLs.USER_UPDATE_EMAIL),
|
||||
RequireEmailAccountIdentity,
|
||||
Validator('param', UserIdParam),
|
||||
OpenAPI({
|
||||
operationId: 'verify_admin_user_email',
|
||||
@@ -761,6 +702,7 @@ export function UserAdminController(app: HonoApp) {
|
||||
'/admin/users/:user_id/verification-email',
|
||||
RateLimitMiddleware(RateLimitConfigs.ADMIN_USER_MODIFY),
|
||||
requireAdminACL(AdminACLs.USER_UPDATE_EMAIL),
|
||||
RequireEmailAccountIdentity,
|
||||
Validator('param', UserIdParam),
|
||||
OpenAPI({
|
||||
operationId: 'resend_admin_user_verification_email',
|
||||
@@ -789,6 +731,7 @@ export function UserAdminController(app: HonoApp) {
|
||||
'/admin/users/:user_id/password-reset',
|
||||
RateLimitMiddleware(RateLimitConfigs.ADMIN_USER_MODIFY),
|
||||
requireAdminACL(AdminACLs.USER_UPDATE_EMAIL),
|
||||
RequireEmailAccountIdentity,
|
||||
Validator('param', UserIdParam),
|
||||
OpenAPI({
|
||||
operationId: 'send_admin_user_password_reset',
|
||||
@@ -809,6 +752,65 @@ export function UserAdminController(app: HonoApp) {
|
||||
return ctx.body(null, 204);
|
||||
},
|
||||
);
|
||||
app.post(
|
||||
'/admin/users/:user_id/password-reset-link',
|
||||
RateLimitMiddleware(RateLimitConfigs.ADMIN_USER_MODIFY),
|
||||
requireAdminACL(AdminACLs.USER_CREATE_PASSWORD_RESET_LINK),
|
||||
RequireUsernameAccountIdentity,
|
||||
Validator('param', UserIdParam),
|
||||
OpenAPI({
|
||||
operationId: 'create_admin_user_password_reset_link',
|
||||
summary: 'Create user password reset link',
|
||||
responseSchema: AdminPasswordResetLinkResponse,
|
||||
statusCode: 200,
|
||||
security: 'adminApiKey',
|
||||
tags: 'Admin',
|
||||
description:
|
||||
'Create a one-time password reset link on an instance where people sign in with a username. Hand the link to the user yourself. It works once and expires after an hour. Deletes the recovery kit of the account. Creates audit log entry. Requires USER_CREATE_PASSWORD_RESET_LINK permission and every ACL the target account holds. Fails with USERNAME_SIGN_IN_ONLY on email instances.',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const adminService = ctx.get('adminService');
|
||||
const adminUserId = ctx.get('adminUserId');
|
||||
const auditLogReason = ctx.get('auditLogReason');
|
||||
const {user_id: userId} = ctx.req.valid('param');
|
||||
return ctx.json(
|
||||
await adminService.userService.securityService.createPasswordResetLink(
|
||||
{user_id: userId},
|
||||
adminUserId,
|
||||
auditLogReason,
|
||||
ctx.get('adminUserAcls'),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
app.delete(
|
||||
'/admin/users/:user_id/recovery-kit',
|
||||
RateLimitMiddleware(RateLimitConfigs.ADMIN_USER_MODIFY),
|
||||
requireAdminACL(AdminACLs.USER_DELETE_RECOVERY_KIT),
|
||||
RequireUsernameAccountIdentity,
|
||||
Validator('param', UserIdParam),
|
||||
OpenAPI({
|
||||
operationId: 'revoke_admin_user_recovery_kit',
|
||||
summary: 'Revoke user recovery kit',
|
||||
responseSchema: null,
|
||||
statusCode: 204,
|
||||
security: 'adminApiKey',
|
||||
tags: 'Admin',
|
||||
description:
|
||||
'Deletes the recovery kit of an account on an instance where people sign in with a username, so its key stops working. Creates audit log entry. Requires USER_DELETE_RECOVERY_KIT permission and every ACL the target account holds. Fails with USERNAME_SIGN_IN_ONLY on email instances.',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const adminService = ctx.get('adminService');
|
||||
const {user_id: userId} = ctx.req.valid('param');
|
||||
await adminService.userService.securityService.revokeRecoveryKit(
|
||||
{user_id: userId},
|
||||
ctx.get('adminUserId'),
|
||||
ctx.get('auditLogReason'),
|
||||
ctx.get('adminUserAcls'),
|
||||
);
|
||||
return ctx.body(null, 204);
|
||||
},
|
||||
);
|
||||
app.put(
|
||||
'/admin/users/:user_id/ban',
|
||||
RateLimitMiddleware(RateLimitConfigs.ADMIN_USER_MODIFY),
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import type {User} from '@app/api/models/User';
|
||||
import {getIpAddressReverse, lookupGeoip} from '@app/api/utils/IpUtils';
|
||||
import {AdminACLs} from '@fluxer/constants/src/AdminACLs';
|
||||
import {AdminACLs, filterKnownAdminACLs} from '@fluxer/constants/src/AdminACLs';
|
||||
import type {UserAdminResponse} from '@fluxer/schema/src/domains/admin/AdminUserSchemas';
|
||||
import type {ICacheService} from '@pkgs/cache/src/ICacheService';
|
||||
import {formatGeoipLocation} from '@pkgs/geoip/src/GeoipLookup';
|
||||
@@ -65,7 +65,7 @@ export async function mapUserToAdminResponse(
|
||||
deletion_audit_log_reason: canViewAuditLog ? user.deletionAuditLogReason : null,
|
||||
deletion_scheduled_by: user.deletionScheduledBy?.toString() ?? null,
|
||||
deletion_scheduled_at: user.deletionScheduledAt?.toISOString() ?? null,
|
||||
acls: user.acls ? Array.from(user.acls) : [],
|
||||
acls: user.acls ? filterKnownAdminACLs(user.acls) : [],
|
||||
traits: Array.from(user.traits).sort(),
|
||||
has_totp: user.totpSecret !== null,
|
||||
authenticator_types: user.authenticatorTypes ? Array.from(user.authenticatorTypes) : [],
|
||||
|
||||
@@ -24,6 +24,7 @@ import {phraseBlocklistCache} from '@app/api/middleware/PhraseBlocklistCache';
|
||||
import {profileSubstringBlocklistCache} from '@app/api/middleware/ProfileSubstringBlocklistCache';
|
||||
import {urlBlocklistCache} from '@app/api/middleware/UrlBlocklistCache';
|
||||
import {canonicalizeStoredPhrase} from '@app/api/utils/PhraseBlocklistNormalization';
|
||||
import {parseUrlDomainEntry} from '@app/api/utils/UrlHostRules';
|
||||
import {canonicalizeUrl} from '@app/api/utils/UrlNormalizer';
|
||||
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
|
||||
import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes';
|
||||
@@ -108,6 +109,16 @@ function normalizeAvatarHashes(hashes: Array<string>): Array<string> {
|
||||
return Array.from(new Set(hashes.map((hash) => stripAvatarAnimationPrefix(hash.toLowerCase()))));
|
||||
}
|
||||
|
||||
function hostFromUrlOrHostname(value: string): string | null {
|
||||
const trimmed = value.trim();
|
||||
if (!/^https?:\/\//i.test(trimmed)) return trimmed;
|
||||
try {
|
||||
return new URL(trimmed).hostname;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function withReasonMetadata(entries: Array<[string, string]>, reason: string | undefined): Map<string, string> {
|
||||
if (!reason) {
|
||||
return new Map(entries);
|
||||
@@ -122,6 +133,7 @@ export class AdminBanManagementService {
|
||||
async banIp(
|
||||
data: {
|
||||
ip: string;
|
||||
duration_hours?: number;
|
||||
},
|
||||
adminUserId: UserID,
|
||||
auditLogReason: string | null,
|
||||
@@ -142,15 +154,24 @@ export class AdminBanManagementService {
|
||||
message: 'This IP address is on the instance exemption list',
|
||||
});
|
||||
}
|
||||
await adminRepository.banIp(data.ip);
|
||||
ipBanCache.ban(data.ip);
|
||||
const durationHours = data.duration_hours ?? 0;
|
||||
const metadata = new Map([['ip', data.ip]]);
|
||||
if (durationHours > 0) {
|
||||
const ttlSeconds = durationHours * 3600;
|
||||
await adminRepository.banIp(data.ip, ttlSeconds);
|
||||
metadata.set('duration_hours', durationHours.toString());
|
||||
metadata.set('expires_at', new Date(Date.now() + ttlSeconds * 1000).toISOString());
|
||||
} else {
|
||||
await adminRepository.banIp(data.ip);
|
||||
}
|
||||
await ipBanCache.refresh();
|
||||
await cacheService.publish(IP_BAN_REFRESH_CHANNEL, 'refresh');
|
||||
await this.createBlocklistAuditLog({
|
||||
adminUserId,
|
||||
targetType: 'ip',
|
||||
action: 'ban_ip',
|
||||
auditLogReason,
|
||||
metadata: new Map([['ip', data.ip]]),
|
||||
metadata,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -177,9 +198,10 @@ export class AdminBanManagementService {
|
||||
|
||||
async checkIpBan(data: {ip: string}): Promise<{
|
||||
banned: boolean;
|
||||
expires_at: string | null;
|
||||
}> {
|
||||
const banned = ipBanCache.isBanned(data.ip);
|
||||
return {banned};
|
||||
const match = ipBanCache.getMatch(data.ip);
|
||||
return {banned: match !== null, expires_at: toIsoString(match?.expiresAt)};
|
||||
}
|
||||
|
||||
async banEmail(
|
||||
@@ -355,7 +377,9 @@ export class AdminBanManagementService {
|
||||
) {
|
||||
const {adminRepository} = this.deps;
|
||||
const {cache: cacheService} = this.deps.apiContext.services;
|
||||
const d = data.domain.toLowerCase();
|
||||
const entry = parseUrlDomainEntry(data.domain);
|
||||
if (!entry.ok) throw InputValidationError.create('domain', entry.message);
|
||||
const d = entry.value;
|
||||
const matchSubs = data.match_subdomains ?? true;
|
||||
await adminRepository.banUrlDomain({
|
||||
domain: d,
|
||||
@@ -367,7 +391,7 @@ export class AdminBanManagementService {
|
||||
added_by: adminUserId,
|
||||
notes: data.notes ?? null,
|
||||
});
|
||||
urlBlocklistCache.addDomain(d);
|
||||
urlBlocklistCache.addDomain(d, matchSubs);
|
||||
await cacheService.publish(BANNED_URL_DOMAINS_REFRESH_CHANNEL, 'refresh');
|
||||
await this.createBlocklistAuditLog({
|
||||
adminUserId,
|
||||
@@ -377,6 +401,7 @@ export class AdminBanManagementService {
|
||||
metadata: new Map([
|
||||
['domain', d],
|
||||
['match_subdomains', String(matchSubs)],
|
||||
['pattern', String(entry.pattern)],
|
||||
]),
|
||||
});
|
||||
}
|
||||
@@ -390,7 +415,8 @@ export class AdminBanManagementService {
|
||||
) {
|
||||
const {adminRepository} = this.deps;
|
||||
const {cache: cacheService} = this.deps.apiContext.services;
|
||||
const d = data.domain.toLowerCase();
|
||||
const entry = parseUrlDomainEntry(data.domain);
|
||||
const d = entry.ok ? entry.value : data.domain.trim().toLowerCase();
|
||||
await adminRepository.unbanUrlDomain(d);
|
||||
urlBlocklistCache.removeDomain(d);
|
||||
await cacheService.publish(BANNED_URL_DOMAINS_REFRESH_CHANNEL, 'refresh');
|
||||
@@ -406,7 +432,8 @@ export class AdminBanManagementService {
|
||||
async checkUrlDomainBan(data: {domain: string}): Promise<{
|
||||
banned: boolean;
|
||||
}> {
|
||||
return {banned: urlBlocklistCache.isHostnameBanned(data.domain)};
|
||||
const host = hostFromUrlOrHostname(data.domain);
|
||||
return {banned: host != null && urlBlocklistCache.isHostnameBanned(host)};
|
||||
}
|
||||
|
||||
async banFileSha(
|
||||
|
||||
@@ -261,7 +261,8 @@ export class AdminMessageService {
|
||||
|
||||
private async getMessageResponseAccessForAdmin(channelId: ChannelID): Promise<MessageResponseAccessContext> {
|
||||
const channel = await this.deps.channelRepository.findUnique(channelId);
|
||||
return channel ? messageResponseAccessForChannel(channel) : messageResponseAccessForGuild(null);
|
||||
const access = channel ? messageResponseAccessForChannel(channel) : messageResponseAccessForGuild(null);
|
||||
return {...access, includeHidden: true};
|
||||
}
|
||||
|
||||
private async listMessageResponsesForAdmin(params: {
|
||||
|
||||
@@ -34,11 +34,13 @@ import type {User} from '@app/api/models/User';
|
||||
import type {IARMessageContext, IARSubmission} from '@app/api/report/IReportRepository';
|
||||
import type {ReportService} from '@app/api/report/ReportService';
|
||||
import {getReportSearchService} from '@app/api/SearchFactory';
|
||||
import {isHiddenPartial} from '@app/api/user/ProfileVisibility';
|
||||
import type {UserChannelService} from '@app/api/user/services/UserChannelService';
|
||||
import {formatUserTag} from '@app/api/user/UserTag';
|
||||
import {assertSafeByteSize} from '@app/api/utils/ByteSizeUtils';
|
||||
import {AdminACLs} from '@fluxer/constants/src/AdminACLs';
|
||||
import {FeatureTemporarilyDisabledError} from '@fluxer/errors/src/domains/core/FeatureTemporarilyDisabledError';
|
||||
import type {SearchReportsRequest} from '@fluxer/schema/src/domains/admin/AdminSchemas';
|
||||
import type {SearchReportsRequest, UpdateReportRequest} from '@fluxer/schema/src/domains/admin/AdminSchemas';
|
||||
import type {MessageResponse} from '@fluxer/schema/src/domains/message/MessageResponseSchemas';
|
||||
import {getEmailTemplate} from '@pkgs/email/src/email_i18n/EmailI18n';
|
||||
import {seconds} from 'itty-time';
|
||||
@@ -56,6 +58,8 @@ interface AdminReportServiceDeps {
|
||||
ncmecSubmissionService: NcmecSubmissionService;
|
||||
}
|
||||
|
||||
type StaffReportResolution = NonNullable<UpdateReportRequest['resolution']>;
|
||||
|
||||
interface ReportNsfwLookupCache {
|
||||
channelNsfwByChannelId: Map<string, boolean | null>;
|
||||
guildNsfwLevelByGuildId: Map<string, number | null>;
|
||||
@@ -105,10 +109,14 @@ export class AdminReportService {
|
||||
publicComment: string | null,
|
||||
auditLogReason: string | null,
|
||||
notifyReporter: boolean,
|
||||
resolution?: StaffReportResolution,
|
||||
) {
|
||||
const {reportService, auditService} = this.deps;
|
||||
const {users: userRepository, email: emailService} = this.deps.apiContext.services;
|
||||
const resolvedReport = await reportService.resolveReport(reportId, adminUserId, publicComment, auditLogReason);
|
||||
const resolvedReport = await reportService.resolveReport(reportId, adminUserId, publicComment, auditLogReason, {
|
||||
outcome: resolution,
|
||||
resolvedBy: 'staff',
|
||||
});
|
||||
let reporterDmSent = false;
|
||||
let reporterEmailSent = false;
|
||||
const reporter =
|
||||
@@ -147,6 +155,7 @@ export class AdminReportService {
|
||||
['notify_reporter', notifyReporter ? 'true' : 'false'],
|
||||
['reporter_dm_sent', reporterDmSent ? 'true' : 'false'],
|
||||
['reporter_email_sent', reporterEmailSent ? 'true' : 'false'],
|
||||
...(resolution ? [['resolution', resolution] as [string, string]] : []),
|
||||
]),
|
||||
});
|
||||
return {
|
||||
@@ -418,7 +427,8 @@ export class AdminReportService {
|
||||
|
||||
private async getMessageResponseAccessForAdmin(channelId: ChannelID): Promise<MessageResponseAccessContext> {
|
||||
const channel = await this.deps.channelRepository.findUnique(channelId);
|
||||
return channel ? messageResponseAccessForChannel(channel) : messageResponseAccessForGuild(null);
|
||||
const access = channel ? messageResponseAccessForChannel(channel) : messageResponseAccessForGuild(null);
|
||||
return {...access, includeHidden: true};
|
||||
}
|
||||
|
||||
private async getMutualDmChannelId(report: IARSubmission): Promise<string | null> {
|
||||
@@ -617,10 +627,23 @@ export class AdminReportService {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const user = await this.deps.userCacheService.getUserPartialResponse(userId, requestCache);
|
||||
const cached = await this.deps.userCacheService.getUserPartialResponse(userId, requestCache);
|
||||
const stored = isHiddenPartial(cached) ? await this.deps.apiContext.services.users.findUnique(userId) : null;
|
||||
const user = stored
|
||||
? {
|
||||
username: stored.username,
|
||||
global_name: stored.globalName,
|
||||
discriminator: stored.discriminator.toString(),
|
||||
bot: stored.isBot,
|
||||
}
|
||||
: cached;
|
||||
const discriminator = user.discriminator?.padStart(4, '0') ?? '0000';
|
||||
return {
|
||||
tag: `${user.username}#${discriminator}`,
|
||||
tag: formatUserTag({
|
||||
username: user.username,
|
||||
discriminator: Number.parseInt(discriminator, 10),
|
||||
isBot: user.bot ?? false,
|
||||
}),
|
||||
username: user.username,
|
||||
global_name: user.global_name ?? null,
|
||||
discriminator,
|
||||
|
||||
@@ -9,6 +9,7 @@ import type {AdminUserUpdatePropagator} from '@app/api/admin/services/AdminUserU
|
||||
import * as AuthSession from '@app/api/auth/AuthSession';
|
||||
import {createReportID, createUserID, type UserID} from '@app/api/BrandedTypes';
|
||||
import type {BillingRepository} from '@app/api/billing/repositories/BillingRepository';
|
||||
import type {NcmecRepository} from '@app/api/csam/NcmecRepository';
|
||||
import {emitAdminAction} from '@app/api/infrastructure/activity/AccountChangeEvents';
|
||||
import type {KVAccountDeletionQueueService} from '@app/api/infrastructure/KVAccountDeletionQueueService';
|
||||
import {Logger} from '@app/api/Logger';
|
||||
@@ -19,6 +20,7 @@ import type {ReportService} from '@app/api/report/ReportService';
|
||||
import {getReportSearchService} from '@app/api/SearchFactory';
|
||||
import type {StoreEntitlementService} from '@app/api/store_billing/StoreEntitlementService';
|
||||
import {clearNewConversationLimit} from '@app/api/user/NewConversationLimit';
|
||||
import {isEnforcementDeletionReason} from '@app/api/user/ProfileVisibility';
|
||||
import {clearPendingDeletion, reschedulePendingDeletion} from '@app/api/user/services/PendingDeletionCoordinator';
|
||||
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
|
||||
import {DeletionReasons} from '@fluxer/constants/src/Core';
|
||||
@@ -44,11 +46,28 @@ interface AdminUserDeletionServiceDeps {
|
||||
billingRepository: BillingRepository;
|
||||
oauth2Tokens: Pick<OAuth2TokenRepository, 'deleteAllAccessTokensForUser' | 'deleteAllRefreshTokensForUser'>;
|
||||
storeEntitlementService: StoreEntitlementService;
|
||||
ncmecRepository: Pick<NcmecRepository, 'getUserWorkflow'>;
|
||||
}
|
||||
|
||||
const minUserRequestedDeletionDays = 14;
|
||||
const minStandardDeletionDays = 60;
|
||||
|
||||
const reportResolvingDeletionReasons: ReadonlySet<number> = new Set([
|
||||
DeletionReasons.SPAM,
|
||||
DeletionReasons.CHEATING_OR_EXPLOITATION,
|
||||
DeletionReasons.COORDINATED_RAIDING,
|
||||
DeletionReasons.AUTOMATION_OR_SELFBOT,
|
||||
DeletionReasons.SCAM_OR_SOCIAL_ENGINEERING,
|
||||
DeletionReasons.HARASSMENT_OR_BULLYING,
|
||||
DeletionReasons.BAN_EVASION,
|
||||
DeletionReasons.TOKEN_OR_CREDENTIAL_SCAM,
|
||||
DeletionReasons.HATE_SPEECH_OR_EXTREMIST_CONTENT,
|
||||
DeletionReasons.MALICIOUS_LINKS_OR_MALWARE,
|
||||
DeletionReasons.IMPERSONATION_OR_FAKE_IDENTITY,
|
||||
]);
|
||||
|
||||
const manuallyResolvedReportCategories: ReadonlySet<string> = new Set(['child_safety', 'underage_user', 'self_harm']);
|
||||
|
||||
function describePendingDeletion(user: User, prefix: string): Array<[string, string]> {
|
||||
if (!user.pendingDeletionAt) return [];
|
||||
return [
|
||||
@@ -246,7 +265,9 @@ export class AdminUserDeletionService {
|
||||
let knownIps: ReadonlySet<string> = new Set();
|
||||
if (data.reason_code !== DeletionReasons.USER_REQUESTED) {
|
||||
knownIps = await this.banIdentifiersForScheduledDeletion({user, adminUserId, auditLogReason});
|
||||
await this.resolvePendingReportsAgainstUser({user, adminUserId});
|
||||
}
|
||||
if (reportResolvingDeletionReasons.has(data.reason_code)) {
|
||||
await this.resolvePendingReportsAgainstUser({user, adminUserId, reasonCode: data.reason_code});
|
||||
}
|
||||
await emitAdminAction(adminUserId, userId, 'schedule_deletion', {reasonCode: data.reason_code, ips: knownIps});
|
||||
await updatePropagator.propagateUserUpdate({userId, oldUser: user, updatedUser: updatedUser});
|
||||
@@ -381,9 +402,17 @@ export class AdminUserDeletionService {
|
||||
return knownIps;
|
||||
}
|
||||
|
||||
private async resolvePendingReportsAgainstUser(params: {user: User; adminUserId: UserID}): Promise<void> {
|
||||
const {user, adminUserId} = params;
|
||||
const {reportService, auditService} = this.deps;
|
||||
private async resolvePendingReportsAgainstUser(params: {
|
||||
user: User;
|
||||
adminUserId: UserID;
|
||||
reasonCode: number;
|
||||
}): Promise<void> {
|
||||
const {user, adminUserId, reasonCode} = params;
|
||||
const outcome = isEnforcementDeletionReason(reasonCode) ? 'actioned' : 'auto_resolved';
|
||||
const {reportService, auditService, ncmecRepository} = this.deps;
|
||||
if (await ncmecRepository.getUserWorkflow(user.id)) {
|
||||
return;
|
||||
}
|
||||
const reportSearchService = getReportSearchService();
|
||||
if (!reportSearchService) {
|
||||
Logger.warn(
|
||||
@@ -409,6 +438,7 @@ export class AdminUserDeletionService {
|
||||
);
|
||||
if (hits.length === 0) break;
|
||||
for (const hit of hits) {
|
||||
if (manuallyResolvedReportCategories.has(hit.category)) continue;
|
||||
pendingReportIds.add(hit.id);
|
||||
}
|
||||
offset += hits.length;
|
||||
@@ -423,7 +453,10 @@ export class AdminUserDeletionService {
|
||||
for (const hitId of pendingReportIds) {
|
||||
const reportId = createReportID(BigInt(hitId));
|
||||
try {
|
||||
await reportService.resolveReport(reportId, adminUserId, null, auditLogReason);
|
||||
await reportService.resolveReport(reportId, adminUserId, null, auditLogReason, {
|
||||
outcome,
|
||||
resolvedBy: 'system',
|
||||
});
|
||||
resolvedCount++;
|
||||
} catch (error) {
|
||||
if (error instanceof ReportAlreadyResolvedError) continue;
|
||||
|
||||
@@ -4,7 +4,10 @@ import type {ApiContext} from '@app/api/ApiContext';
|
||||
import {mapUserToAdminResponse} from '@app/api/admin/models/UserTypes';
|
||||
import {createUserID} from '@app/api/BrandedTypes';
|
||||
import {isSyntheticUserId} from '@app/api/constants/Core';
|
||||
import {usesUniqueUsernames} from '@app/api/instance/AccountIdentityModeCache';
|
||||
import {Logger} from '@app/api/Logger';
|
||||
import type {User} from '@app/api/models/User';
|
||||
import {findPersonByLoginHandle, parseLoginHandle} from '@app/api/user/UniqueUsernames';
|
||||
import type {LookupUserRequest} from '@fluxer/schema/src/domains/admin/AdminUserSchemas';
|
||||
|
||||
interface AdminUserLookupServiceDeps {
|
||||
@@ -41,10 +44,17 @@ export class AdminUserLookupService {
|
||||
} else if (query.includes('@')) {
|
||||
user = await userRepository.findByEmail(query);
|
||||
} else {
|
||||
user = await userRepository.findByStripeSubscriptionId(query);
|
||||
user = (await this.findPersonByBareUsername(query)) ?? (await userRepository.findByStripeSubscriptionId(query));
|
||||
}
|
||||
return {
|
||||
users: user ? [await mapUserToAdminResponse(user, cacheService, acls)] : [],
|
||||
};
|
||||
}
|
||||
|
||||
private async findPersonByBareUsername(query: string): Promise<User | null> {
|
||||
if (!usesUniqueUsernames()) return null;
|
||||
const handle = parseLoginHandle(query);
|
||||
if (!handle || handle.discriminator !== null) return null;
|
||||
return await findPersonByLoginHandle(this.deps.apiContext.services.users, handle);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,10 +10,11 @@ import {GuildMemberSearchIndexService} from '@app/api/guild/services/member/Guil
|
||||
import type {IDiscriminatorService} from '@app/api/infrastructure/DiscriminatorService';
|
||||
import type {EntityAssetService, PreparedAssetUpload} from '@app/api/infrastructure/EntityAssetService';
|
||||
import {Logger} from '@app/api/Logger';
|
||||
import {getInstanceConfigRepository} from '@app/api/middleware/ServiceSingletons';
|
||||
import type {User} from '@app/api/models/User';
|
||||
import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes';
|
||||
import {AccessDeniedError} from '@fluxer/errors/src/domains/core/AccessDeniedError';
|
||||
import {InputValidationError} from '@fluxer/errors/src/domains/core/InputValidationError';
|
||||
import {enqueueStripeCustomerEmailSync} from '@app/api/stripe/StripeCustomer';
|
||||
import {assertNoDiscriminatorChange, reserveUsername, type UsernameReservation} from '@app/api/user/UniqueUsernames';
|
||||
import {USERNAME_MODE_DISCRIMINATOR} from '@app/api/user/UserTag';
|
||||
import {TagAlreadyTakenError} from '@fluxer/errors/src/domains/user/TagAlreadyTakenError';
|
||||
import {UnknownUserError} from '@fluxer/errors/src/domains/user/UnknownUserError';
|
||||
import type {
|
||||
@@ -21,8 +22,6 @@ import type {
|
||||
ChangeEmailRequest,
|
||||
ChangeUsernameRequest,
|
||||
ClearUserFieldsRequest,
|
||||
SetUserBotStatusRequest,
|
||||
SetUserSystemStatusRequest,
|
||||
VerifyUserEmailRequest,
|
||||
} from '@fluxer/schema/src/domains/admin/AdminUserSchemas';
|
||||
import {types} from 'cassandra-driver';
|
||||
@@ -105,75 +104,6 @@ export class AdminUserProfileService {
|
||||
};
|
||||
}
|
||||
|
||||
async setUserBotStatus(
|
||||
data: SetUserBotStatusRequest,
|
||||
adminUserId: UserID,
|
||||
auditLogReason: string | null,
|
||||
acls: ReadonlySet<string>,
|
||||
) {
|
||||
const {users: userRepository, cache: cacheService} = this.deps.apiContext.services;
|
||||
const {auditService, updatePropagator} = this.deps;
|
||||
const userId = createUserID(data.user_id);
|
||||
const user = await userRepository.findUnique(userId);
|
||||
if (!user) {
|
||||
throw new UnknownUserError();
|
||||
}
|
||||
if (data.bot && user.acls.size > 0) {
|
||||
throw new AccessDeniedError();
|
||||
}
|
||||
const updates: Record<string, boolean> = {bot: data.bot};
|
||||
if (!data.bot) {
|
||||
updates['system'] = false;
|
||||
}
|
||||
const updatedUser = await userRepository.patchUpsert(userId, updates, user.toRow());
|
||||
await updatePropagator.propagateUserUpdate({userId, oldUser: user, updatedUser: updatedUser});
|
||||
await auditService.createAuditLog({
|
||||
adminUserId,
|
||||
targetType: 'user',
|
||||
targetId: BigInt(userId),
|
||||
action: 'set_bot_status',
|
||||
auditLogReason,
|
||||
metadata: new Map([['bot', data.bot.toString()]]),
|
||||
});
|
||||
return {
|
||||
user: await mapUserToAdminResponse(updatedUser, cacheService, acls),
|
||||
};
|
||||
}
|
||||
|
||||
async setUserSystemStatus(
|
||||
data: SetUserSystemStatusRequest,
|
||||
adminUserId: UserID,
|
||||
auditLogReason: string | null,
|
||||
acls: ReadonlySet<string>,
|
||||
) {
|
||||
const {users: userRepository, cache: cacheService} = this.deps.apiContext.services;
|
||||
const {auditService, updatePropagator} = this.deps;
|
||||
const userId = createUserID(data.user_id);
|
||||
const user = await userRepository.findUnique(userId);
|
||||
if (!user) {
|
||||
throw new UnknownUserError();
|
||||
}
|
||||
if (data.system && !user.isBot) {
|
||||
throw InputValidationError.fromCode(
|
||||
'system',
|
||||
ValidationErrorCodes.USER_MUST_BE_A_BOT_TO_BE_MARKED_AS_A_SYSTEM_USER,
|
||||
);
|
||||
}
|
||||
const updatedUser = await userRepository.patchUpsert(userId, {system: data.system}, user.toRow());
|
||||
await updatePropagator.propagateUserUpdate({userId, oldUser: user, updatedUser: updatedUser});
|
||||
await auditService.createAuditLog({
|
||||
adminUserId,
|
||||
targetType: 'user',
|
||||
targetId: BigInt(userId),
|
||||
action: 'set_system_status',
|
||||
auditLogReason,
|
||||
metadata: new Map([['system', data.system.toString()]]),
|
||||
});
|
||||
return {
|
||||
user: await mapUserToAdminResponse(updatedUser, cacheService, acls),
|
||||
};
|
||||
}
|
||||
|
||||
async verifyUserEmail(
|
||||
data: VerifyUserEmailRequest,
|
||||
adminUserId: UserID,
|
||||
@@ -223,22 +153,37 @@ export class AdminUserProfileService {
|
||||
if (!user) {
|
||||
throw new UnknownUserError();
|
||||
}
|
||||
const discriminatorResult = await discriminatorService.generateDiscriminator({
|
||||
username: data.username,
|
||||
requestedDiscriminator: data.discriminator,
|
||||
user,
|
||||
});
|
||||
if (!discriminatorResult.available || discriminatorResult.discriminator === -1) {
|
||||
throw new TagAlreadyTakenError();
|
||||
const uniqueUsernames = !user.isBot && (await getInstanceConfigRepository().usesUniqueUsernames());
|
||||
if (uniqueUsernames) {
|
||||
assertNoDiscriminatorChange(data.discriminator, user.discriminator);
|
||||
}
|
||||
const reservation: UsernameReservation | null = uniqueUsernames
|
||||
? await reserveUsername({users: userRepository, cache: cacheService}, data.username, userId)
|
||||
: null;
|
||||
let updatedUser: User;
|
||||
let discriminatorResult: {discriminator: number; available: boolean};
|
||||
try {
|
||||
discriminatorResult = uniqueUsernames
|
||||
? {discriminator: USERNAME_MODE_DISCRIMINATOR, available: true}
|
||||
: await discriminatorService.generateDiscriminator({
|
||||
username: data.username,
|
||||
requestedDiscriminator: data.discriminator,
|
||||
user,
|
||||
});
|
||||
if (!discriminatorResult.available || discriminatorResult.discriminator === -1) {
|
||||
throw new TagAlreadyTakenError();
|
||||
}
|
||||
updatedUser = await userRepository.patchUpsert(
|
||||
userId,
|
||||
{
|
||||
username: data.username,
|
||||
discriminator: discriminatorResult.discriminator,
|
||||
},
|
||||
user.toRow(),
|
||||
);
|
||||
} finally {
|
||||
await reservation?.release();
|
||||
}
|
||||
const updatedUser = await userRepository.patchUpsert(
|
||||
userId,
|
||||
{
|
||||
username: data.username,
|
||||
discriminator: discriminatorResult.discriminator,
|
||||
},
|
||||
user.toRow(),
|
||||
);
|
||||
await updatePropagator.propagateUserUpdate({userId, oldUser: user, updatedUser: updatedUser});
|
||||
await contactChangeLogService.recordDiff({
|
||||
oldUser: user,
|
||||
@@ -274,6 +219,7 @@ export class AdminUserProfileService {
|
||||
users: userRepository,
|
||||
cache: cacheService,
|
||||
contactChangeLog: contactChangeLogService,
|
||||
worker: workerService,
|
||||
} = this.deps.apiContext.services;
|
||||
const {auditService, updatePropagator} = this.deps;
|
||||
const userId = createUserID(data.user_id);
|
||||
@@ -296,6 +242,7 @@ export class AdminUserProfileService {
|
||||
reason: 'admin_action',
|
||||
actorUserId: adminUserId,
|
||||
});
|
||||
await enqueueStripeCustomerEmailSync(workerService, user, updatedUser);
|
||||
await auditService.createAuditLog({
|
||||
adminUserId,
|
||||
targetType: 'user',
|
||||
|
||||
@@ -9,12 +9,15 @@ import * as AuthMfa from '@app/api/auth/AuthMfa';
|
||||
import * as AuthSession from '@app/api/auth/AuthSession';
|
||||
import * as AuthUtility from '@app/api/auth/AuthUtility';
|
||||
import {visibleWebAuthnCredentials} from '@app/api/auth/services/PasskeyRelyingParty';
|
||||
import {RecoveryKitRepository} from '@app/api/auth/services/RecoveryKitRepository';
|
||||
import {createPasswordResetToken, createUserID, type UserID} from '@app/api/BrandedTypes';
|
||||
import {Config} from '@app/api/Config';
|
||||
import {emitAdminAction} from '@app/api/infrastructure/activity/AccountChangeEvents';
|
||||
import {Logger} from '@app/api/Logger';
|
||||
import {getInstanceConfigRepository} from '@app/api/middleware/ServiceSingletons';
|
||||
import {User} from '@app/api/models/User';
|
||||
import {clearNewConversationLimit} from '@app/api/user/NewConversationLimit';
|
||||
import {PASSWORD_RESET_TOKEN_TTL_SECONDS} from '@app/api/user/repositories/auth/TokenRepository';
|
||||
import {mapWebAuthnCredentialToResponse} from '@app/api/user/UserMappers';
|
||||
import {resolveAssignedTraits} from '@app/api/user/UserTraits';
|
||||
import {getIpAddressReverse, getLocationLabelFromIp} from '@app/api/utils/IpUtils';
|
||||
@@ -29,6 +32,7 @@ import {MissingACLError} from '@fluxer/errors/src/domains/core/MissingACLError';
|
||||
import {ServiceUnavailableError} from '@fluxer/errors/src/domains/core/ServiceUnavailableError';
|
||||
import {UnknownUserError} from '@fluxer/errors/src/domains/user/UnknownUserError';
|
||||
import type {
|
||||
AdminPasswordResetLinkResponse,
|
||||
DeleteWebAuthnCredentialRequest,
|
||||
DisableMfaRequest,
|
||||
ListWebAuthnCredentialsRequest,
|
||||
@@ -262,6 +266,68 @@ export class AdminUserSecurityService {
|
||||
});
|
||||
}
|
||||
|
||||
async createPasswordResetLink(
|
||||
data: SendPasswordResetRequest,
|
||||
adminUserId: UserID,
|
||||
auditLogReason: string | null,
|
||||
acls: ReadonlySet<string>,
|
||||
): Promise<AdminPasswordResetLinkResponse> {
|
||||
const {users: userRepository} = this.deps.apiContext.services;
|
||||
const {apiContext, auditService} = this.deps;
|
||||
const userId = createUserID(data.user_id);
|
||||
const user = await userRepository.findUnique(userId);
|
||||
if (!user) {
|
||||
throw new UnknownUserError();
|
||||
}
|
||||
AuthUtility.assertNonBotUser(apiContext, user);
|
||||
assertCallerHoldsTargetAcls(user.acls, acls);
|
||||
const token = createPasswordResetToken(await AuthUtility.generateSecureToken(apiContext));
|
||||
const expiresAt = new Date(Date.now() + PASSWORD_RESET_TOKEN_TTL_SECONDS * 1000);
|
||||
await userRepository.deleteAllPasswordResetTokens(userId);
|
||||
await new RecoveryKitRepository().delete(userId);
|
||||
await userRepository.createPasswordResetToken({
|
||||
token_: token,
|
||||
user_id: userId,
|
||||
email: null,
|
||||
});
|
||||
await auditService.createAuditLog({
|
||||
adminUserId,
|
||||
targetType: 'user',
|
||||
targetId: BigInt(userId),
|
||||
action: 'create_password_reset_link',
|
||||
auditLogReason,
|
||||
metadata: new Map(),
|
||||
});
|
||||
return {
|
||||
url: `${Config.email.appBaseUrl}/reset#token=${token}`,
|
||||
expires_at: expiresAt.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
async revokeRecoveryKit(
|
||||
data: SendPasswordResetRequest,
|
||||
adminUserId: UserID,
|
||||
auditLogReason: string | null,
|
||||
acls: ReadonlySet<string>,
|
||||
): Promise<void> {
|
||||
const {users: userRepository} = this.deps.apiContext.services;
|
||||
const userId = createUserID(data.user_id);
|
||||
const user = await userRepository.findUnique(userId);
|
||||
if (!user) {
|
||||
throw new UnknownUserError();
|
||||
}
|
||||
assertCallerHoldsTargetAcls(user.acls, acls);
|
||||
await new RecoveryKitRepository().delete(userId);
|
||||
await this.deps.auditService.createAuditLog({
|
||||
adminUserId,
|
||||
targetType: 'user',
|
||||
targetId: BigInt(userId),
|
||||
action: 'revoke_recovery_kit',
|
||||
auditLogReason,
|
||||
metadata: new Map(),
|
||||
});
|
||||
}
|
||||
|
||||
async resendVerificationEmail(
|
||||
data: ResendVerificationEmailRequest,
|
||||
adminUserId: UserID,
|
||||
@@ -573,3 +639,11 @@ export class AdminUserSecurityService {
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function assertCallerHoldsTargetAcls(targetAcls: ReadonlySet<string>, callerAcls: ReadonlySet<string>): void {
|
||||
if (callerAcls.has(AdminACLs.WILDCARD)) return;
|
||||
const missing = [...targetAcls].find((acl) => !callerAcls.has(acl));
|
||||
if (missing !== undefined) {
|
||||
throw new MissingACLError(missing);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import {AdminUserSecurityService} from '@app/api/admin/services/AdminUserSecurit
|
||||
import {AdminUserUpdatePropagator} from '@app/api/admin/services/AdminUserUpdatePropagator';
|
||||
import {createChannelID, createUserID, type UserID} from '@app/api/BrandedTypes';
|
||||
import type {IChannelRepository} from '@app/api/channel/IChannelRepository';
|
||||
import {NcmecRepository} from '@app/api/csam/NcmecRepository';
|
||||
import type {IGuildRepositoryAggregate} from '@app/api/guild/repositories/IGuildRepositoryAggregate';
|
||||
import type {IDiscriminatorService} from '@app/api/infrastructure/DiscriminatorService';
|
||||
import type {EntityAssetService} from '@app/api/infrastructure/EntityAssetService';
|
||||
@@ -112,6 +113,7 @@ export class AdminUserService {
|
||||
billingRepository: getBillingRepository(),
|
||||
oauth2Tokens: new OAuth2TokenRepository(),
|
||||
storeEntitlementService: deps.storeEntitlementService,
|
||||
ncmecRepository: new NcmecRepository(),
|
||||
});
|
||||
this.contactChangeLogService = contactChangeLog;
|
||||
}
|
||||
|
||||
@@ -78,6 +78,7 @@ export class AdminGuildMembershipService {
|
||||
reason: data.reason ?? undefined,
|
||||
banDurationSeconds: data.ban_duration_seconds ?? undefined,
|
||||
skipGuildAuditLog: true,
|
||||
by: 'staff',
|
||||
},
|
||||
auditLogReason,
|
||||
);
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {createTestAccount, setUserACLs, type TestAccount} from '@app/api/auth/tests/AuthTestUtils';
|
||||
import {createChannel, createGuild} from '@app/api/channel/tests/ChannelTestUtils';
|
||||
import {ensureSessionStarted} from '@app/api/message/tests/MessageTestUtils';
|
||||
import {getAdminRepository} from '@app/api/middleware/ServiceSingletons';
|
||||
import {type ApiTestHarness, createApiTestHarness} from '@app/api/test/ApiTestHarness';
|
||||
import {createBuilder} from '@app/api/test/TestRequestBuilder';
|
||||
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
|
||||
import {afterAll, beforeAll, beforeEach, describe, expect, it} from 'vitest';
|
||||
|
||||
interface ValidationErrorResponse {
|
||||
code: string;
|
||||
errors?: Array<{path: string; message: string}>;
|
||||
}
|
||||
|
||||
interface EntryPage {
|
||||
items: Array<{value: string; match_subdomains: boolean | null}>;
|
||||
}
|
||||
|
||||
describe('Admin url-domain blocklist patterns', () => {
|
||||
let harness: ApiTestHarness;
|
||||
let admin: TestAccount;
|
||||
|
||||
beforeAll(async () => {
|
||||
harness = await createApiTestHarness();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await harness.reset();
|
||||
admin = await setUserACLs(harness, await createTestAccount(harness), [
|
||||
'admin:authenticate',
|
||||
'ban:url_domain:add',
|
||||
'ban:url_domain:check',
|
||||
'ban:url_domain:remove',
|
||||
]);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await harness?.shutdown();
|
||||
});
|
||||
|
||||
async function add(domain: string, matchSubdomains?: boolean): Promise<void> {
|
||||
await createBuilder(harness, admin.token)
|
||||
.post('/admin/blocklists/url-domain/entries')
|
||||
.body(matchSubdomains === undefined ? {domain} : {domain, match_subdomains: matchSubdomains})
|
||||
.expect(204)
|
||||
.execute();
|
||||
}
|
||||
|
||||
async function check(value: string): Promise<boolean> {
|
||||
const json = await createBuilder<{banned: boolean}>(harness, admin.token)
|
||||
.get(`/admin/blocklists/url-domain/entries/${encodeURIComponent(value)}`)
|
||||
.expect(200)
|
||||
.execute();
|
||||
return json.banned;
|
||||
}
|
||||
|
||||
async function list(): Promise<EntryPage['items']> {
|
||||
const json = await createBuilder<EntryPage>(harness, admin.token)
|
||||
.get('/admin/blocklists/url-domain/entries?limit=200')
|
||||
.expect(200)
|
||||
.execute();
|
||||
return json.items;
|
||||
}
|
||||
|
||||
it('stores a canonical pattern and reports the hosts it covers', async () => {
|
||||
await add('**Shop**.OnRender.com.');
|
||||
expect(await list()).toMatchObject([{value: '*shop*.onrender.com', match_subdomains: true}]);
|
||||
expect(await check('shop-2.onrender.com')).toBe(true);
|
||||
expect(await check('https://www.myshop.onrender.com/checkout')).toBe(true);
|
||||
expect(await check('onrender.com')).toBe(false);
|
||||
expect(await check('docs.onrender.com')).toBe(false);
|
||||
});
|
||||
|
||||
it('records whether the entry is a pattern in the audit log', async () => {
|
||||
await add('*shop*.onrender.com', false);
|
||||
await add('shop.example.com');
|
||||
const logs = (await getAdminRepository().listAllAuditLogsPaginated(1000)).filter(
|
||||
(log) => log.action === 'ban_url_domain',
|
||||
);
|
||||
const metadata = logs.map((log) => Object.fromEntries(log.metadata));
|
||||
expect(metadata).toEqual(
|
||||
expect.arrayContaining([
|
||||
{domain: '*shop*.onrender.com', match_subdomains: 'false', pattern: 'true'},
|
||||
{domain: 'shop.example.com', match_subdomains: 'true', pattern: 'false'},
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects patterns that are too broad or malformed', async () => {
|
||||
for (const domain of ['*', '*.com', '*shop*.co.uk', '*.onrender.com', '*ab*.onrender.com', 'shop.*.example.com']) {
|
||||
const json = await createBuilder<ValidationErrorResponse>(harness, admin.token)
|
||||
.post('/admin/blocklists/url-domain/entries')
|
||||
.body({domain})
|
||||
.expect(400, 'INVALID_FORM_BODY')
|
||||
.execute();
|
||||
expect(json.errors?.[0]?.path, domain).toBe('domain');
|
||||
}
|
||||
expect(await list()).toEqual([]);
|
||||
});
|
||||
|
||||
it('validates the value on update', async () => {
|
||||
const json = await createBuilder<ValidationErrorResponse>(harness, admin.token)
|
||||
.patch(`/admin/blocklists/url-domain/entries/${encodeURIComponent('*.com')}`)
|
||||
.body({})
|
||||
.expect(400, 'INVALID_FORM_BODY')
|
||||
.execute();
|
||||
expect(json.errors?.[0]?.path).toBe('domain');
|
||||
});
|
||||
|
||||
it('stores internationalized domains in ASCII form', async () => {
|
||||
await add('Bücher.Example.');
|
||||
expect((await list()).map((entry) => entry.value)).toEqual(['xn--bcher-kva.example']);
|
||||
expect(await check('www.bücher.example')).toBe(true);
|
||||
});
|
||||
|
||||
it('accepts an add for a domain that is already blocked', async () => {
|
||||
await add('shop.example.com');
|
||||
await add('shop.example.com', false);
|
||||
expect(await list()).toMatchObject([{value: 'shop.example.com', match_subdomains: false}]);
|
||||
});
|
||||
|
||||
it('removes a pattern through any spelling that canonicalizes to it', async () => {
|
||||
await add('*shop*.onrender.com');
|
||||
await createBuilder(harness, admin.token)
|
||||
.delete(`/admin/blocklists/url-domain/entries/${encodeURIComponent('*SHOP**.onrender.com')}`)
|
||||
.expect(204)
|
||||
.execute();
|
||||
expect(await list()).toEqual([]);
|
||||
expect(await check('shop.onrender.com')).toBe(false);
|
||||
});
|
||||
|
||||
it('blocks messages whose masked links or autolinks point at a covered host', async () => {
|
||||
await add('*shop*.onrender.com');
|
||||
const member = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, member.token, 'Links');
|
||||
const channel = await createChannel(harness, member.token, guild.id, 'general');
|
||||
await ensureSessionStarted(harness, member.token);
|
||||
for (const content of [
|
||||
'[open the store](https://shop-2.onrender.com)',
|
||||
'<https://[email protected]:8443/x>',
|
||||
'https://SHOP.onrender.com./',
|
||||
]) {
|
||||
await createBuilder(harness, member.token)
|
||||
.post(`/channels/${channel.id}/messages`)
|
||||
.body({content})
|
||||
.expect(403, APIErrorCodes.CONTENT_BLOCKED)
|
||||
.execute();
|
||||
}
|
||||
await createBuilder(harness, member.token)
|
||||
.post(`/channels/${channel.id}/messages`)
|
||||
.body({content: '[docs](https://docs.onrender.com) and https://onrender.com'})
|
||||
.expect(200)
|
||||
.execute();
|
||||
});
|
||||
|
||||
it('blocks rich embeds that link to a covered host', async () => {
|
||||
await add('*shop*.onrender.com');
|
||||
const member = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, member.token, 'Embeds');
|
||||
const channel = await createChannel(harness, member.token, guild.id, 'general');
|
||||
await ensureSessionStarted(harness, member.token);
|
||||
await createBuilder(harness, member.token)
|
||||
.post(`/channels/${channel.id}/messages`)
|
||||
.body({embeds: [{title: 'Store', url: 'https://shop.onrender.com/'}]})
|
||||
.expect(403, APIErrorCodes.CONTENT_BLOCKED)
|
||||
.execute();
|
||||
});
|
||||
});
|
||||
@@ -78,7 +78,6 @@ const adminEndpoints: Array<AdminEndpointCase> = [
|
||||
{method: 'GET', path: '/admin/users/1/webauthn-credentials', requiredACL: 'user:update:mfa'},
|
||||
{method: 'DELETE', path: '/admin/users/1/webauthn-credentials/credential', requiredACL: 'user:update:mfa'},
|
||||
{method: 'DELETE', path: '/admin/users/1/profile-fields', requiredACL: 'user:update:profile'},
|
||||
{method: 'PUT', path: '/admin/users/1/bot-status', requiredACL: 'user:update:bot_status'},
|
||||
{method: 'PUT', path: '/admin/users/1/acls', requiredACL: 'acl:set:user'},
|
||||
{method: 'PUT', path: '/admin/users/1/deletion', requiredACL: 'user:delete'},
|
||||
{method: 'POST', path: '/admin/users/1/avatar-block', requiredACL: 'ban:avatar_hash:add'},
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {AdminRepository} from '@app/api/admin/AdminRepository';
|
||||
import type {AdminAuditLog} from '@app/api/admin/IAdminRepository';
|
||||
import {createTestAccount, setUserACLs, type TestAccount} from '@app/api/auth/tests/AuthTestUtils';
|
||||
import {ipBanCache} from '@app/api/middleware/IpBanMiddleware';
|
||||
import {getAdminRepository} from '@app/api/middleware/ServiceSingletons';
|
||||
import {type ApiTestHarness, createApiTestHarness} from '@app/api/test/ApiTestHarness';
|
||||
import {HTTP_STATUS} from '@app/api/test/TestConstants';
|
||||
import {createBuilder} from '@app/api/test/TestRequestBuilder';
|
||||
import {AdminACLs} from '@fluxer/constants/src/AdminACLs';
|
||||
import {afterAll, beforeAll, beforeEach, describe, expect, it} from 'vitest';
|
||||
|
||||
interface BlocklistEntryPage {
|
||||
items: Array<{value: string; reason: string | null; expires_at: string | null; created_at: string | null}>;
|
||||
}
|
||||
|
||||
interface BlocklistCheck {
|
||||
banned: boolean;
|
||||
expires_at: string | null;
|
||||
}
|
||||
|
||||
const HOUR_MS = 3_600_000;
|
||||
|
||||
describe('Admin IP bans with an expiry', () => {
|
||||
let harness: ApiTestHarness;
|
||||
let admin: TestAccount;
|
||||
|
||||
beforeAll(async () => {
|
||||
harness = await createApiTestHarness();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await harness.reset();
|
||||
admin = await setUserACLs(harness, await createTestAccount(harness), [
|
||||
AdminACLs.AUTHENTICATE,
|
||||
AdminACLs.BAN_IP_ADD,
|
||||
AdminACLs.BAN_IP_CHECK,
|
||||
AdminACLs.BAN_IP_REMOVE,
|
||||
]);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await harness.shutdown();
|
||||
});
|
||||
|
||||
async function addIpBan(body: Record<string, unknown>, status: number = HTTP_STATUS.NO_CONTENT): Promise<void> {
|
||||
await createBuilder(harness, admin.token).post('/admin/blocklists/ip/entries').body(body).expect(status).execute();
|
||||
}
|
||||
|
||||
async function listIpBans(): Promise<BlocklistEntryPage['items']> {
|
||||
const page = await createBuilder<BlocklistEntryPage>(harness, admin.token)
|
||||
.get('/admin/blocklists/ip/entries')
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
return page.items;
|
||||
}
|
||||
|
||||
async function checkIpBan(ip: string): Promise<BlocklistCheck> {
|
||||
return createBuilder<BlocklistCheck>(harness, admin.token)
|
||||
.get(`/admin/blocklists/ip/entries/${encodeURIComponent(ip)}`)
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
}
|
||||
|
||||
async function banIpAuditLogs(): Promise<Array<AdminAuditLog>> {
|
||||
const logs = await getAdminRepository().listAllAuditLogsPaginated(1000);
|
||||
return logs.filter((log) => log.action === 'ban_ip');
|
||||
}
|
||||
|
||||
it('stores an expiring ban that the listing and the check both report', async () => {
|
||||
const before = Date.now();
|
||||
await addIpBan({ip: '198.51.100.7', duration_hours: 24});
|
||||
|
||||
const [entry] = await listIpBans();
|
||||
expect(entry?.value).toBe('198.51.100.7');
|
||||
expect(entry?.reason).toBe('platform_admin_enforcement');
|
||||
const expiresAt = Date.parse(entry?.expires_at ?? '');
|
||||
expect(expiresAt).toBeGreaterThanOrEqual(before + 24 * HOUR_MS);
|
||||
expect(expiresAt).toBeLessThanOrEqual(Date.now() + 24 * HOUR_MS);
|
||||
|
||||
const check = await checkIpBan('198.51.100.7');
|
||||
expect(check.banned).toBe(true);
|
||||
expect(Date.parse(check.expires_at ?? '')).toBe(expiresAt);
|
||||
});
|
||||
|
||||
it('records the duration and expiry in the audit log', async () => {
|
||||
await addIpBan({ip: '198.51.100.8', duration_hours: 168});
|
||||
|
||||
const [log] = await banIpAuditLogs();
|
||||
const metadata = Object.fromEntries(log!.metadata);
|
||||
expect(metadata['ip']).toBe('198.51.100.8');
|
||||
expect(metadata['duration_hours']).toBe('168');
|
||||
expect(Date.parse(metadata['expires_at'] ?? '')).toBeGreaterThan(Date.now() + 167 * HOUR_MS);
|
||||
});
|
||||
|
||||
it('keeps a ban permanent when no duration is given', async () => {
|
||||
await addIpBan({ip: '198.51.100.9'});
|
||||
await addIpBan({ip: '198.51.100.10', duration_hours: 0});
|
||||
|
||||
const entries = await listIpBans();
|
||||
expect(entries.map((entry) => entry.expires_at)).toEqual([null, null]);
|
||||
expect(await checkIpBan('198.51.100.9')).toEqual({banned: true, expires_at: null});
|
||||
const [log] = await banIpAuditLogs();
|
||||
expect(log!.metadata.has('duration_hours')).toBe(false);
|
||||
});
|
||||
|
||||
it('replaces a permanent ban with an expiring one when the address is banned again', async () => {
|
||||
await addIpBan({ip: '198.51.100.11'});
|
||||
await addIpBan({ip: '198.51.100.11', duration_hours: 24});
|
||||
|
||||
const [entry] = await listIpBans();
|
||||
expect(entry?.expires_at).not.toBeNull();
|
||||
const check = await checkIpBan('198.51.100.11');
|
||||
expect(check.banned).toBe(true);
|
||||
expect(check.expires_at).not.toBeNull();
|
||||
});
|
||||
|
||||
it('rejects a duration beyond one year', async () => {
|
||||
await addIpBan({ip: '198.51.100.12', duration_hours: 8761}, HTTP_STATUS.BAD_REQUEST);
|
||||
await addIpBan({ip: '198.51.100.12', duration_hours: 1.5}, HTTP_STATUS.BAD_REQUEST);
|
||||
|
||||
expect(await listIpBans()).toEqual([]);
|
||||
});
|
||||
|
||||
it('reports a check with no match as not banned with no expiry', async () => {
|
||||
expect(await checkIpBan('198.51.100.13')).toEqual({banned: false, expires_at: null});
|
||||
});
|
||||
|
||||
it('stops applying an expiring ban once its expiry has passed', async () => {
|
||||
await new AdminRepository().banIp('198.51.100.14', 1);
|
||||
await ipBanCache.refresh();
|
||||
expect(ipBanCache.isBanned('198.51.100.14')).toBe(true);
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 1100));
|
||||
|
||||
expect(ipBanCache.isBanned('198.51.100.14')).toBe(false);
|
||||
await ipBanCache.refresh();
|
||||
expect(ipBanCache.isBanned('198.51.100.14')).toBe(false);
|
||||
expect(await listIpBans()).toEqual([]);
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user