Compare commits

...
Author SHA1 Message Date
HampusandGitHub 4e730832c7 fix(installer): pull images before the first start (#3248) 2026-10-07 02:37:35 +02:00
HampusandGitHub d7c00d4556 fix(schema): keep template topics optional after trimming (#3247) 2026-10-07 01:42:37 +02:00
HampusandGitHub 154b65afe5 docs(self-hosting): fix LiveKit CSP and backup guidance (#3246) 2026-10-07 00:09:39 +02:00
HampusandGitHub fcc2a3f64b docs(github): keep vulnerability reports out of chats (#3245) 2026-10-06 23:25:53 +02:00
HampusandGitHub 80456861ac fix(api): accept long forum topics in imported templates (#3244) 2026-10-06 22:46:47 +02:00
0c4f016ba2 feat(config): read secrets from NAME_FILE variables (#1421)
Co-authored-by: Hampus <[email protected]>
2026-10-06 21:43:08 +02:00
HampusandGitHub cc5545c333 fix(api): sync stripe customer email on change (#3243) 2026-10-06 21:38:25 +02:00
HampusandGitHub 6e28092cdc fix(app): keep mention highlight when mentions are suppressed (#3242) 2026-10-06 20:58:28 +02:00
HampusandGitHub 8b6910d505 chore(github): send bug reports and ideas to feedback.fluxer.com (#3241) 2026-10-06 19:14:26 +02:00
HampusandGitHub d87e31efaf fix(app): drop the reply when its target message is deleted (#3237) 2026-10-06 02:14:00 +02:00
HampusandGitHub 6618a6baf4 fix(installer): replace a stale installer before upgrading (#3235) 2026-10-05 21:50:16 +02:00
HampusandGitHub 22b8f5454b fix(app): skip forwarded messages when editing with arrow up (#3234) 2026-10-05 21:34:05 +02:00
HampusandGitHub 801bd3f106 fix(app): cycle dms in sidebar order with the keyboard (#3233) 2026-10-05 21:17:40 +02:00
HampusandGitHub e26c8c870d feat(api): archive and schedule automated message deletion (#3231) 2026-10-05 21:03:29 +02:00
HampusandGitHub f4e545e090 fix(app): reorder dm list immediately on pin and unpin (#3230) 2026-10-05 20:45:50 +02:00
HampusandGitHub 2006fc0d8d feat(push): allow listed hosts to resolve to private addresses (#3228) 2026-10-05 20:11:08 +02:00
HampusandGitHub d456048e69 fix(svc): respect FLUXER_POSTGRES_SSL=false with a Postgres URL (#3227) 2026-10-05 20:00:50 +02:00
HampusandGitHub fd35b4da24 fix(api): stop counting one refund twice against the allowance (#3226) 2026-10-05 17:41:28 +02:00
HampusandGitHub 283d179b05 fix(app): strip youtube is= share tracking param (#3225) 2026-10-05 17:33:35 +02:00
HampusandGitHub 3093e7334b feat(api): derive stable placeholder names for hidden profiles (#3224) 2026-10-05 16:57:25 +02:00
HampusandGitHub 02c82f0038 fix(api): limit report auto-resolution on scheduled deletion (#3221) 2026-10-05 14:16:08 +02:00
HampusandGitHub e1eecc3b6c feat(auth): add username sign-in mode and recovery kits (#3215) 2026-10-05 14:10:07 +02:00
HampusandGitHub bf3d73a5f7 fix(ci): drop removed preapproval docs and format a test (#3220) 2026-10-05 13:36:33 +02:00
HampusandGitHub 05257d6439 feat(api): add moderation events and visibility actions (#3219) 2026-10-05 13:24:19 +02:00
HampusandGitHub 532e828fe6 perf(app): restore preloading channels and guilds on hover (#3208) 2026-10-04 19:46:49 +02:00
475 changed files with 57407 additions and 24970 deletions
+9 -9
View File
@@ -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.
-41
View File
@@ -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
-83
View File
@@ -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
-18
View File
@@ -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.
-44
View File
@@ -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
View File
@@ -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.
+2 -2
View File
@@ -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
View File
@@ -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",
]
+3
View File
@@ -23,6 +23,9 @@
# Fluxer
> [!IMPORTANT]
> Bug reports and feature requests have moved to [feedback.fluxer.com](https://feedback.fluxer.com). Sign in with your Fluxer account to post, vote and follow updates. GitHub Issues and Discussions are closed. Report security vulnerabilities privately through [fluxer.app/security](https://fluxer.app/security).
Fluxer is a free and open source instant messaging and VoIP chat app built for friends, groups, and communities.
<p align="center">
+1
View File
@@ -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
+10 -1
View File
@@ -184,6 +184,7 @@ FLUXER_S3_SECRET_KEY=CHANGE_ME
FLUXER_SUDO_MODE_SECRET=CHANGE_ME
FLUXER_CONNECTION_INITIATION_SECRET=CHANGE_ME
FLUXER_PROFILE_PSEUDONYM_SECRET=CHANGE_ME
FLUXER_GATEWAY_RPC_AUTH_TOKEN=CHANGE_ME
FLUXER_ERLANG_COOKIE=CHANGE_ME
FLUXER_MEDIA_PROXY_SECRET_KEY=CHANGE_ME
@@ -218,6 +219,9 @@ FLUXER_VAPID_PRIVATE_KEY=CHANGE_ME
#FLUXER_PUSH_SERVICE_FCM_BASE_URL=https://fcm.googleapis.com
#FLUXER_PUSH_SERVICE_MANAGED_RELAY_HOSTS=push.fluxer.com
#FLUXER_PUSH_SERVICE_OWN_RELAY_HOSTS=
# Push hosts on your own network, such as a ntfy server, that may resolve to
# private addresses. Comma separated.
#FLUXER_PUSH_SERVICE_PRIVATE_HOSTS=ntfy.example.com
#FLUXER_PUSH_SERVICE_RELAY_CONSENT_ACCEPTED=false
# Direct mobile push through your own APNs and FCM credentials, off by default.
@@ -259,7 +263,7 @@ FLUXER_VAPID_PRIVATE_KEY=CHANGE_ME
# only when a browser must reach an origin the defaults do not cover. Separate
# several with spaces or commas. The three values below are illustrations.
#FLUXER_CSP_EXTRA_DEFAULT_SRC=
#FLUXER_CSP_EXTRA_CONNECT_SRC=wss://livekit.example.com:7881
#FLUXER_CSP_EXTRA_CONNECT_SRC=wss://livekit.example.com
#FLUXER_CSP_EXTRA_IMG_SRC=https://cdn.example.com
#FLUXER_CSP_EXTRA_MEDIA_SRC=
#FLUXER_CSP_EXTRA_FONT_SRC=
@@ -306,6 +310,7 @@ FLUXER_KLIPY_API_KEY=
# Hosts the api never unfurls, comma separated.
#FLUXER_API_UNFURL_IGNORED_HOSTS=
# Email delivery. Only an instance where members sign in with email needs it.
FLUXER_EMAIL_ENABLED=false
FLUXER_EMAIL_PROVIDER=none
FLUXER_EMAIL_FROM_EMAIL=[email protected]
@@ -333,6 +338,10 @@ FLUXER_DISCOVERY_ENABLED=true
#FLUXER_APP_STATUS_PAGE_URL=
#FLUXER_APP_STATUS_PAGE_INCIDENT_HISTORY_URL=
#FLUXER_INSTANCE_SETUP_CONFIGURED=false
# How members sign in on a new instance, username or email. Unset means username. Read only on the first start.
#FLUXER_ACCOUNT_IDENTITY=
# Username tags on a new email instance. none gives unique names with no tag, random gives name#4821. Unset means none. A username instance always uses none. Read only on the first start.
#FLUXER_TAG_STYLE=
#FLUXER_AUTO_JOIN_INVITE_CODE=
#FLUXER_DELETION_GRACE_PERIOD_HOURS=336
+4
View File
@@ -152,6 +152,8 @@ x-fluxer-env: &fluxer-env
FLUXER_APP_STATUS_PAGE_URL: ${FLUXER_APP_STATUS_PAGE_URL:-}
FLUXER_APP_STATUS_PAGE_INCIDENT_HISTORY_URL: ${FLUXER_APP_STATUS_PAGE_INCIDENT_HISTORY_URL:-}
FLUXER_INSTANCE_SETUP_CONFIGURED: ${FLUXER_INSTANCE_SETUP_CONFIGURED:-}
FLUXER_ACCOUNT_IDENTITY: ${FLUXER_ACCOUNT_IDENTITY:-}
FLUXER_TAG_STYLE: ${FLUXER_TAG_STYLE:-}
FLUXER_AUTO_JOIN_INVITE_CODE: ${FLUXER_AUTO_JOIN_INVITE_CODE:-}
FLUXER_DISCOVERY_ENABLED: ${FLUXER_DISCOVERY_ENABLED:-}
FLUXER_DISCOVERY_MIN_MEMBER_COUNT: ${FLUXER_DISCOVERY_MIN_MEMBER_COUNT:-}
@@ -175,6 +177,7 @@ x-fluxer-env: &fluxer-env
FLUXER_SUDO_MODE_SECRET: ${FLUXER_SUDO_MODE_SECRET:?set FLUXER_SUDO_MODE_SECRET in .env}
FLUXER_CONNECTION_INITIATION_SECRET: ${FLUXER_CONNECTION_INITIATION_SECRET:?set FLUXER_CONNECTION_INITIATION_SECRET in .env}
FLUXER_PROFILE_PSEUDONYM_SECRET: ${FLUXER_PROFILE_PSEUDONYM_SECRET:?set FLUXER_PROFILE_PSEUDONYM_SECRET in .env}
FLUXER_SSO_ALLOW_PRIVATE_ADDRESSES: ${FLUXER_SSO_ALLOW_PRIVATE_ADDRESSES:-}
FLUXER_VAPID_PUBLIC_KEY: ${FLUXER_VAPID_PUBLIC_KEY:?set FLUXER_VAPID_PUBLIC_KEY in .env}
FLUXER_VAPID_PRIVATE_KEY: ${FLUXER_VAPID_PRIVATE_KEY:?set FLUXER_VAPID_PRIVATE_KEY in .env}
@@ -612,6 +615,7 @@ services:
FLUXER_PUSH_SERVICE_FCM_BASE_URL: ${FLUXER_PUSH_SERVICE_FCM_BASE_URL:-}
FLUXER_PUSH_SERVICE_MANAGED_RELAY_HOSTS: ${FLUXER_PUSH_SERVICE_MANAGED_RELAY_HOSTS:-}
FLUXER_PUSH_SERVICE_OWN_RELAY_HOSTS: ${FLUXER_PUSH_SERVICE_OWN_RELAY_HOSTS:-}
FLUXER_PUSH_SERVICE_PRIVATE_HOSTS: ${FLUXER_PUSH_SERVICE_PRIVATE_HOSTS:-}
FLUXER_PUSH_SERVICE_RELAY_CONSENT_ACCEPTED: ${FLUXER_PUSH_SERVICE_RELAY_CONSENT_ACCEPTED:-}
FLUXER_PUSH_APNS_DEFAULT_ENVIRONMENT: ${FLUXER_PUSH_APNS_DEFAULT_ENVIRONMENT:-}
FLUXER_PUSH_FCM_ENABLED: ${FLUXER_PUSH_FCM_ENABLED:-}
+187 -7
View File
@@ -7124,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",
@@ -7258,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",
@@ -9317,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": {
@@ -9727,7 +9862,7 @@
"type": "object",
"properties": {
"acls": {
"maxItems": 103,
"maxItems": 105,
"type": "array",
"items": {"$ref": "#/components/schemas/AdminAclType"},
"description": "List of access control permissions to assign"
@@ -9855,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"]
@@ -10525,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": {
@@ -10832,6 +10991,7 @@
"experiment_delivery",
"registration",
"self_hosted",
"account_identity",
"app_public",
"policy",
"integrations",
@@ -12592,7 +12752,7 @@
},
"acls": {
"description": "Replacement list of access control permissions for the key",
"maxItems": 103,
"maxItems": 105,
"type": "array",
"items": {"$ref": "#/components/schemas/AdminAclType"}
}
@@ -12610,7 +12770,7 @@
"type": "string"
},
"acls": {
"maxItems": 103,
"maxItems": 105,
"type": "array",
"items": {"type": "string"},
"description": "List of access control permissions for the key"
@@ -12640,7 +12800,7 @@
"maximum": 365
},
"acls": {
"maxItems": 103,
"maxItems": 105,
"type": "array",
"items": {"$ref": "#/components/schemas/AdminAclType"},
"description": "List of access control permissions for the key"
@@ -12661,7 +12821,7 @@
"type": "string"
},
"acls": {
"maxItems": 103,
"maxItems": 105,
"type": "array",
"items": {"type": "string"},
"description": "List of access control permissions for the key"
@@ -12674,7 +12834,7 @@
"type": "object",
"properties": {
"acls": {
"maxItems": 103,
"maxItems": 105,
"type": "array",
"items": {"type": "string", "minLength": 1, "maxLength": 64},
"description": "Every admin access control permission the admin API recognises"
@@ -12762,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",
@@ -13235,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"},
@@ -15219,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": {
@@ -15502,7 +15682,7 @@
"description": "ISO 8601 timestamp when the pending deletion was scheduled",
"type": "string"
},
"acls": {"maxItems": 103, "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"}},
+4
View File
@@ -76,6 +76,8 @@ pub const REPORT_VIEW: &str = "report:view";
pub const REPORT_VIEW_REPORTER_PII: &str = "report:view:reporter_pii";
pub const SYSTEM_DM_SEND: &str = "system_dm:send";
pub const USER_CANCEL_BULK_MESSAGE_DELETION: &str = "user:cancel:bulk_message_deletion";
pub const USER_CREATE_PASSWORD_RESET_LINK: &str = "user:create:password_reset_link";
pub const USER_DELETE_RECOVERY_KIT: &str = "user:delete:recovery_kit";
pub const USER_DELETE: &str = "user:delete";
pub const USER_LIST_DM_CHANNELS: &str = "user:list:dm_channels";
pub const USER_LIST_GUILDS: &str = "user:list:guilds";
@@ -180,6 +182,8 @@ pub const ALL_ACLS: &[&str] = &[
REPORT_VIEW_REPORTER_PII,
SYSTEM_DM_SEND,
USER_CANCEL_BULK_MESSAGE_DELETION,
USER_CREATE_PASSWORD_RESET_LINK,
USER_DELETE_RECOVERY_KIT,
USER_DELETE,
USER_LIST_DM_CHANNELS,
USER_LIST_GUILDS,
+13 -3
View File
@@ -2,9 +2,9 @@
use super::client::{AdminApiClient, ApiResult};
use super::types::{
CreateRegistrationUrlRequest, CreateRegistrationUrlResponse, InstanceConfigResponse,
InstanceConfigUpdateRequest, InstanceEmailSmtpTestRequest, InstanceEmailSmtpTestResponse,
InstancePremiumDiscovery,
AccountIdentitySettings, CreateRegistrationUrlRequest, CreateRegistrationUrlResponse,
InstanceAccountIdentityDiscovery, InstanceConfigResponse, InstanceConfigUpdateRequest,
InstanceEmailSmtpTestRequest, InstanceEmailSmtpTestResponse, InstancePremiumDiscovery,
};
impl AdminApiClient {
@@ -16,6 +16,16 @@ impl AdminApiClient {
self.get("/.well-known/fluxer", None).await
}
pub async fn get_instance_account_identity(&self) -> ApiResult<AccountIdentitySettings> {
let discovery: InstanceAccountIdentityDiscovery =
self.get("/.well-known/fluxer", None).await?;
let mode = discovery.features.account_identity;
Ok(AccountIdentitySettings {
mode,
tag_style: discovery.features.tag_style,
})
}
pub async fn update_instance_config(
&self,
update: &InstanceConfigUpdateRequest,
@@ -13,6 +13,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,
}
+22 -1
View File
@@ -5,7 +5,8 @@ use crate::api::generated::{snowflake, types as generated_types};
use super::client::{AdminApiClient, ApiError, ApiResult};
use super::types::{
AdminUser, AdminUserMeResponse, GuildInfo, ListUserGuildsResponse, LookupUserResponse,
SearchUsersResponse, TerminateSessionsResponse, UserMutationResponse,
PasswordResetLinkResponse, SearchUsersResponse, TerminateSessionsResponse,
UserMutationResponse,
};
impl AdminApiClient {
@@ -473,6 +474,26 @@ impl AdminApiClient {
Ok(())
}
pub async fn create_password_reset_link(
&self,
user_id: &str,
) -> ApiResult<PasswordResetLinkResponse> {
let response = self
.generated()
.create_admin_user_password_reset_link(&snowflake(user_id))
.await
.map_err(|e| self.generated_error(e))?;
self.generated_value(response.into_inner())
}
pub async fn revoke_recovery_kit(&self, user_id: &str) -> ApiResult<()> {
self.generated()
.revoke_admin_user_recovery_kit(&snowflake(user_id))
.await
.map_err(|e| self.generated_error(e))?;
Ok(())
}
pub async fn remove_relationship(
&self,
user_id: &str,
@@ -0,0 +1,25 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use crate::{
api::client::AdminApiClient, middleware::auth::AuthContext, state::AppState,
utils::user_tag::with_unique_usernames,
};
use axum::{
extract::{Request, State},
middleware::Next,
response::Response,
};
pub async fn scope_account_identity(
State(state): State<AppState>,
request: Request,
next: Next,
) -> Response {
let Some(auth) = request.extensions().get::<AuthContext>() else {
return next.run(request).await;
};
let client = AdminApiClient::new(state.http_client(), state.config(), &auth.session);
let settings = state.account_identity_settings(&client).await;
let unique_usernames = settings.mode.is_username() || settings.tag_style.is_none();
with_unique_usernames(unique_usernames, next.run(request)).await
}
+1
View File
@@ -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;
+45 -6
View File
@@ -48,17 +48,41 @@ pub fn router() -> Router<AppState> {
)
}
fn render_ban_page(state: &AppState, auth: &AuthContext, key: &str, req: &Request) -> Response {
async fn render_ban_page(
state: &AppState,
auth: &AuthContext,
key: &str,
csrf_token: String,
) -> Response {
let config = state.config();
let ban_cfg = match templates::pages::bans::get_ban_config(key) {
Some(c) => c,
None => return axum::http::StatusCode::NOT_FOUND.into_response(),
};
let csrf_token = csrf::get_csrf_token(req);
let markup = templates::pages::bans::bans_page(config, auth, ban_cfg, None, &csrf_token);
let username_sign_in = email_bans_on_username_instance(state, auth, key).await;
let markup = templates::pages::bans::bans_page(
config,
auth,
ban_cfg,
None,
&csrf_token,
username_sign_in,
);
Html(markup.into_string()).into_response()
}
async fn email_bans_on_username_instance(state: &AppState, auth: &AuthContext, key: &str) -> bool {
key == "email-bans"
&& state
.account_identity(&AdminApiClient::new(
state.http_client(),
state.config(),
&auth.session,
))
.await
.is_username()
}
macro_rules! ban_get {
($name:ident, $key:expr) => {
async fn $name(
@@ -66,7 +90,8 @@ macro_rules! ban_get {
auth: axum::Extension<AuthContext>,
request: Request,
) -> Response {
render_ban_page(&state, &auth.0, $key, &request)
let csrf_token = csrf::get_csrf_token(&request);
render_ban_page(&state, &auth.0, $key, csrf_token).await
}
};
}
@@ -96,7 +121,17 @@ async fn generic_ban_post(
let value = extract_value(form, ban_cfg.input_name);
let is_htmx = htmx::is_htmx_request(headers);
let (level, msg) = execute_ban(&client, ban_key, action, &value, form).await;
flash_response(config, auth, is_htmx, level, &msg, ban_cfg, csrf_token)
let username_sign_in = !is_htmx && email_bans_on_username_instance(state, auth, ban_key).await;
flash_response(
config,
auth,
is_htmx,
level,
&msg,
ban_cfg,
csrf_token,
username_sign_in,
)
}
macro_rules! ban_post {
@@ -114,14 +149,18 @@ macro_rules! ban_post {
let form: BanFormData = match Form::from_request(request, &state).await {
Ok(Form(f)) => f,
Err(_) => {
let is_htmx = htmx::is_htmx_request(&headers);
let username_sign_in =
!is_htmx && email_bans_on_username_instance(&state, &auth.0, $key).await;
return flash_response(
state.config(),
&auth.0,
htmx::is_htmx_request(&headers),
is_htmx,
"error",
"Invalid form data",
templates::pages::bans::get_ban_config($key).unwrap(),
&csrf_token,
username_sign_in,
);
}
};
+10 -2
View File
@@ -226,6 +226,7 @@ fn ban_action_result(
}
}
#[allow(clippy::too_many_arguments)]
pub fn flash_response(
config: &crate::config::AdminConfig,
auth: &AuthContext,
@@ -234,13 +235,20 @@ pub fn flash_response(
message: &str,
ban_cfg: &templates::pages::bans::BanConfig,
csrf_token: &str,
username_sign_in: bool,
) -> Response {
if is_htmx {
render_inline_flash(level, message)
} else {
let flash = to_flash(level, message);
let markup =
templates::pages::bans::bans_page(config, auth, ban_cfg, Some(&flash), csrf_token);
let markup = templates::pages::bans::bans_page(
config,
auth,
ban_cfg,
Some(&flash),
csrf_token,
username_sign_in,
);
Html(markup.into_string()).into_response()
}
}
+4
View File
@@ -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" {
+8 -1
View File
@@ -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()
}
+4
View File
@@ -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(),
+34 -1
View File
@@ -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(
+5
View File
@@ -367,6 +367,11 @@ pub async fn dispatch(
"Password reset sent successfully",
"Failed to send password reset",
),
"revoke_recovery_kit" => DispatchOutcome::from_result(
client.revoke_recovery_kit(user_id).await,
"Recovery kit revoked",
"Failed to revoke recovery kit",
),
"remove_relationship" => {
let Some(target_id) = get("target_user_id").or_else(|| get("target_id")) else {
return DispatchOutcome::error("Target user ID is required");
+49 -20
View File
@@ -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),
+104 -10
View File
@@ -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." }
},
+46 -1
View File
@@ -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"
);
}
}
+5 -3
View File
@@ -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();
+7 -1
View File
@@ -4,7 +4,7 @@ use crate::{
config::AdminConfig,
middleware::auth::AuthContext,
templates::{
components::{form::csrf_input, page_container::page_header},
components::{alert::alert_info, form::csrf_input, page_container::page_header},
layout::admin_layout,
},
};
@@ -149,10 +149,16 @@ pub fn bans_page(
ban_cfg: &BanConfig,
flash: Option<&crate::api::types::FlashMessage>,
csrf_token: &str,
username_sign_in: bool,
) -> Markup {
let base = &config.base_path;
let content = html! {
(page_header(ban_cfg.title, None))
@if username_sign_in && ban_cfg.active_page == "email-bans" {
div class="mb-6" {
(alert_info(html! { "People sign in with a username on this instance. Accounts have no email address, so email bans have no effect." }))
}
}
div class="grid gap-6 lg:grid-cols-2" {
(ban_card(base, ban_cfg, csrf_token))
(check_ban_card(base, ban_cfg, csrf_token))
@@ -177,7 +177,12 @@ fn guild_feature_label(feature: &str) -> String {
}
}
pub fn bulk_actions_page(config: &AdminConfig, auth: &AuthContext, csrf_token: &str) -> Markup {
pub fn bulk_actions_page(
config: &AdminConfig,
auth: &AuthContext,
csrf_token: &str,
username_sign_in: bool,
) -> Markup {
let base = &config.base_path;
let admin_acls = auth
.admin_user
@@ -198,7 +203,7 @@ pub fn bulk_actions_page(config: &AdminConfig, auth: &AuthContext, csrf_token: &
(bulk_add_guild_members_section(base, csrf_token))
}
@if acl::has_permission(admin_acls, acl::BULK_DELETE_USERS) {
(bulk_schedule_deletion_section(base, csrf_token))
(bulk_schedule_deletion_section(base, csrf_token, username_sign_in))
}
@if acl::has_permission(admin_acls, acl::BULK_DELETE_USER_MESSAGES) {
(bulk_delete_user_messages_section(base, csrf_token))
@@ -331,7 +336,7 @@ fn bulk_add_guild_members_section(base: &str, csrf_token: &str) -> Markup {
)
}
fn bulk_schedule_deletion_section(base: &str, csrf_token: &str) -> Markup {
fn bulk_schedule_deletion_section(base: &str, csrf_token: &str, username_sign_in: bool) -> Markup {
section_card_simple(
"Bulk Schedule User Deletion",
html! {
@@ -370,7 +375,11 @@ fn bulk_schedule_deletion_section(base: &str, csrf_token: &str) -> Markup {
},
))
(text_input("audit_log_reason", "Audit Log Reason (optional)", "", "Reason for this bulk operation"))
(opt_out_checkbox("notify_user", "Email each user about the scheduled deletion"))
@if username_sign_in {
input type="hidden" name="notify_user_present" value="1";
} @else {
(opt_out_checkbox("notify_user", "Email each user about the scheduled deletion"))
}
(form_actions(html! {
(danger_button("Schedule Deletion"))
}))
@@ -416,7 +425,7 @@ mod tests {
#[test]
fn deletion_form_has_no_preselected_reason() {
let markup = bulk_schedule_deletion_section("/admin", "csrf").into_string();
let markup = bulk_schedule_deletion_section("/admin", "csrf", false).into_string();
assert!(markup.contains(r#"<option value="" selected>Select a reason</option>"#));
for (value, _) in DELETION_REASONS {
assert!(!markup.contains(&format!(r#"<option value="{value}" selected>"#)));
@@ -425,17 +434,25 @@ mod tests {
#[test]
fn deletion_form_defaults_to_the_moderation_retention_floor() {
let markup = bulk_schedule_deletion_section("/admin", "csrf").into_string();
let markup = bulk_schedule_deletion_section("/admin", "csrf", false).into_string();
assert!(markup.contains(r#"name="days_until_deletion" value="60" min="14" max="365""#));
}
#[test]
fn deletion_form_emails_each_user_by_default() {
let markup = bulk_schedule_deletion_section("/admin", "csrf").into_string();
let markup = bulk_schedule_deletion_section("/admin", "csrf", false).into_string();
assert!(markup.contains(r#"name="notify_user" value="true" checked"#));
assert!(markup.contains(r#"name="notify_user_present" value="1""#));
}
#[test]
fn username_mode_hides_the_email_choices() {
let deletion = bulk_schedule_deletion_section("/admin", "csrf", true).into_string();
assert!(!deletion.contains("Email each user"));
assert!(!deletion.contains(r#"name="notify_user" value="true""#));
assert!(deletion.contains(r#"name="notify_user_present" value="1""#));
}
#[test]
fn remove_grid_can_clear_the_deprecated_clone_features() {
let markup = guild_feature_checkbox_grid("remove_features[]", true).into_string();
@@ -18,6 +18,7 @@ use crate::{
},
utils::bigint::format_discriminator,
utils::timestamps::format_admin_timestamp,
utils::user_tag::user_tag,
};
use maud::{Markup, html};
@@ -74,7 +75,7 @@ fn owner_display(
let Some(discriminator) = discriminator else {
return owner_id.to_owned();
};
let tag = format!("{username}#{}", format_discriminator(discriminator));
let tag = user_tag(username, &format_discriminator(discriminator), false);
match global_name.filter(|value| !value.trim().is_empty()) {
Some(global_name) => format!("{global_name} ({tag})"),
None => tag,
@@ -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()
@@ -15,7 +15,7 @@ use crate::{
layout::admin_layout,
pages::user_detail_tabs,
},
utils::bigint::format_discriminator,
utils::{bigint::format_discriminator, user_tag::user_tag},
};
use maud::{Markup, html};
@@ -163,7 +163,7 @@ fn render_user_detail(
))
}
p class="break-words text-sm text-neutral-500" {
(user.username) "#" (format_discriminator(&user.discriminator))
(user_tag(&user.username, &format_discriminator(&user.discriminator), user.bot))
}
p class="break-all text-sm text-neutral-500" {
(user.id)
@@ -1,12 +1,17 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use crate::{
api::types::{AdminUser, UserSession, WebAuthnCredential},
acl,
api::types::{
AccountIdentityMode, AdminUser, PasswordResetLinkResponse, UserSession, WebAuthnCredential,
},
config::AdminConfig,
templates::components::{
alert::{AlertVariant, alert},
form::{checkbox, csrf_input, form_actions, submit_button},
page_container::card_with_header,
},
utils::timestamps::format_admin_timestamp,
};
use maud::{Markup, html};
@@ -17,19 +22,35 @@ const BTN_CLS: &str = "w-full inline-flex items-center justify-center rounded-md
bg-brand-primary px-4 py-2 text-sm font-medium text-white \
shadow-sm hover:bg-brand-primary-dark";
pub struct AccountTabOptions<'a> {
pub admin_acls: &'a [String],
pub account_identity: AccountIdentityMode,
pub password_reset_link: Option<&'a PasswordResetLinkResponse>,
}
pub fn account_tab(
config: &AdminConfig,
user: &AdminUser,
sessions: &[UserSession],
webauthn_credentials: &[WebAuthnCredential],
csrf_token: &str,
options: &AccountTabOptions<'_>,
) -> Markup {
let base = &config.base_path;
let username_sign_in = options.account_identity.is_username();
let can_create_reset_link = username_sign_in
&& acl::has_permission(options.admin_acls, acl::USER_CREATE_PASSWORD_RESET_LINK);
let can_revoke_recovery_kit = username_sign_in
&& !user.bot
&& acl::has_permission(options.admin_acls, acl::USER_DELETE_RECOVERY_KIT);
html! {
div class="space-y-6" {
(edit_account_card(base, user, csrf_token))
@if can_create_reset_link {
(password_reset_link_card(base, user, csrf_token, options.password_reset_link))
}
(edit_account_card(base, user, csrf_token, username_sign_in))
(sessions_card(config, sessions))
(quick_actions_card(base, user, csrf_token))
(quick_actions_card(base, user, csrf_token, username_sign_in, can_revoke_recovery_kit))
(clear_fields_card(base, user, csrf_token))
(security_actions_card(base, user, csrf_token))
(webauthn_credentials_card(base, user, webauthn_credentials, csrf_token))
@@ -37,7 +58,77 @@ pub fn account_tab(
}
}
fn edit_account_card(base: &str, user: &AdminUser, csrf_token: &str) -> Markup {
fn password_reset_link_card(
base: &str,
user: &AdminUser,
csrf_token: &str,
link: Option<&PasswordResetLinkResponse>,
) -> Markup {
let action_url = format!(
"{base}/users/{}?action=create_password_reset_link&tab=account",
user.id
);
html! {
(card_with_header("Password Reset Link", html! {
div class="space-y-4" {
(password_reset_link_result(link))
p class="text-sm text-neutral-600" {
"Create a one-time link that lets this user choose a new password. \
Hand it to them yourself. It works once and expires after an hour."
}
form method="post"
action=(&action_url)
data-admin-result-form="true"
hx-post=(&action_url)
hx-target={"#" (PASSWORD_RESET_LINK_RESULT_ID)}
hx-swap="outerHTML"
hx-push-url="false" {
(csrf_input(csrf_token))
button type="submit" class=(BTN_CLS) { "Create Password Reset Link" }
}
}
}))
}
}
pub const PASSWORD_RESET_LINK_RESULT_ID: &str = "password-reset-link-result";
pub fn password_reset_link_result(link: Option<&PasswordResetLinkResponse>) -> Markup {
html! {
div id=(PASSWORD_RESET_LINK_RESULT_ID) hx-history=[link.is_some().then_some("false")] {
@if let Some(link) = link {
(alert(AlertVariant::Success, Some("Password reset link created"), html! {
div class="flex flex-col gap-2" {
p class="text-sm" {
"Copy this link now. It is shown only once."
}
div class="flex items-center gap-2" {
input type="url" readonly value=(link.url)
aria-label="Password reset link"
class="h-8 min-w-0 flex-1 rounded-lg border border-green-200 bg-white px-3 py-1.5 text-xs text-neutral-900";
button type="button"
class="inline-flex h-8 shrink-0 items-center justify-center rounded-lg border border-neutral-300 bg-neutral-50 px-3 text-xs font-medium text-neutral-700 hover:border-neutral-400 hover:text-neutral-900"
data-copy-value=(link.url)
onclick="window.__adminCopyToClipboard && window.__adminCopyToClipboard(this.dataset.copyValue, this, 'Copied')" {
"Copy Link"
}
}
p class="text-xs" {
"Expires " (format_admin_timestamp(&link.expires_at))
}
}
}))
}
}
}
}
fn edit_account_card(
base: &str,
user: &AdminUser,
csrf_token: &str,
username_sign_in: bool,
) -> Markup {
html! {
(card_with_header("Edit Account Information", html! {
div class="grid gap-4 md:grid-cols-2" {
@@ -46,22 +137,26 @@ fn edit_account_card(base: &str, user: &AdminUser, csrf_token: &str) -> Markup {
p class="text-sm font-medium text-neutral-700" { "Change Username:" }
input type="text" name="username" placeholder="New username"
required class=(INPUT_CLS);
input type="text" name="discriminator"
placeholder="Discriminator (optional)" inputmode="numeric" pattern="[0-9]{1,4}" maxlength="4"
class=(INPUT_CLS);
@if !crate::utils::user_tag::unique_usernames() || user.bot {
input type="text" name="discriminator"
placeholder="Discriminator (optional)" inputmode="numeric" pattern="[0-9]{1,4}" maxlength="4"
class=(INPUT_CLS);
}
(form_actions(html! {
(submit_button("Change Username"))
}))
}, csrf_token))
(post_form(base, &user.id, "change_email", "account",
"Are you sure you want to change this user\\'s email address?", html! {
p class="text-sm font-medium text-neutral-700" { "Change Email:" }
input type="email" name="email" placeholder="New email address"
required class=(INPUT_CLS);
(form_actions(html! {
(submit_button("Change Email"))
}))
}, csrf_token))
@if !username_sign_in {
(post_form(base, &user.id, "change_email", "account",
"Are you sure you want to change this user\\'s email address?", html! {
p class="text-sm font-medium text-neutral-700" { "Change Email:" }
input type="email" name="email" placeholder="New email address"
required class=(INPUT_CLS);
(form_actions(html! {
(submit_button("Change Email"))
}))
}, csrf_token))
}
(post_form(base, &user.id, "change_dob", "account",
"Are you sure you want to change this user\\'s date of birth?", html! {
p class="text-sm font-medium text-neutral-700" { "Change Date of Birth:" }
@@ -152,16 +247,28 @@ fn session_entry(base: &str, s: &UserSession, is_tombstone: bool) -> Markup {
}
}
fn quick_actions_card(base: &str, user: &AdminUser, csrf_token: &str) -> Markup {
fn quick_actions_card(
base: &str,
user: &AdminUser,
csrf_token: &str,
username_sign_in: bool,
can_revoke_recovery_kit: bool,
) -> Markup {
html! {
(card_with_header("Quick Actions", html! {
div class="flex flex-wrap gap-3" {
@if !user.email_verified {
@if !user.email_verified && !username_sign_in {
(action_form(base, &user.id, "verify_email", "account", None,
"Verify Email", csrf_token))
}
(action_form(base, &user.id, "send_password_reset", "account", None,
"Send Password Reset", csrf_token))
@if !username_sign_in {
(action_form(base, &user.id, "send_password_reset", "account", None,
"Send Password Reset", csrf_token))
}
@if can_revoke_recovery_kit {
(action_form(base, &user.id, "revoke_recovery_kit", "account", None,
"Revoke Recovery Kit", csrf_token))
}
}
}))
}
@@ -1,6 +1,9 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use crate::{api::types::AdminResolvedUser, utils::bigint::format_discriminator};
use crate::{
api::types::AdminResolvedUser,
utils::{bigint::format_discriminator, user_tag::user_tag},
};
pub mod account;
pub mod applications;
@@ -15,8 +18,11 @@ pub mod reports;
pub mod settings;
pub(super) fn resolved_user_display(user: &AdminResolvedUser) -> String {
let disc = format_discriminator(&user.discriminator);
let tag = format!("{}#{}", user.username, disc);
let tag = user_tag(
&user.username,
&format_discriminator(&user.discriminator),
false,
);
match &user.global_name {
Some(gn) if !gn.trim().is_empty() => format!("{} ({})", gn, tag),
_ => tag,
@@ -63,6 +63,7 @@ pub struct CurrentBan<'a> {
pub struct ModerationContext<'a> {
pub deletion_scheduler: Option<&'a AdminUser>,
pub current_ban: Option<CurrentBan<'a>>,
pub username_sign_in: bool,
}
pub fn find_current_ban<'a>(user: &AdminUser, logs: &'a [AuditLogEntry]) -> Option<CurrentBan<'a>> {
@@ -118,8 +119,8 @@ pub fn moderation_tab(
html! {
div class="space-y-6" {
div class="grid grid-cols-1 gap-6 md:grid-cols-2" {
(ban_actions_card(base, user, csrf_token, context.current_ban.as_ref()))
(deletion_card(base, user, csrf_token, context.deletion_scheduler))
(ban_actions_card(base, user, csrf_token, context.current_ban.as_ref(), context.username_sign_in))
(deletion_card(base, user, csrf_token, context.deletion_scheduler, context.username_sign_in))
}
@if can_delete_all_messages {
(delete_all_messages_card(base, user, csrf_token, delete_all_messages_dry_run))
@@ -131,11 +132,20 @@ pub fn moderation_tab(
}
}
fn notify_user_checkbox(username_sign_in: bool, label: &str) -> Markup {
if username_sign_in {
html! { input type="hidden" name="notify_user_present" value="1"; }
} else {
opt_out_checkbox("notify_user", label)
}
}
fn ban_actions_card(
base: &str,
user: &AdminUser,
csrf_token: &str,
current_ban: Option<&CurrentBan<'_>>,
username_sign_in: bool,
) -> Markup {
html! {
(card_with_header("Ban Actions", html! {
@@ -159,7 +169,7 @@ fn ban_actions_card(
px-3 py-2 text-sm shadow-sm \
focus:border-brand-primary focus:outline-none \
focus:ring-1 focus:ring-brand-primary";
(opt_out_checkbox("notify_user", "Email the user that the suspension was lifted"))
(notify_user_checkbox(username_sign_in, "Email the user that the suspension was lifted"))
(form_actions(html! {
(submit_button("Unban User"))
}))
@@ -194,7 +204,7 @@ fn ban_actions_card(
px-3 py-2 text-sm shadow-sm \
focus:border-brand-primary focus:outline-none \
focus:ring-1 focus:ring-brand-primary";
(opt_out_checkbox("notify_user", "Email the user about this suspension (temporary bans only)"))
(notify_user_checkbox(username_sign_in, "Email the user about this suspension (temporary bans only)"))
(form_actions(html! {
(submit_button("Ban/Suspend User"))
}))
@@ -335,6 +345,7 @@ fn deletion_card(
user: &AdminUser,
csrf_token: &str,
scheduler: Option<&AdminUser>,
username_sign_in: bool,
) -> Markup {
html! {
(card_with_header("Account Deletion", html! {
@@ -353,7 +364,9 @@ fn deletion_card(
px-3 py-2 text-sm shadow-sm \
focus:border-brand-primary focus:outline-none \
focus:ring-1 focus:ring-brand-primary";
(checkbox("notify_user", "true", "Email the user that the deletion was cancelled", false, true))
@if !username_sign_in {
(checkbox("notify_user", "true", "Email the user that the deletion was cancelled", false, true))
}
(checkbox("confirm", "true", &confirmation, false, true))
(form_actions(html! {
(danger_button("Cancel Deletion"))
@@ -397,7 +410,7 @@ fn deletion_card(
px-3 py-2 text-sm shadow-sm \
focus:border-brand-primary focus:outline-none \
focus:ring-1 focus:ring-brand-primary";
(opt_out_checkbox("notify_user", "Email the user about the scheduled deletion"))
(notify_user_checkbox(username_sign_in, "Email the user about the scheduled deletion"))
(form_actions(html! {
(submit_button("Schedule Deletion"))
}))
@@ -776,7 +789,8 @@ mod tests {
"deletion_scheduled_at": "2026-08-31T17:40:29.690Z"
}));
let scheduler = user(json!({"id": "1400000000000000001", "username": "lilith"}));
let markup = deletion_card("/admin", &target, "csrf", Some(&scheduler)).into_string();
let markup =
deletion_card("/admin", &target, "csrf", Some(&scheduler), false).into_string();
assert!(markup.contains(r#"href="/admin/users/1400000000000000001""#));
assert!(markup.contains("lilith"));
assert!(markup.contains("Report batch 12"));
@@ -793,7 +807,7 @@ mod tests {
#[test]
fn schedule_form_makes_the_reason_an_explicit_choice() {
let markup = deletion_card("/admin", &user(json!({})), "csrf", None).into_string();
let markup = deletion_card("/admin", &user(json!({})), "csrf", None, false).into_string();
assert!(markup.contains(r#"<option value="" disabled selected>Choose a reason</option>"#));
assert!(!markup.contains(r#"<option value="1" selected>"#));
assert!(!markup.contains("replace_pending_deletion_at"));
@@ -801,22 +815,46 @@ mod tests {
#[test]
fn schedule_form_emails_the_user_by_default() {
let markup = deletion_card("/admin", &user(json!({})), "csrf", None).into_string();
let markup = deletion_card("/admin", &user(json!({})), "csrf", None, false).into_string();
assert!(markup.contains(r#"name="notify_user" value="true" checked"#));
assert!(markup.contains(r#"name="notify_user_present" value="1""#));
}
#[test]
fn temp_ban_form_emails_the_user_by_default() {
let markup = ban_actions_card("/admin", &user(json!({})), "csrf", None).into_string();
let markup =
ban_actions_card("/admin", &user(json!({})), "csrf", None, false).into_string();
assert!(markup.contains(r#"name="notify_user" value="true" checked"#));
assert!(markup.contains(r#"name="notify_user_present" value="1""#));
}
#[test]
fn username_mode_offers_no_email_and_sends_none() {
let pending = user(json!({
"pending_deletion_at": "2026-10-30T17:40:29.690Z",
"deletion_reason_code": 3
}));
let banned = user(json!({"temp_banned_until": "2026-10-01T00:00:00.000Z"}));
let forms = [
deletion_card("/admin", &user(json!({})), "csrf", None, true).into_string(),
deletion_card("/admin", &pending, "csrf", None, true).into_string(),
ban_actions_card("/admin", &user(json!({})), "csrf", None, true).into_string(),
ban_actions_card("/admin", &banned, "csrf", None, true).into_string(),
];
for markup in &forms {
assert!(!markup.contains("Email the user"));
assert!(!markup.contains(r#"name="notify_user" value="true""#));
}
assert!(forms[0].contains(r#"name="notify_user_present" value="1""#));
assert!(!forms[1].contains("notify_user"));
assert!(forms[2].contains(r#"name="notify_user_present" value="1""#));
assert!(forms[3].contains(r#"name="notify_user_present" value="1""#));
}
#[test]
fn unban_form_separates_the_public_and_private_reasons() {
let target = user(json!({"temp_banned_until": "2026-10-01T00:00:00.000Z"}));
let markup = ban_actions_card("/admin", &target, "csrf", None).into_string();
let markup = ban_actions_card("/admin", &target, "csrf", None, false).into_string();
assert!(markup.contains("?action=unban&amp;tab=moderation"));
assert!(markup.contains(r#"name="notify_user" value="true" checked"#));
assert!(markup.contains(r#"name="notify_user_present" value="1""#));
@@ -864,7 +902,7 @@ mod tests {
.collect::<Vec<_>>(),
["3"]
);
let markup = ban_actions_card("/admin", &target, "csrf", Some(&ban)).into_string();
let markup = ban_actions_card("/admin", &target, "csrf", Some(&ban), false).into_string();
assert!(markup.contains("Regel § 3"));
assert!(markup.contains("Also sent links"));
assert!(markup.contains(r#"name="ban_audit_log_id" value="2""#));
@@ -12,6 +12,7 @@ use crate::{
utils::{
bigint::format_discriminator,
timestamps::{format_admin_timestamp, snowflake_creation_date},
user_tag::user_tag,
},
};
use maud::{Markup, html};
@@ -24,10 +25,11 @@ pub fn overview_tab(
change_log: Option<&ListUserChangeLogResponse>,
) -> Markup {
render_overview_tab(
config, user, admin_acls, csrf_token, change_log, None, false,
config, user, admin_acls, csrf_token, change_log, None, false, false,
)
}
#[allow(clippy::too_many_arguments)]
pub fn overview_tab_with_limit_config(
config: &AdminConfig,
user: &AdminUser,
@@ -35,6 +37,7 @@ pub fn overview_tab_with_limit_config(
csrf_token: &str,
change_log: Option<&ListUserChangeLogResponse>,
limit_config: Option<&LimitConfigResponse>,
username_sign_in: bool,
) -> Markup {
render_overview_tab(
config,
@@ -44,9 +47,11 @@ pub fn overview_tab_with_limit_config(
change_log,
limit_config,
true,
username_sign_in,
)
}
#[allow(clippy::too_many_arguments)]
fn render_overview_tab(
config: &AdminConfig,
user: &AdminUser,
@@ -55,6 +60,7 @@ fn render_overview_tab(
change_log: Option<&ListUserChangeLogResponse>,
limit_config: Option<&LimitConfigResponse>,
show_traits: bool,
username_sign_in: bool,
) -> Markup {
html! {
div class="space-y-6" {
@@ -118,7 +124,7 @@ fn render_overview_tab(
(snowflake_creation_date(&user.id))
}))
(detail_row("Username", html! {
(user.username) "#" (format_discriminator(&user.discriminator))
(user_tag(&user.username, &format_discriminator(&user.discriminator), user.bot))
}))
(detail_row("Display Name", html! {
@if let Some(ref name) = user.global_name {
@@ -127,7 +133,7 @@ fn render_overview_tab(
span class="text-neutral-400" { "Not set" }
}
}))
@if acl::has_permission(admin_acls, acl::USER_VIEW_EMAIL) {
@if acl::has_permission(admin_acls, acl::USER_VIEW_EMAIL) && !username_sign_in {
(detail_row("Email", html! {
@if let Some(ref email) = user.email {
(email)
@@ -573,3 +579,64 @@ fn custom_traits<'a>(user: &'a AdminUser, trait_definitions: &[&str]) -> Vec<&'a
.filter(|trait_name| !DERIVED_TRAITS.contains(trait_name))
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
fn test_config() -> AdminConfig {
AdminConfig {
env: crate::config::RuntimeEnv::Test,
host: String::new(),
port: 3020,
secret_key_base: "test-secret".to_owned(),
base_path: "/admin".to_owned(),
api_endpoint: String::new(),
media_endpoint: String::new(),
static_cdn_endpoint: String::new(),
admin_endpoint: String::new(),
web_app_endpoint: String::new(),
oauth_client_id: String::new(),
oauth_client_secret: String::new(),
oauth_redirect_uri: String::new(),
build_version: "test".to_owned(),
self_hosted: true,
proxy: crate::config::ProxyConfig {
trust_client_ip_header: false,
client_ip_header_name: String::new(),
},
}
}
fn render_email_row(username_sign_in: bool) -> String {
let user: AdminUser = serde_json::from_value(serde_json::json!({
"id": "1500000000000000001",
"username": "target",
"discriminator": "0001",
"email": "[email protected]"
}))
.expect("valid admin user");
let acls = vec![acl::USER_VIEW_EMAIL.to_owned()];
render_overview_tab(
&test_config(),
&user,
&acls,
"csrf",
None,
None,
false,
username_sign_in,
)
.into_string()
}
#[test]
fn username_mode_hides_the_email_row() {
assert!(!render_email_row(true).contains("[email protected]"));
}
#[test]
fn email_mode_shows_the_email_row() {
assert!(render_email_row(false).contains("[email protected]"));
}
}
@@ -8,6 +8,7 @@ use crate::{
page_container::card_with_header,
table::data_table,
},
utils::user_tag::user_tag,
};
use maud::{Markup, html};
@@ -162,7 +163,7 @@ fn format_status(status: i32) -> &'static str {
fn format_reporter(report: &ReportEntry) -> String {
if let Some(ref username) = report.reporter_username {
let disc = report.reporter_discriminator.as_deref().unwrap_or("0000");
let tag = format!("{username}#{disc}");
let tag = user_tag(username, disc, false);
if let Some(ref gn) = report.reporter_global_name {
let trimmed = gn.trim();
if !trimmed.is_empty() {
@@ -260,7 +261,7 @@ fn format_reported_entity(report: &ReportEntry) -> String {
.reported_user_discriminator
.as_deref()
.unwrap_or("0000");
let tag = format!("{username}#{disc}");
let tag = user_tag(username, disc, false);
if let Some(ref gn) = report.reported_user_global_name {
let trimmed = gn.trim();
if !trimmed.is_empty() {
@@ -3,7 +3,10 @@
use crate::{
api::types::AdminUser,
templates::components::page_container::{card_with_header, detail_row},
utils::bigint::{format_discriminator, has_flag, list_flags},
utils::{
bigint::{format_discriminator, has_flag, list_flags},
user_tag::user_tag,
},
};
use maud::{Markup, html};
@@ -13,7 +16,7 @@ pub fn settings_tab(user: &AdminUser) -> Markup {
(card_with_header("Profile Settings", html! {
dl class="divide-y divide-neutral-100" {
(detail_row("Username", html! {
(user.username) "#" (format_discriminator(&user.discriminator))
(user_tag(&user.username, &format_discriminator(&user.discriminator), user.bot))
}))
(detail_row("Display Name", html! {
@if let Some(ref name) = user.global_name {
@@ -10,7 +10,9 @@ use crate::{
media::user_avatar_url,
user_profile_badges::user_profile_badges,
},
utils::{bigint::format_discriminator, timestamps::snowflake_creation_date},
utils::{
bigint::format_discriminator, timestamps::snowflake_creation_date, user_tag::user_tag,
},
};
use maud::{Markup, html};
@@ -72,7 +74,7 @@ pub fn user_peek_fragment(
))
}
p class="break-words text-sm text-neutral-500" {
(user.username) "#" (format_discriminator(&user.discriminator))
(user_tag(&user.username, &format_discriminator(&user.discriminator), user.bot))
}
div class="flex flex-wrap items-center justify-center gap-2 \
sm:justify-start" {
+49 -16
View File
@@ -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", &params(), 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", &params(), true).into_string();
assert!(markup.contains("search-email"));
}
}
+4
View File
@@ -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()
}
+1
View File
@@ -3,3 +3,4 @@
pub mod bigint;
pub mod forms;
pub mod timestamps;
pub mod user_tag;
+69
View File
@@ -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());
}
}
+15
View File
@@ -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));
@@ -1069,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);
}
+446
View File
@@ -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(),
},
}
}
+241
View File
@@ -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(),
},
}
}
+5
View File
@@ -187,6 +187,7 @@ export function buildAPIConfigFromMaster(master: MasterConfig): APIConfig {
headersTimeoutMs: master.services.api.headers_timeout_ms,
requestTimeoutMs: master.services.api.request_timeout_ms,
maxInflightRequests: master.services.api.max_inflight_requests,
automatedMessageDeletionDelayDays: master.services.api.automated_message_deletion_delay_days,
ipBanExemptIps: normalizeIpBanExemptIps(master.services.api.ip_ban_exempt_ips),
cassandra: {
hosts: cassandraSource?.hosts.join(',') ?? '',
@@ -391,6 +392,7 @@ export function buildAPIConfigFromMaster(master: MasterConfig): APIConfig {
auth: {
sudoModeSecret: master.auth.sudo_mode_secret,
connectionInitiationSecret: master.auth.connection_initiation_secret,
profilePseudonymSecret: master.auth.profile_pseudonym_secret,
ssoAllowPrivateAddresses: master.auth.sso_allow_private_addresses,
passkeys: {
rpName: master.auth.passkeys.rp_name,
@@ -412,6 +414,7 @@ export function buildAPIConfigFromMaster(master: MasterConfig): APIConfig {
},
instance: {
selfHosted: master.instance.self_hosted,
baseDomain: master.domain.base_domain,
autoJoinInviteCode: master.instance.auto_join_invite_code,
visionariesGuildId: master.instance.visionaries_guild_id,
visionariesGuildVisionaryRoleId: master.instance.visionaries_guild_visionary_role_id,
@@ -429,6 +432,8 @@ export function buildAPIConfigFromMaster(master: MasterConfig): APIConfig {
setup: {
configured: master.instance.setup.configured,
},
accountIdentity: master.instance.account_identity,
tagStyle: master.instance.tag_style,
},
discovery: {
enabled: master.discovery.enabled,
+7
View File
@@ -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,
+7 -2
View File
@@ -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) {
@@ -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,
@@ -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,6 +20,7 @@ import {ListUserGuildsResponse} from '@fluxer/schema/src/domains/admin/AdminGuil
import {SearchUsersResponse} from '@fluxer/schema/src/domains/admin/AdminSchemas';
import {
AdminAclListResponse,
AdminPasswordResetLinkResponse,
AdminUserAclsRequest,
AdminUserBanNoteRequest,
AdminUserBanRequest,
@@ -632,6 +637,7 @@ export function UserAdminController(app: HonoApp) {
'/admin/users/:user_id/email',
RateLimitMiddleware(RateLimitConfigs.ADMIN_USER_MODIFY),
requireAdminACL(AdminACLs.USER_UPDATE_EMAIL),
RequireEmailAccountIdentity,
Validator('param', UserIdParam),
Validator('json', AdminUserEmailUpdateRequest),
OpenAPI({
@@ -664,6 +670,7 @@ export function UserAdminController(app: HonoApp) {
'/admin/users/:user_id/email-verification',
RateLimitMiddleware(RateLimitConfigs.ADMIN_USER_MODIFY),
requireAdminACL(AdminACLs.USER_UPDATE_EMAIL),
RequireEmailAccountIdentity,
Validator('param', UserIdParam),
OpenAPI({
operationId: 'verify_admin_user_email',
@@ -695,6 +702,7 @@ export function UserAdminController(app: HonoApp) {
'/admin/users/:user_id/verification-email',
RateLimitMiddleware(RateLimitConfigs.ADMIN_USER_MODIFY),
requireAdminACL(AdminACLs.USER_UPDATE_EMAIL),
RequireEmailAccountIdentity,
Validator('param', UserIdParam),
OpenAPI({
operationId: 'resend_admin_user_verification_email',
@@ -723,6 +731,7 @@ export function UserAdminController(app: HonoApp) {
'/admin/users/:user_id/password-reset',
RateLimitMiddleware(RateLimitConfigs.ADMIN_USER_MODIFY),
requireAdminACL(AdminACLs.USER_UPDATE_EMAIL),
RequireEmailAccountIdentity,
Validator('param', UserIdParam),
OpenAPI({
operationId: 'send_admin_user_password_reset',
@@ -743,6 +752,65 @@ export function UserAdminController(app: HonoApp) {
return ctx.body(null, 204);
},
);
app.post(
'/admin/users/:user_id/password-reset-link',
RateLimitMiddleware(RateLimitConfigs.ADMIN_USER_MODIFY),
requireAdminACL(AdminACLs.USER_CREATE_PASSWORD_RESET_LINK),
RequireUsernameAccountIdentity,
Validator('param', UserIdParam),
OpenAPI({
operationId: 'create_admin_user_password_reset_link',
summary: 'Create user password reset link',
responseSchema: AdminPasswordResetLinkResponse,
statusCode: 200,
security: 'adminApiKey',
tags: 'Admin',
description:
'Create a one-time password reset link on an instance where people sign in with a username. Hand the link to the user yourself. It works once and expires after an hour. Deletes the recovery kit of the account. Creates audit log entry. Requires USER_CREATE_PASSWORD_RESET_LINK permission and every ACL the target account holds. Fails with USERNAME_SIGN_IN_ONLY on email instances.',
}),
async (ctx) => {
const adminService = ctx.get('adminService');
const adminUserId = ctx.get('adminUserId');
const auditLogReason = ctx.get('auditLogReason');
const {user_id: userId} = ctx.req.valid('param');
return ctx.json(
await adminService.userService.securityService.createPasswordResetLink(
{user_id: userId},
adminUserId,
auditLogReason,
ctx.get('adminUserAcls'),
),
);
},
);
app.delete(
'/admin/users/:user_id/recovery-kit',
RateLimitMiddleware(RateLimitConfigs.ADMIN_USER_MODIFY),
requireAdminACL(AdminACLs.USER_DELETE_RECOVERY_KIT),
RequireUsernameAccountIdentity,
Validator('param', UserIdParam),
OpenAPI({
operationId: 'revoke_admin_user_recovery_kit',
summary: 'Revoke user recovery kit',
responseSchema: null,
statusCode: 204,
security: 'adminApiKey',
tags: 'Admin',
description:
'Deletes the recovery kit of an account on an instance where people sign in with a username, so its key stops working. Creates audit log entry. Requires USER_DELETE_RECOVERY_KIT permission and every ACL the target account holds. Fails with USERNAME_SIGN_IN_ONLY on email instances.',
}),
async (ctx) => {
const adminService = ctx.get('adminService');
const {user_id: userId} = ctx.req.valid('param');
await adminService.userService.securityService.revokeRecoveryKit(
{user_id: userId},
ctx.get('adminUserId'),
ctx.get('auditLogReason'),
ctx.get('adminUserAcls'),
);
return ctx.body(null, 204);
},
);
app.put(
'/admin/users/:user_id/ban',
RateLimitMiddleware(RateLimitConfigs.ADMIN_USER_MODIFY),
@@ -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,7 +10,11 @@ import {GuildMemberSearchIndexService} from '@app/api/guild/services/member/Guil
import type {IDiscriminatorService} from '@app/api/infrastructure/DiscriminatorService';
import type {EntityAssetService, PreparedAssetUpload} from '@app/api/infrastructure/EntityAssetService';
import {Logger} from '@app/api/Logger';
import {getInstanceConfigRepository} from '@app/api/middleware/ServiceSingletons';
import type {User} from '@app/api/models/User';
import {enqueueStripeCustomerEmailSync} from '@app/api/stripe/StripeCustomer';
import {assertNoDiscriminatorChange, reserveUsername, type UsernameReservation} from '@app/api/user/UniqueUsernames';
import {USERNAME_MODE_DISCRIMINATOR} from '@app/api/user/UserTag';
import {TagAlreadyTakenError} from '@fluxer/errors/src/domains/user/TagAlreadyTakenError';
import {UnknownUserError} from '@fluxer/errors/src/domains/user/UnknownUserError';
import type {
@@ -149,22 +153,37 @@ export class AdminUserProfileService {
if (!user) {
throw new UnknownUserError();
}
const discriminatorResult = await discriminatorService.generateDiscriminator({
username: data.username,
requestedDiscriminator: data.discriminator,
user,
});
if (!discriminatorResult.available || discriminatorResult.discriminator === -1) {
throw new TagAlreadyTakenError();
const uniqueUsernames = !user.isBot && (await getInstanceConfigRepository().usesUniqueUsernames());
if (uniqueUsernames) {
assertNoDiscriminatorChange(data.discriminator, user.discriminator);
}
const reservation: UsernameReservation | null = uniqueUsernames
? await reserveUsername({users: userRepository, cache: cacheService}, data.username, userId)
: null;
let updatedUser: User;
let discriminatorResult: {discriminator: number; available: boolean};
try {
discriminatorResult = uniqueUsernames
? {discriminator: USERNAME_MODE_DISCRIMINATOR, available: true}
: await discriminatorService.generateDiscriminator({
username: data.username,
requestedDiscriminator: data.discriminator,
user,
});
if (!discriminatorResult.available || discriminatorResult.discriminator === -1) {
throw new TagAlreadyTakenError();
}
updatedUser = await userRepository.patchUpsert(
userId,
{
username: data.username,
discriminator: discriminatorResult.discriminator,
},
user.toRow(),
);
} finally {
await reservation?.release();
}
const updatedUser = await userRepository.patchUpsert(
userId,
{
username: data.username,
discriminator: discriminatorResult.discriminator,
},
user.toRow(),
);
await updatePropagator.propagateUserUpdate({userId, oldUser: user, updatedUser: updatedUser});
await contactChangeLogService.recordDiff({
oldUser: user,
@@ -200,6 +219,7 @@ export class AdminUserProfileService {
users: userRepository,
cache: cacheService,
contactChangeLog: contactChangeLogService,
worker: workerService,
} = this.deps.apiContext.services;
const {auditService, updatePropagator} = this.deps;
const userId = createUserID(data.user_id);
@@ -222,6 +242,7 @@ export class AdminUserProfileService {
reason: 'admin_action',
actorUserId: adminUserId,
});
await enqueueStripeCustomerEmailSync(workerService, user, updatedUser);
await auditService.createAuditLog({
adminUserId,
targetType: 'user',
@@ -9,12 +9,15 @@ import * as AuthMfa from '@app/api/auth/AuthMfa';
import * as AuthSession from '@app/api/auth/AuthSession';
import * as AuthUtility from '@app/api/auth/AuthUtility';
import {visibleWebAuthnCredentials} from '@app/api/auth/services/PasskeyRelyingParty';
import {RecoveryKitRepository} from '@app/api/auth/services/RecoveryKitRepository';
import {createPasswordResetToken, createUserID, type UserID} from '@app/api/BrandedTypes';
import {Config} from '@app/api/Config';
import {emitAdminAction} from '@app/api/infrastructure/activity/AccountChangeEvents';
import {Logger} from '@app/api/Logger';
import {getInstanceConfigRepository} from '@app/api/middleware/ServiceSingletons';
import {User} from '@app/api/models/User';
import {clearNewConversationLimit} from '@app/api/user/NewConversationLimit';
import {PASSWORD_RESET_TOKEN_TTL_SECONDS} from '@app/api/user/repositories/auth/TokenRepository';
import {mapWebAuthnCredentialToResponse} from '@app/api/user/UserMappers';
import {resolveAssignedTraits} from '@app/api/user/UserTraits';
import {getIpAddressReverse, getLocationLabelFromIp} from '@app/api/utils/IpUtils';
@@ -29,6 +32,7 @@ import {MissingACLError} from '@fluxer/errors/src/domains/core/MissingACLError';
import {ServiceUnavailableError} from '@fluxer/errors/src/domains/core/ServiceUnavailableError';
import {UnknownUserError} from '@fluxer/errors/src/domains/user/UnknownUserError';
import type {
AdminPasswordResetLinkResponse,
DeleteWebAuthnCredentialRequest,
DisableMfaRequest,
ListWebAuthnCredentialsRequest,
@@ -262,6 +266,68 @@ export class AdminUserSecurityService {
});
}
async createPasswordResetLink(
data: SendPasswordResetRequest,
adminUserId: UserID,
auditLogReason: string | null,
acls: ReadonlySet<string>,
): Promise<AdminPasswordResetLinkResponse> {
const {users: userRepository} = this.deps.apiContext.services;
const {apiContext, auditService} = this.deps;
const userId = createUserID(data.user_id);
const user = await userRepository.findUnique(userId);
if (!user) {
throw new UnknownUserError();
}
AuthUtility.assertNonBotUser(apiContext, user);
assertCallerHoldsTargetAcls(user.acls, acls);
const token = createPasswordResetToken(await AuthUtility.generateSecureToken(apiContext));
const expiresAt = new Date(Date.now() + PASSWORD_RESET_TOKEN_TTL_SECONDS * 1000);
await userRepository.deleteAllPasswordResetTokens(userId);
await new RecoveryKitRepository().delete(userId);
await userRepository.createPasswordResetToken({
token_: token,
user_id: userId,
email: null,
});
await auditService.createAuditLog({
adminUserId,
targetType: 'user',
targetId: BigInt(userId),
action: 'create_password_reset_link',
auditLogReason,
metadata: new Map(),
});
return {
url: `${Config.email.appBaseUrl}/reset#token=${token}`,
expires_at: expiresAt.toISOString(),
};
}
async revokeRecoveryKit(
data: SendPasswordResetRequest,
adminUserId: UserID,
auditLogReason: string | null,
acls: ReadonlySet<string>,
): Promise<void> {
const {users: userRepository} = this.deps.apiContext.services;
const userId = createUserID(data.user_id);
const user = await userRepository.findUnique(userId);
if (!user) {
throw new UnknownUserError();
}
assertCallerHoldsTargetAcls(user.acls, acls);
await new RecoveryKitRepository().delete(userId);
await this.deps.auditService.createAuditLog({
adminUserId,
targetType: 'user',
targetId: BigInt(userId),
action: 'revoke_recovery_kit',
auditLogReason,
metadata: new Map(),
});
}
async resendVerificationEmail(
data: ResendVerificationEmailRequest,
adminUserId: UserID,
@@ -573,3 +639,11 @@ export class AdminUserSecurityService {
};
}
}
function assertCallerHoldsTargetAcls(targetAcls: ReadonlySet<string>, callerAcls: ReadonlySet<string>): void {
if (callerAcls.has(AdminACLs.WILDCARD)) return;
const missing = [...targetAcls].find((acl) => !callerAcls.has(acl));
if (missing !== undefined) {
throw new MissingACLError(missing);
}
}
@@ -12,6 +12,7 @@ import {AdminUserSecurityService} from '@app/api/admin/services/AdminUserSecurit
import {AdminUserUpdatePropagator} from '@app/api/admin/services/AdminUserUpdatePropagator';
import {createChannelID, createUserID, type UserID} from '@app/api/BrandedTypes';
import type {IChannelRepository} from '@app/api/channel/IChannelRepository';
import {NcmecRepository} from '@app/api/csam/NcmecRepository';
import type {IGuildRepositoryAggregate} from '@app/api/guild/repositories/IGuildRepositoryAggregate';
import type {IDiscriminatorService} from '@app/api/infrastructure/DiscriminatorService';
import type {EntityAssetService} from '@app/api/infrastructure/EntityAssetService';
@@ -112,6 +113,7 @@ export class AdminUserService {
billingRepository: getBillingRepository(),
oauth2Tokens: new OAuth2TokenRepository(),
storeEntitlementService: deps.storeEntitlementService,
ncmecRepository: new NcmecRepository(),
});
this.contactChangeLogService = contactChangeLog;
}
@@ -78,6 +78,7 @@ export class AdminGuildMembershipService {
reason: data.reason ?? undefined,
banDurationSeconds: data.ban_duration_seconds ?? undefined,
skipGuildAuditLog: true,
by: 'staff',
},
auditLogReason,
);
@@ -2,11 +2,12 @@
import {createTestAccount, setUserACLs} from '@app/api/auth/tests/AuthTestUtils';
import {type ApiTestHarness, createApiTestHarness} from '@app/api/test/ApiTestHarness';
import {NoopWorkerService} from '@app/api/test/NoopWorkerService';
import {HTTP_STATUS, TEST_CREDENTIALS} from '@app/api/test/TestConstants';
import {createBuilder, createBuilderWithoutAuth} from '@app/api/test/TestRequestBuilder';
import {AdminACLs} from '@fluxer/constants/src/AdminACLs';
import {UserFlags} from '@fluxer/constants/src/UserConstants';
import {afterAll, beforeAll, beforeEach, describe, expect, test} from 'vitest';
import {afterAll, afterEach, beforeAll, beforeEach, describe, expect, test, vi} from 'vitest';
interface ChangeLogResponse {
entries: Array<{
@@ -44,6 +45,9 @@ describe('Admin User Change Log and Flags', () => {
beforeEach(async () => {
await harness.reset();
});
afterEach(() => {
vi.restoreAllMocks();
});
afterAll(async () => {
await harness?.shutdown();
});
@@ -176,6 +180,37 @@ describe('Admin User Change Log and Flags', () => {
.execute();
});
});
describe('PATCH /admin/users/{user_id}/email', () => {
test('queues a Stripe customer email sync for users with a Stripe customer', async () => {
const admin = await createTestAccount(harness);
await setUserACLs(harness, admin, [AdminACLs.AUTHENTICATE, AdminACLs.WILDCARD]);
const target = await createTestAccount(harness);
await createBuilderWithoutAuth(harness)
.post(`/test/users/${target.userId}/premium`)
.body({stripe_customer_id: 'cus_admin_email_sync'})
.expect(HTTP_STATUS.OK)
.execute();
const addJob = vi.spyOn(NoopWorkerService.prototype, 'addJob');
await createBuilder(harness, `${admin.token}`)
.patch(`/admin/users/${target.userId}/email`)
.body({email: `admin-changed-${Date.now()}@example.com`})
.expect(HTTP_STATUS.OK)
.execute();
expect(addJob).toHaveBeenCalledWith('syncStripeCustomerEmail', {userId: target.userId});
});
test('does not queue a Stripe customer email sync for users without a Stripe customer', async () => {
const admin = await createTestAccount(harness);
await setUserACLs(harness, admin, [AdminACLs.AUTHENTICATE, AdminACLs.WILDCARD]);
const target = await createTestAccount(harness);
const addJob = vi.spyOn(NoopWorkerService.prototype, 'addJob');
await createBuilder(harness, `${admin.token}`)
.patch(`/admin/users/${target.userId}/email`)
.body({email: `admin-changed-${Date.now()}@example.com`})
.expect(HTTP_STATUS.OK)
.execute();
expect(addJob).not.toHaveBeenCalledWith('syncStripeCustomerEmail', expect.anything());
});
});
describe('PUT /admin/users/{user_id}/email-verification', () => {
test('verifying email clears email_bounced', async () => {
const admin = await createTestAccount(harness);
@@ -19,14 +19,20 @@ import {
type WebAuthnRegistrationOptions,
} from '@app/api/auth/tests/WebAuthnTestUtils';
import {createUserID} from '@app/api/BrandedTypes';
import {Config} from '@app/api/Config';
import {createFriendship} from '@app/api/channel/tests/ChannelTestUtils';
import {getAdminRepository, getUserRepository} from '@app/api/middleware/ServiceSingletons';
import {
getAdminRepository,
getInstanceConfigRepository,
getUserRepository,
} from '@app/api/middleware/ServiceSingletons';
import type {User} from '@app/api/models/User';
import {createBuilder} from '@app/api/test/TestRequestBuilder';
import {AccountIdentityModes} from '@fluxer/constants/src/AccountIdentityConstants';
import {AdminACLs} from '@fluxer/constants/src/AdminACLs';
import {DeletionReasons} from '@fluxer/constants/src/Core';
import {PremiumFlags, UserFlags} from '@fluxer/constants/src/UserConstants';
import {expect} from 'vitest';
import {expect, onTestFinished} from 'vitest';
async function loadUser(account: TestAccount): Promise<User> {
const user = await getUserRepository().findUnique(createUserID(BigInt(account.userId)));
@@ -271,6 +277,50 @@ export const UserWriteAdminAuditCases: ReadonlyArray<AdminAuditCoverageCase> = [
};
},
},
{
method: 'POST',
route: '/admin/users/:user_id/password-reset-link',
async prepare({harness}) {
const target = await createTestAccount(harness);
const originalSelfHosted = Config.instance.selfHosted;
onTestFinished(() => {
Config.instance.selfHosted = originalSelfHosted;
});
Config.instance.selfHosted = true;
await getInstanceConfigRepository().setAccountIdentityMode(AccountIdentityModes.USERNAME, 'setup');
return {
request: {path: `/admin/users/${target.userId}/password-reset-link`},
expected: {
action: 'create_password_reset_link',
targetType: 'user',
targetId: target.userId,
metadata: {},
},
};
},
},
{
method: 'DELETE',
route: '/admin/users/:user_id/recovery-kit',
async prepare({harness}) {
const target = await createTestAccount(harness);
const originalSelfHosted = Config.instance.selfHosted;
onTestFinished(() => {
Config.instance.selfHosted = originalSelfHosted;
});
Config.instance.selfHosted = true;
await getInstanceConfigRepository().setAccountIdentityMode(AccountIdentityModes.USERNAME, 'setup');
return {
request: {path: `/admin/users/${target.userId}/recovery-kit`, expectStatus: 204},
expected: {
action: 'revoke_recovery_kit',
targetType: 'user',
targetId: target.userId,
metadata: {},
},
};
},
},
{
method: 'PUT',
route: '/admin/users/:user_id/ban',
+69 -4
View File
@@ -2,6 +2,12 @@
import {requireSudoMode} from '@app/api/auth/services/SudoVerificationService';
import {Config} from '@app/api/Config';
import {
onUsernameInstance,
RequireEmailAccountIdentity,
RequireUsernameAccountIdentity,
RequireUsernameLookup,
} from '@app/api/middleware/AccountIdentityMiddleware';
import {DefaultUserOnly, LoginRequired} from '@app/api/middleware/AuthMiddleware';
import {CaptchaMiddleware} from '@app/api/middleware/CaptchaMiddleware';
import {LocalAuthMiddleware} from '@app/api/middleware/LocalAuthMiddleware';
@@ -33,6 +39,8 @@ import {
LogoutAuthSessionsWithVerificationRequest,
MfaTicketRequest,
MfaTotpRequest,
RecoverAccountRequest,
RecoverAccountResponse,
RegisterRequest,
ResetPasswordRequest,
ResetPasswordTokenParam,
@@ -41,6 +49,10 @@ import {
SsoStartRequest,
SsoStartResponse,
SsoStatusResponse,
UsernameAvailabilityQuery,
UsernameAvailabilityResponse,
UsernameInstanceLoginRequest,
UsernameInstanceRegisterRequest,
UsernameSuggestionsRequest,
UsernameSuggestionsResponse,
ValidateResetPasswordTokenResponse,
@@ -113,7 +125,7 @@ export function AuthController(app: HonoApp) {
LocalAuthMiddleware,
RateLimitMiddleware(RateLimitConfigs.AUTH_REGISTER),
CaptchaMiddleware,
Validator('json', RegisterRequest),
Validator('json', RegisterRequest, {schemaFor: onUsernameInstance(UsernameInstanceRegisterRequest)}),
OpenAPI({
operationId: 'register_account',
summary: 'Register account',
@@ -122,7 +134,7 @@ export function AuthController(app: HonoApp) {
security: [],
tags: ['Auth'],
description:
'Create a new user account with email and password. Requires a solved captcha challenge (X-Captcha-Token). User account is created but must verify email before logging in.',
'Create a new user account. Email instances take an email and password, and the account must verify its email before logging in. Username instances take a username and password, and an email sent by an older client is discarded. Requires a solved captcha challenge (X-Captcha-Token).',
}),
async (ctx) => {
const result = await ctx.get('authRequestService').register({
@@ -138,7 +150,7 @@ export function AuthController(app: HonoApp) {
LocalAuthMiddleware,
RateLimitMiddleware(RateLimitConfigs.AUTH_LOGIN),
CaptchaMiddleware,
Validator('json', LoginRequest),
Validator('json', LoginRequest, {schemaFor: onUsernameInstance(UsernameInstanceLoginRequest)}),
OpenAPI({
operationId: 'login_user',
summary: 'Login account',
@@ -147,13 +159,14 @@ export function AuthController(app: HonoApp) {
security: [],
tags: ['Auth'],
description:
'Authenticate with email and password. Returns authentication token if credentials are valid and MFA is not required. If MFA is enabled, returns a ticket for MFA verification. Requires a solved captcha challenge (X-Captcha-Token).',
'Authenticate with a password and either email (or login on email instances) or login (a username on username instances). Returns authentication token if credentials are valid and MFA is not required. If MFA is enabled, returns a ticket for MFA verification. Requires a solved captcha challenge (X-Captcha-Token).',
}),
async (ctx) => {
const result = await ctx.get('authRequestService').login({
data: ctx.req.valid('json'),
request: ctx.req.raw,
requestCache: ctx.get('requestCache'),
captchaVerified: ctx.get('captchaVerified') === true,
});
return ctx.json(result);
},
@@ -201,6 +214,7 @@ export function AuthController(app: HonoApp) {
app.post(
'/auth/verify',
LocalAuthMiddleware,
RequireEmailAccountIdentity,
RateLimitMiddleware(RateLimitConfigs.AUTH_VERIFY_EMAIL),
Validator('json', VerifyEmailRequest),
OpenAPI({
@@ -221,6 +235,7 @@ export function AuthController(app: HonoApp) {
app.post(
'/auth/verify/resend',
LocalAuthMiddleware,
RequireEmailAccountIdentity,
RateLimitMiddleware(RateLimitConfigs.AUTH_RESEND_VERIFICATION),
LoginRequired,
DefaultUserOnly,
@@ -242,6 +257,7 @@ export function AuthController(app: HonoApp) {
app.post(
'/auth/forgot',
LocalAuthMiddleware,
RequireEmailAccountIdentity,
RateLimitMiddleware(RateLimitConfigs.AUTH_FORGOT_PASSWORD),
CaptchaMiddleware,
Validator('json', ForgotPasswordRequest),
@@ -306,9 +322,35 @@ export function AuthController(app: HonoApp) {
return ctx.json(result);
},
);
app.post(
'/auth/recover',
LocalAuthMiddleware,
RateLimitMiddleware(RateLimitConfigs.AUTH_RECOVER_ACCOUNT),
RequireUsernameAccountIdentity,
CaptchaMiddleware,
Validator('json', RecoverAccountRequest),
OpenAPI({
operationId: 'recover_account',
summary: 'Recover account with recovery kit',
responseSchema: RecoverAccountResponse,
statusCode: 200,
security: [],
tags: ['Auth'],
description:
'Set a new password using the recovery key from a recovery kit. Only available on instances where people sign in with a username. Ends every session, replaces the recovery kit and returns the new recovery key. Returns an MFA ticket instead of a token when the account has two-factor authentication. Requires a solved captcha challenge (X-Captcha-Token).',
}),
async (ctx) => {
const result = await ctx.get('authRequestService').recoverAccount({
data: ctx.req.valid('json'),
request: ctx.req.raw,
});
return ctx.json(result);
},
);
app.post(
'/auth/email-revert',
LocalAuthMiddleware,
RequireEmailAccountIdentity,
RateLimitMiddleware(RateLimitConfigs.AUTH_EMAIL_REVERT),
Validator('json', EmailRevertRequest),
OpenAPI({
@@ -377,6 +419,7 @@ export function AuthController(app: HonoApp) {
app.post(
'/auth/authorize-ip',
LocalAuthMiddleware,
RequireEmailAccountIdentity,
RateLimitMiddleware(RateLimitConfigs.AUTH_AUTHORIZE_IP),
Validator('json', AuthorizeIpRequest),
OpenAPI({
@@ -397,6 +440,7 @@ export function AuthController(app: HonoApp) {
app.post(
'/auth/ip-authorization/resend',
LocalAuthMiddleware,
RequireEmailAccountIdentity,
RateLimitMiddleware(RateLimitConfigs.AUTH_IP_AUTHORIZATION_RESEND),
Validator('json', MfaTicketRequest),
OpenAPI({
@@ -541,6 +585,27 @@ export function AuthController(app: HonoApp) {
return ctx.json(response);
},
);
app.get(
'/auth/username-availability',
LocalAuthMiddleware,
RateLimitMiddleware(RateLimitConfigs.AUTH_USERNAME_AVAILABILITY),
RequireUsernameLookup,
Validator('query', UsernameAvailabilityQuery),
OpenAPI({
operationId: 'get_username_availability',
summary: 'Check username availability',
responseSchema: UsernameAvailabilityResponse,
statusCode: 200,
security: [],
tags: ['Auth'],
description:
'Check whether a username is free for a new account. Only available on instances where people sign in with a username or where usernames are unique. Usernames are compared without regard to case, and bots do not hold names. An invalid or reserved username returns a validation error.',
}),
async (ctx) => {
const {username} = ctx.req.valid('query');
return ctx.json(await ctx.get('authRequestService').getUsernameAvailability(username));
},
);
app.post(
'/auth/handoff/initiate',
RateLimitMiddleware(RateLimitConfigs.AUTH_HANDOFF_INITIATE),
+3 -1
View File
@@ -6,6 +6,7 @@ import * as AuthSession from '@app/api/auth/AuthSession';
import * as AuthUtility from '@app/api/auth/AuthUtility';
import {createEmailRevertToken} from '@app/api/BrandedTypes';
import type {User} from '@app/api/models/User';
import {enqueueStripeCustomerEmailSync} from '@app/api/stripe/StripeCustomer';
import {mapUserToPrivateResponse} from '@app/api/user/UserMappers';
import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes';
import {InputValidationError} from '@fluxer/errors/src/domains/core/InputValidationError';
@@ -44,7 +45,7 @@ export async function revertEmailChange(
user_id: string;
token: string;
}> {
const {users, gateway, contactChangeLog, config} = ctx.services;
const {users, gateway, contactChangeLog, config, worker} = ctx.services;
const {token, password, request} = params;
const tokenData = await users.getEmailRevertToken(token);
if (!tokenData) {
@@ -101,5 +102,6 @@ export async function revertEmailChange(
reason: 'user_requested',
actorUserId: user.id,
});
await enqueueStripeCustomerEmailSync(worker, user, updatedUser);
return {user_id: updatedUser.id.toString(), token: authToken};
}
+136 -33
View File
@@ -3,8 +3,10 @@
import type {ApiContext} from '@app/api/ApiContext';
import * as AuthMfa from '@app/api/auth/AuthMfa';
import * as AuthPassword from '@app/api/auth/AuthPassword';
import {applyPendingRecovery} from '@app/api/auth/AuthRecoveryKit';
import * as AuthSession from '@app/api/auth/AuthSession';
import * as AuthUtility from '@app/api/auth/AuthUtility';
import {getLocalPartAtInstance, usernameFromInstanceLocalPart} from '@app/api/auth/InstanceAddress';
import {resolveWebAuthnSecondFactor} from '@app/api/auth/services/WebAuthnSecondFactor';
import {
createInviteCode,
@@ -26,10 +28,12 @@ import {createRequestCache} from '@app/api/middleware/RequestCacheMiddleware';
import {getInstanceConfigRepository} from '@app/api/middleware/ServiceSingletons';
import type {AuthSession as AuthSessionModel} from '@app/api/models/AuthSession';
import type {User} from '@app/api/models/User';
import {findPersonByLoginHandle, type ParsedLoginHandle, parseLoginHandle} from '@app/api/user/UniqueUsernames';
import {lookupGeoip} from '@app/api/utils/IpUtils';
import {createRateLimitError} from '@app/api/utils/RateLimitUtils';
import {AccountIdentityModes} from '@fluxer/constants/src/AccountIdentityConstants';
import {UserAuthenticatorTypes, UserFlags} from '@fluxer/constants/src/UserConstants';
import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes';
import {type ValidationErrorCode, ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes';
import {IpAuthorizationRequiredError} from '@fluxer/errors/src/domains/auth/IpAuthorizationRequiredError';
import {IpAuthorizationResendCooldownError} from '@fluxer/errors/src/domains/auth/IpAuthorizationResendCooldownError';
import {IpAuthorizationResendLimitExceededError} from '@fluxer/errors/src/domains/auth/IpAuthorizationResendLimitExceededError';
@@ -41,6 +45,7 @@ import {UnknownUserError} from '@fluxer/errors/src/domains/user/UnknownUserError
import {requireClientIp} from '@fluxer/ip_utils/src/ClientIp';
import {getSameIpDecisionKey} from '@fluxer/ip_utils/src/IpAddress';
import type {LoginRequest} from '@fluxer/schema/src/domains/auth/AuthSchemas';
import {EmailType} from '@fluxer/schema/src/primitives/UserValidators';
import {formatGeoipLocation} from '@pkgs/geoip/src/GeoipLookup';
import type {AuthenticationResponseJSON} from '@simplewebauthn/server';
import {ms, seconds} from 'itty-time';
@@ -51,6 +56,7 @@ const DUMMY_ARGON2_HASH =
interface LoginParams {
data: LoginRequest;
request: Request;
captchaVerified?: boolean;
}
interface LoginMfaTotpParams {
@@ -87,6 +93,21 @@ export interface LoginMfaResult {
type LoginResult = LoginTokenResult | LoginMfaResult;
interface LoginIdentifierRateLimit {
identifier: string;
maxAttempts: number;
windowMs: number;
}
interface LoginIdentifier {
field: 'email' | 'login';
rateLimits: Array<LoginIdentifierRateLimit>;
sourceRateLimits: (sourceKey: string) => Array<LoginIdentifierRateLimit>;
failureRateLimit: LoginIdentifierRateLimit | null;
invalidCode: ValidationErrorCode;
lookup: () => Promise<User | null>;
}
export interface IpAuthorizationTicketCache {
userId: string;
email: string;
@@ -199,46 +220,66 @@ export async function completeIpAuthorization(
export async function login(
ctx: ApiContext,
deps: LoginDependencies,
{data, request}: LoginParams,
{data, request, captchaVerified = false}: LoginParams,
): Promise<LoginResult> {
const {users, cache, rateLimit, email, config} = ctx.services;
const {inviteService, kvDeletionQueue} = deps;
const skipRateLimits = config.dev.testModeEnabled || config.dev.disableRateLimits;
const emailRateLimit = await rateLimit.checkLimit({
identifier: `login:email:${data.email.toLowerCase()}`,
maxAttempts: 5,
windowMs: ms('15 minutes'),
});
if (!emailRateLimit.allowed && !skipRateLimits) {
throw createRateLimitError(emailRateLimit);
}
const identifier = await resolveLoginIdentifier(ctx, data);
const invalidCredentials = () =>
InputValidationError.fromCodes([
{path: identifier.field, code: identifier.invalidCode},
{path: 'password', code: identifier.invalidCode},
]);
const enforceRateLimits = async (limits: Array<LoginIdentifierRateLimit>) => {
for (const limit of limits) {
const result = await rateLimit.checkLimit(limit);
if (!result.allowed && !skipRateLimits) {
throw createRateLimitError(result);
}
}
};
await enforceRateLimits(identifier.rateLimits);
const clientIp = requireClientIp(request, {
trustClientIpHeader: config.proxy.trust_client_ip_header,
clientIpHeaderName: config.proxy.client_ip_header,
});
const ipRateLimit = await rateLimit.checkLimit({
identifier: `login:ip:${getSameIpDecisionKey(clientIp) ?? clientIp}`,
maxAttempts: 10,
windowMs: ms('30 minutes'),
});
if (!ipRateLimit.allowed && !skipRateLimits) {
throw createRateLimitError(ipRateLimit);
}
const user = await users.findByEmail(data.email);
const sourceKey = getSameIpDecisionKey(clientIp) ?? clientIp;
await enforceRateLimits([
...identifier.sourceRateLimits(sourceKey),
{identifier: `login:ip:${sourceKey}`, maxAttempts: 10, windowMs: ms('30 minutes')},
]);
const failureLimit = identifier.failureRateLimit;
const failureState = failureLimit ? await rateLimit.peekLimit(failureLimit) : null;
const failureLockout =
failureLimit && failureState !== null && failureState.remaining === 0 && !skipRateLimits
? {
...failureState,
allowed: false,
retryAfter: Math.ceil(failureLimit.windowMs / failureLimit.maxAttempts / 1000),
}
: null;
const rejectCredentials = async (): Promise<never> => {
if (failureLimit) {
await rateLimit.checkLimit(failureLimit);
}
if (failureLockout) {
throw createRateLimitError(failureLockout);
}
throw invalidCredentials();
};
const user = await identifier.lookup();
if (!user) {
throw InputValidationError.fromCodes([
{path: 'email', code: ValidationErrorCodes.INVALID_EMAIL_OR_PASSWORD},
{path: 'password', code: ValidationErrorCodes.INVALID_EMAIL_OR_PASSWORD},
]);
if (identifier.invalidCode === ValidationErrorCodes.INVALID_LOGIN_OR_PASSWORD) {
await AuthPassword.verifyPassword(ctx, {password: data.password, passwordHash: DUMMY_ARGON2_HASH});
}
return await rejectCredentials();
}
AuthUtility.assertNonBotUser(ctx, user);
if (!user.passwordHash) {
await AuthPassword.verifyPassword(ctx, {password: data.password, passwordHash: DUMMY_ARGON2_HASH});
emitLogin(user, false, {failure: 'no_password'});
throw InputValidationError.fromCodes([
{path: 'email', code: ValidationErrorCodes.INVALID_EMAIL_OR_PASSWORD},
{path: 'password', code: ValidationErrorCodes.INVALID_EMAIL_OR_PASSWORD},
]);
return await rejectCredentials();
}
const isMatch = await AuthPassword.verifyPassword(ctx, {
password: data.password,
@@ -246,10 +287,10 @@ export async function login(
});
if (!isMatch) {
emitLogin(user, false, {failure: 'bad_password'});
throw InputValidationError.fromCodes([
{path: 'email', code: ValidationErrorCodes.INVALID_EMAIL_OR_PASSWORD},
{path: 'password', code: ValidationErrorCodes.INVALID_EMAIL_OR_PASSWORD},
]);
return await rejectCredentials();
}
if (failureLockout && !captchaVerified && !(await users.checkIpAuthorized(user.id, clientIp))) {
throw createRateLimitError(failureLockout);
}
const currentUser = await AuthUtility.reactivateOnSignIn(
ctx,
@@ -346,6 +387,67 @@ export async function login(
};
}
function emailLoginRateLimits(emailAddress: string): Array<LoginIdentifierRateLimit> {
return [{identifier: `login:email:${emailAddress.toLowerCase()}`, maxAttempts: 5, windowMs: ms('15 minutes')}];
}
async function resolveLoginIdentifier(ctx: ApiContext, data: LoginRequest): Promise<LoginIdentifier> {
const {users} = ctx.services;
const mode = await getInstanceConfigRepository().getAccountIdentityMode();
if (mode === AccountIdentityModes.USERNAME) {
const field = data.email !== undefined ? 'email' : 'login';
const input = (field === 'email' ? data.email : data.login) ?? '';
const handle = field === 'email' ? parseOlderAppLoginHandle(ctx, input) : parseLoginHandle(input);
return {
field,
rateLimits: [],
sourceRateLimits: (sourceKey) => {
if (!handle) {
return [{identifier: `login:id-unparsed:${sourceKey}`, maxAttempts: 5, windowMs: ms('15 minutes')}];
}
const lowered = handle.username.toLowerCase();
return [{identifier: `login:id:${lowered}:${sourceKey}`, maxAttempts: 5, windowMs: ms('15 minutes')}];
},
failureRateLimit: handle
? {identifier: `login:id:${handle.username.toLowerCase()}`, maxAttempts: 100, windowMs: ms('1 hour')}
: null,
invalidCode: ValidationErrorCodes.INVALID_LOGIN_OR_PASSWORD,
lookup: async () => (handle ? await findPersonByLoginHandle(users, handle) : null),
};
}
if (data.email !== undefined) {
const emailAddress = data.email;
return {
field: 'email',
rateLimits: emailLoginRateLimits(emailAddress),
sourceRateLimits: () => [],
failureRateLimit: null,
invalidCode: ValidationErrorCodes.INVALID_EMAIL_OR_PASSWORD,
lookup: () => users.findByEmail(emailAddress),
};
}
const parsedEmail = EmailType.safeParse(data.login);
if (!parsedEmail.success) {
throw InputValidationError.fromCode('login', ValidationErrorCodes.INVALID_EMAIL_FORMAT);
}
const emailAddress = parsedEmail.data;
return {
field: 'login',
rateLimits: emailLoginRateLimits(emailAddress),
sourceRateLimits: () => [],
failureRateLimit: null,
invalidCode: ValidationErrorCodes.INVALID_EMAIL_OR_PASSWORD,
lookup: () => users.findByEmail(emailAddress),
};
}
function parseOlderAppLoginHandle(ctx: ApiContext, input: string): ParsedLoginHandle | null {
if (!input.includes('@')) return parseLoginHandle(input);
const localPart = getLocalPartAtInstance(ctx.services.config, input.trim());
const username = localPart === null ? null : usernameFromInstanceLocalPart(localPart);
return username === null ? null : parseLoginHandle(username);
}
const MFA_TICKET_MAX_ATTEMPTS = 5;
const MFA_USER_MAX_ATTEMPTS = 10;
@@ -420,11 +522,12 @@ export async function completeMfaLogin(
request: Request,
): Promise<[token: string, AuthSessionModel]> {
const {cache, rateLimit} = ctx.services;
const sessionUser = await applyPendingRecovery(ctx, user, ticket);
await cache.delete(`mfa-ticket:${ticket}`);
await rateLimit.resetLimit(`mfa:ticket:${ticket}`);
await rateLimit.resetLimit(`mfa:user:${user.id}`);
const session = await createLoginSession(ctx, user, request);
emitLogin(user, true, {mfa: true});
const session = await createLoginSession(ctx, sessionUser, request);
emitLogin(sessionUser, true, {mfa: true});
return session;
}
+65 -16
View File
@@ -5,15 +5,19 @@ import type {ApiContext} from '@app/api/ApiContext';
import {createMfaTicketResponse, type LoginMfaResult} from '@app/api/auth/AuthLogin';
import * as AuthSession from '@app/api/auth/AuthSession';
import * as AuthUtility from '@app/api/auth/AuthUtility';
import {RecoveryKitRepository} from '@app/api/auth/services/RecoveryKitRepository';
import {resolveWebAuthnSecondFactor} from '@app/api/auth/services/WebAuthnSecondFactor';
import {createPasswordResetToken} from '@app/api/BrandedTypes';
import {Config} from '@app/api/Config';
import type {UserRow} from '@app/api/database/types/UserTypes';
import {Logger} from '@app/api/Logger';
import {getInstanceConfigRepository} from '@app/api/middleware/ServiceSingletons';
import type {User} from '@app/api/models/User';
import {EXTERNAL_RESPONSE_LIMITS} from '@app/api/utils/ExternalResponseLimits';
import * as FetchUtils from '@app/api/utils/FetchUtils';
import {hashPassword as hashPasswordUtil, verifyPassword as verifyPasswordUtil} from '@app/api/utils/PasswordUtils';
import {createRateLimitError} from '@app/api/utils/RateLimitUtils';
import {AccountIdentityModes} from '@fluxer/constants/src/AccountIdentityConstants';
import {FLUXER_USER_AGENT} from '@fluxer/constants/src/Core';
import {UserAuthenticatorTypes, UserFlags} from '@fluxer/constants/src/UserConstants';
import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes';
@@ -83,6 +87,19 @@ interface ResetPasswordParams {
request: Request;
}
interface ApplyPasswordResetParams {
user: User;
password: string;
request: Request;
afterPasswordSet?: () => Promise<void>;
}
interface CommitPasswordResetParams {
user: User;
passwordHash: string;
webauthnIsSecondFactor: boolean;
}
interface VerifyPasswordParams {
password: string;
passwordHash: string;
@@ -218,6 +235,14 @@ export async function forgotPassword(ctx: ApiContext, {data, request}: ForgotPas
await email.sendPasswordResetEmail(user.email!, user.username, token, user.locale);
}
async function resetTokenMatchesUser(user: User, tokenEmail: string | null): Promise<boolean> {
if (tokenEmail === null) {
const mode = await getInstanceConfigRepository().getAccountIdentityMode();
return mode === AccountIdentityModes.USERNAME && !user.email;
}
return !!user.email && user.email.trim().toLowerCase() === tokenEmail.trim().toLowerCase();
}
export async function validateResetToken(ctx: ApiContext, token: string): Promise<boolean> {
const {users} = ctx.services;
const tokenData = await users.getPasswordResetToken(token);
@@ -228,11 +253,7 @@ export async function validateResetToken(ctx: ApiContext, token: string): Promis
if (!user) {
return false;
}
if (
user.flags & UserFlags.DELETED ||
!user.email ||
user.email.trim().toLowerCase() !== tokenData.email.trim().toLowerCase()
) {
if (user.flags & UserFlags.DELETED || !(await resetTokenMatchesUser(user, tokenData.email))) {
return false;
}
return true;
@@ -252,22 +273,39 @@ export async function resetPassword(
throw InputValidationError.fromCode('token', ValidationErrorCodes.INVALID_OR_EXPIRED_RESET_TOKEN);
}
AuthUtility.assertNonBotUser(ctx, user);
if (
user.flags & UserFlags.DELETED ||
!user.email ||
user.email.trim().toLowerCase() !== tokenData.email.trim().toLowerCase()
) {
if (user.flags & UserFlags.DELETED || !(await resetTokenMatchesUser(user, tokenData.email))) {
throw InputValidationError.fromCode('token', ValidationErrorCodes.INVALID_OR_EXPIRED_RESET_TOKEN);
}
await AuthUtility.handleBanStatus(ctx, user);
const currentUser = await AuthUtility.handleBanStatus(ctx, user);
if (await isPasswordPwned(ctx, data.password)) {
throw InputValidationError.fromCode('password', ValidationErrorCodes.PASSWORD_IS_TOO_COMMON);
}
const webauthnIsSecondFactor = await resolveWebAuthnSecondFactor(ctx, user);
const hasMfa = user.authenticatorTypes.has(UserAuthenticatorTypes.TOTP) || webauthnIsSecondFactor;
const newPasswordHash = await hashPassword(ctx, data.password);
if (tokenData.email !== null) {
return await applyPasswordReset(ctx, {
user,
password: data.password,
request,
afterPasswordSet: () => users.deleteAllPasswordResetTokens(user.id),
});
}
return await applyPasswordReset(ctx, {
user: currentUser,
password: data.password,
request,
afterPasswordSet: async () => {
await users.deleteAllPasswordResetTokens(currentUser.id);
await new RecoveryKitRepository().delete(currentUser.id);
},
});
}
export async function commitPasswordReset(
ctx: ApiContext,
{user, passwordHash, webauthnIsSecondFactor}: CommitPasswordResetParams,
): Promise<User> {
const {users} = ctx.services;
const updates: Partial<UserRow> = {
password_hash: newPasswordHash,
password_hash: passwordHash,
password_last_changed_at: new Date(),
};
if (webauthnIsSecondFactor && !user.authenticatorTypes.has(UserAuthenticatorTypes.WEBAUTHN)) {
@@ -280,7 +318,18 @@ export async function resetPassword(
await ctx.services.botMfaMirror.syncAuthenticatorTypesForOwner(updatedUser);
}
await AuthSession.terminateAllUserSessions(ctx, user.id);
await users.deleteAllPasswordResetTokens(user.id);
return updatedUser;
}
export async function applyPasswordReset(
ctx: ApiContext,
{user, password, request, afterPasswordSet}: ApplyPasswordResetParams,
): Promise<ResetPasswordResult> {
const webauthnIsSecondFactor = await resolveWebAuthnSecondFactor(ctx, user);
const hasMfa = user.authenticatorTypes.has(UserAuthenticatorTypes.TOTP) || webauthnIsSecondFactor;
const passwordHash = await hashPassword(ctx, password);
const updatedUser = await commitPasswordReset(ctx, {user, passwordHash, webauthnIsSecondFactor});
await afterPasswordSet?.();
if (hasMfa) {
return await createMfaTicketResponse(ctx, updatedUser, webauthnIsSecondFactor);
}
+271
View File
@@ -0,0 +1,271 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import crypto from 'node:crypto';
import type {ApiContext} from '@app/api/ApiContext';
import {createMfaTicketResponse} from '@app/api/auth/AuthLogin';
import * as AuthPassword from '@app/api/auth/AuthPassword';
import * as AuthUtility from '@app/api/auth/AuthUtility';
import {RecoveryKitRepository} from '@app/api/auth/services/RecoveryKitRepository';
import {resolveWebAuthnSecondFactor} from '@app/api/auth/services/WebAuthnSecondFactor';
import {createUserID, type UserID} from '@app/api/BrandedTypes';
import type {UserRecoveryKitRow} from '@app/api/database/types/AuthTypes';
import {usesUsernameSignIn} from '@app/api/instance/AccountIdentityModeCache';
import {
REGISTRATION_PENDING_APPROVAL_TRAIT,
REGISTRATION_REJECTED_TRAIT,
} from '@app/api/instance/InstanceConfigRepository';
import {Logger} from '@app/api/Logger';
import {getInstanceConfigRepository} from '@app/api/middleware/ServiceSingletons';
import type {User} from '@app/api/models/User';
import {findPersonByLoginHandle, parseLoginHandle} from '@app/api/user/UniqueUsernames';
import {createRateLimitError} from '@app/api/utils/RateLimitUtils';
import {AccountIdentityModes} from '@fluxer/constants/src/AccountIdentityConstants';
import {
generateRecoveryKey,
normalizeRecoveryKey,
RECOVERY_KEY_BYTE_LENGTH,
} from '@fluxer/constants/src/RecoveryKeyUtils';
import {UserAuthenticatorTypes} from '@fluxer/constants/src/UserConstants';
import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes';
import {RegistrationPendingApprovalError} from '@fluxer/errors/src/domains/auth/RegistrationPendingApprovalError';
import {RegistrationRejectedError} from '@fluxer/errors/src/domains/auth/RegistrationRejectedError';
import {InputValidationError} from '@fluxer/errors/src/domains/core/InputValidationError';
import {requireClientIp} from '@fluxer/ip_utils/src/ClientIp';
import {getSameIpDecisionKey} from '@fluxer/ip_utils/src/IpAddress';
import type {RecoverAccountRequest} from '@fluxer/schema/src/domains/auth/AuthSchemas';
import type {
RecoveryKitCreateResponse,
RecoveryKitStatusResponse,
} from '@fluxer/schema/src/domains/user/UserResponseSchemas';
import {ms, seconds} from 'itty-time';
const DUMMY_SECRET_HASH = crypto.createHash('sha256').update('fluxer-recovery-kit-dummy').digest('hex');
const recoveryKits = new RecoveryKitRepository();
const DUMMY_KIT_USER_ID = createUserID(0n);
interface RecoverAccountParams {
data: RecoverAccountRequest;
request: Request;
}
interface PendingRecovery {
userId: string;
expectedSecretHash: string;
secretHash: string;
createdAt: string;
passwordHash: string;
}
export interface RecoverAccountResult {
result: Awaited<ReturnType<typeof AuthPassword.applyPasswordReset>>;
recoveryKey: string;
createdAt: Date;
}
function hashRecoveryKey(normalizedKey: string): string {
return crypto.createHash('sha256').update(normalizedKey).digest('hex');
}
function secretHashesMatch(left: string, right: string): boolean {
const leftBuffer = Buffer.from(left, 'hex');
const rightBuffer = Buffer.from(right, 'hex');
return leftBuffer.length === rightBuffer.length && crypto.timingSafeEqual(leftBuffer, rightBuffer);
}
function issueRecoveryKey(): {formatted: string; secretHash: string; createdAt: Date} {
const {key, formatted} = generateRecoveryKey(new Uint8Array(crypto.randomBytes(RECOVERY_KEY_BYTE_LENGTH)));
return {formatted, secretHash: hashRecoveryKey(key), createdAt: new Date()};
}
function pendingRecoveryKey(ticket: string): string {
return `mfa-recovery:${ticket}`;
}
async function restoreRecoveryKit(userId: UserID, issuedSecretHash: string, kit: UserRecoveryKitRow): Promise<void> {
try {
await recoveryKits.replaceIfUnchanged({
userId,
expectedSecretHash: issuedSecretHash,
secretHash: kit.secret_hash,
createdAt: kit.created_at,
});
} catch (error) {
Logger.error({error, userId: userId.toString()}, 'Could not restore the recovery kit after a failed recovery');
}
}
function invalidRecoveryKeyError(): InputValidationError {
return InputValidationError.fromCode('recovery_key', ValidationErrorCodes.INVALID_RECOVERY_KEY);
}
async function checkRecoverRateLimits(ctx: ApiContext, username: string | null, request: Request): Promise<void> {
const {rateLimit, config} = ctx.services;
const clientIp = requireClientIp(request, {
trustClientIpHeader: config.proxy.trust_client_ip_header,
clientIpHeaderName: config.proxy.client_ip_header,
});
const sourceKey = getSameIpDecisionKey(clientIp) ?? clientIp;
const ipRateLimit = await rateLimit.checkLimit({
identifier: `recover:ip:${sourceKey}`,
maxAttempts: 10,
windowMs: ms('30 minutes'),
});
if (!ipRateLimit.allowed) {
throw createRateLimitError(ipRateLimit);
}
const identifierRateLimit = await rateLimit.checkLimit({
identifier:
username === null ? `recover:id-unparsed:${sourceKey}` : `recover:id:${username.toLowerCase()}:${sourceKey}`,
maxAttempts: 5,
windowMs: ms('30 minutes'),
});
if (!identifierRateLimit.allowed) {
throw createRateLimitError(identifierRateLimit);
}
}
export async function getRecoveryKitStatus(userId: UserID): Promise<RecoveryKitStatusResponse> {
const kit = await recoveryKits.find(userId);
return {
has_recovery_kit: kit !== null,
created_at: kit ? kit.created_at.toISOString() : null,
};
}
export async function createRecoveryKit(userId: UserID): Promise<RecoveryKitCreateResponse> {
const issued = issueRecoveryKey();
await recoveryKits.upsert({user_id: userId, secret_hash: issued.secretHash, created_at: issued.createdAt});
return {recovery_key: issued.formatted, created_at: issued.createdAt.toISOString()};
}
async function instanceUsesRecoveryKits(): Promise<boolean> {
return (await getInstanceConfigRepository().getAccountIdentityMode()) === AccountIdentityModes.USERNAME;
}
export async function deleteRecoveryKit(userId: UserID): Promise<void> {
if (!(await instanceUsesRecoveryKits())) {
return;
}
await recoveryKits.delete(userId);
}
export async function findRecoveryKitCreatedAt(userId: UserID): Promise<Date | null> {
if (!(await instanceUsesRecoveryKits())) {
return null;
}
return (await recoveryKits.find(userId))?.created_at ?? null;
}
export async function recoverAccount(
ctx: ApiContext,
{data, request}: RecoverAccountParams,
): Promise<RecoverAccountResult> {
const handle = parseLoginHandle(data.login);
await checkRecoverRateLimits(ctx, handle?.username ?? null, request);
const normalizedKey = normalizeRecoveryKey(data.recovery_key);
const providedHash = hashRecoveryKey(normalizedKey ?? data.recovery_key);
const user = handle ? await findPersonByLoginHandle(ctx.services.users, handle) : null;
const kit = await recoveryKits.find(user ? user.id : DUMMY_KIT_USER_ID);
const keyMatches = secretHashesMatch(providedHash, kit?.secret_hash ?? DUMMY_SECRET_HASH);
if (!user || !kit || normalizedKey === null || !keyMatches) {
throw invalidRecoveryKeyError();
}
const currentUser = await AuthUtility.handleBanStatus(ctx, user);
if (currentUser.traits.has(REGISTRATION_PENDING_APPROVAL_TRAIT)) {
throw new RegistrationPendingApprovalError();
}
if (currentUser.traits.has(REGISTRATION_REJECTED_TRAIT)) {
throw new RegistrationRejectedError();
}
if (await AuthPassword.isPasswordPwned(ctx, data.password)) {
throw InputValidationError.fromCode('password', ValidationErrorCodes.PASSWORD_IS_TOO_COMMON);
}
const issued = issueRecoveryKey();
const webauthnIsSecondFactor = await resolveWebAuthnSecondFactor(ctx, currentUser);
if (currentUser.authenticatorTypes.has(UserAuthenticatorTypes.TOTP) || webauthnIsSecondFactor) {
const pending: PendingRecovery = {
userId: currentUser.id.toString(),
expectedSecretHash: kit.secret_hash,
secretHash: issued.secretHash,
createdAt: issued.createdAt.toISOString(),
passwordHash: await AuthPassword.hashPassword(ctx, data.password),
};
const challenge = await createMfaTicketResponse(ctx, currentUser, webauthnIsSecondFactor);
try {
await ctx.services.cache.set<PendingRecovery>(
pendingRecoveryKey(challenge.ticket),
pending,
seconds('5 minutes'),
);
} catch (error) {
await ctx.services.cache.delete(`mfa-ticket:${challenge.ticket}`);
throw error;
}
return {result: challenge, recoveryKey: issued.formatted, createdAt: issued.createdAt};
}
const rotated = await recoveryKits.replaceIfUnchanged({
userId: currentUser.id,
expectedSecretHash: kit.secret_hash,
secretHash: issued.secretHash,
createdAt: issued.createdAt,
});
if (!rotated) {
throw invalidRecoveryKeyError();
}
let result: RecoverAccountResult['result'];
try {
result = await AuthPassword.applyPasswordReset(ctx, {
user: currentUser,
password: data.password,
request,
afterPasswordSet: () => ctx.services.users.deleteAllPasswordResetTokens(currentUser.id),
});
} catch (error) {
await restoreRecoveryKit(currentUser.id, issued.secretHash, kit);
throw error;
}
return {result, recoveryKey: issued.formatted, createdAt: issued.createdAt};
}
export async function applyPendingRecovery(ctx: ApiContext, user: User, ticket: string): Promise<User> {
if (!usesUsernameSignIn()) {
return user;
}
const {cache, users} = ctx.services;
const key = pendingRecoveryKey(ticket);
const pending = await cache.get<PendingRecovery>(key);
if (!pending) {
return user;
}
await cache.delete(key);
if (pending.userId !== user.id.toString()) {
throw invalidRecoveryKeyError();
}
const kit = await recoveryKits.find(user.id);
const rotated =
kit !== null &&
(await recoveryKits.replaceIfUnchanged({
userId: user.id,
expectedSecretHash: pending.expectedSecretHash,
secretHash: pending.secretHash,
createdAt: new Date(pending.createdAt),
}));
if (!kit || !rotated) {
await cache.delete(`mfa-ticket:${ticket}`);
throw invalidRecoveryKeyError();
}
let updatedUser: User;
try {
updatedUser = await AuthPassword.commitPasswordReset(ctx, {
user,
passwordHash: pending.passwordHash,
webauthnIsSecondFactor: await resolveWebAuthnSecondFactor(ctx, user),
});
} catch (error) {
await restoreRecoveryKit(user.id, pending.secretHash, kit);
throw error;
}
await users.deleteAllPasswordResetTokens(user.id);
return updatedUser;
}
+87 -5
View File
@@ -5,6 +5,11 @@ import * as AuthPassword from '@app/api/auth/AuthPassword';
import * as AuthSession from '@app/api/auth/AuthSession';
import * as AuthUtility from '@app/api/auth/AuthUtility';
import {assertEmailNotBlocklisted} from '@app/api/auth/EmailBlocklist';
import {
getLocalPartAtInstance,
getPrimaryInstanceHost,
usernameFromInstanceLocalPart,
} from '@app/api/auth/InstanceAddress';
import {createEmailVerificationToken, createInviteCode, createUserID, type UserID} from '@app/api/BrandedTypes';
import type {APIConfig} from '@app/api/config/APIConfig';
import type {UserRow} from '@app/api/database/types/UserTypes';
@@ -12,6 +17,7 @@ import {emitActivity} from '@app/api/infrastructure/activity/ActivityEvents';
import {isBlockedEmailDomain} from '@app/api/infrastructure/activity/SharedLists';
import type {IDiscriminatorService} from '@app/api/infrastructure/DiscriminatorService';
import type {KVActivityTracker} from '@app/api/infrastructure/KVActivityTracker';
import {withAccountIdentitySetupLock} from '@app/api/instance/AccountIdentitySetupLock';
import {
type InstanceConfigRepository,
type InstanceRegistrationUrl,
@@ -25,12 +31,21 @@ import {profileSubstringBlocklistCache} from '@app/api/middleware/ProfileSubstri
import type {RequestCache} from '@app/api/middleware/RequestCacheMiddleware';
import type {User} from '@app/api/models/User';
import {UserSettings} from '@app/api/models/UserSettings';
import {
deriveAvailableUsername,
isUsernameTaken,
reserveUsername,
type UsernameReservation,
} from '@app/api/user/UniqueUsernames';
import {USERNAME_MODE_DISCRIMINATOR} from '@app/api/user/UserTag';
import * as AgeUtils from '@app/api/utils/AgeUtils';
import {extractEmailDomain} from '@app/api/utils/EmailDomainUtils';
import {lookupGeoip} from '@app/api/utils/IpUtils';
import {createRateLimitError} from '@app/api/utils/RateLimitUtils';
import {generateRandomUsername} from '@app/api/utils/UsernameGenerator';
import {deriveUsernameFromDisplayName} from '@app/api/utils/UsernameSuggestionUtils';
import {inputValidationErrorFromZodIssues} from '@app/api/Validator';
import {AccountIdentityModes, TagStyles} from '@fluxer/constants/src/AccountIdentityConstants';
import {AdminACLs} from '@fluxer/constants/src/AdminACLs';
import {getRegionalMinimumAge} from '@fluxer/constants/src/RegionalMinimumAge';
import {ProfileFieldPrivacyFlags, UserFlags} from '@fluxer/constants/src/UserConstants';
@@ -42,6 +57,7 @@ import {InputValidationError} from '@fluxer/errors/src/domains/core/InputValidat
import {requireClientIp} from '@fluxer/ip_utils/src/ClientIp';
import {getSameIpDecisionKey, getSubnet} from '@fluxer/ip_utils/src/IpAddress';
import type {RegisterRequest} from '@fluxer/schema/src/domains/auth/AuthSchemas';
import {UsernameType} from '@fluxer/schema/src/primitives/UserValidators';
import {parseAcceptLanguage} from '@pkgs/locale/src/LocaleService';
import {types} from 'cassandra-driver';
import {ms} from 'itty-time';
@@ -92,6 +108,17 @@ function shouldRequireHostedLegalConsent(config: APIConfig): boolean {
}
export async function register(
ctx: ApiContext,
deps: RegistrationDependencies,
params: RegisterParams,
): Promise<RegisterResult> {
if (!ctx.services.config.instance.selfHosted || (await deps.instanceConfigRepository.isAccountIdentityLocked())) {
return await registerAccount(ctx, deps, params);
}
return await withAccountIdentitySetupLock(ctx.services.cache, () => registerAccount(ctx, deps, params));
}
async function registerAccount(
ctx: ApiContext,
deps: RegistrationDependencies,
{data, request, requestCache}: RegisterParams,
@@ -101,6 +128,14 @@ export async function register(
deps;
const appPublicConfig = await instanceConfigRepository.getAppPublicConfig();
const emailEnabled = await instanceConfigRepository.isEmailEnabled();
const accountIdentity = await instanceConfigRepository.getAccountIdentity();
const usernameMode = accountIdentity.mode === AccountIdentityModes.USERNAME;
const uniqueUsernames = accountIdentity.tagStyle === TagStyles.NONE;
let olderAppUsername: string | undefined;
if (usernameMode) {
assertUsernameModeRegistration(data);
olderAppUsername = data.username ? undefined : requestedUsernameFromOlderApp(config, data.email);
}
const requiresTermsConsent = shouldRequireHostedLegalConsent(config) || appPublicConfig.legal.terms_url !== null;
const requiresPrivacyConsent = shouldRequireHostedLegalConsent(config) || appPublicConfig.legal.privacy_url !== null;
if ((requiresTermsConsent || requiresPrivacyConsent) && !data.consent) {
@@ -132,7 +167,7 @@ export async function register(
if (data.password && (await AuthPassword.isPasswordPwned(ctx, data.password))) {
throw InputValidationError.fromCode('password', ValidationErrorCodes.PASSWORD_IS_TOO_COMMON);
}
const rawEmail = data.email ?? null;
const rawEmail = usernameMode ? null : (data.email ?? null);
const emailKey = rawEmail ? rawEmail.toLowerCase() : null;
const enforceRateLimits = !config.dev.relaxRegistrationRateLimits;
await enforceRegistrationRateLimits(ctx, {enforceRateLimits, clientIp, emailKey});
@@ -148,8 +183,21 @@ export async function register(
const emailTaken = await users.findByEmail(rawEmail);
if (emailTaken) throw InputValidationError.fromCode('email', ValidationErrorCodes.EMAIL_ALREADY_IN_USE);
}
let usernameCandidate: string | undefined = data.username ?? undefined;
let usernameCandidate: string | undefined = data.username ?? olderAppUsername;
let discriminator: number | null = null;
if (uniqueUsernames) {
discriminator = USERNAME_MODE_DISCRIMINATOR;
if (usernameCandidate) {
if (await isUsernameTaken(users, usernameCandidate)) {
throw InputValidationError.fromCode('username', ValidationErrorCodes.USERNAME_ALREADY_TAKEN);
}
} else {
usernameCandidate = await deriveAvailableUsername(
users,
deriveUsernameFromDisplayName(data.global_name ?? '') ?? generateRandomUsername(),
);
}
}
if (!usernameCandidate) {
const derivedUsername = deriveUsernameFromDisplayName(data.global_name ?? '');
if (derivedUsername) {
@@ -173,6 +221,8 @@ export async function register(
const grantBootstrapAdmin =
shouldAttemptBootstrapAdminGrant(config, {
rawEmail,
hasPassword: Boolean(data.password),
usernameMode,
pendingApproval: registrationAccess.pendingApproval,
setupConfigured: appPublicConfig.setup.configured,
}) && !(await instanceConfigRepository.isAdminBootstrapped());
@@ -253,7 +303,11 @@ export async function register(
);
let user: User;
let createAttempted = false;
let usernameReservation: UsernameReservation | null = null;
try {
if (uniqueUsernames) {
usernameReservation = await reserveUsername({users, cache: ctx.services.cache}, username);
}
if (registrationAccess.pendingApproval) {
await instanceConfigRepository.addPendingRegistration({
user_id: userId.toString(),
@@ -277,6 +331,8 @@ export async function register(
});
}
throw error;
} finally {
await usernameReservation?.release();
}
await users.upsertSettings(
UserSettings.getDefaultUserSettings({
@@ -338,19 +394,45 @@ function shouldAttemptBootstrapAdminGrant(
config: APIConfig,
params: {
rawEmail: string | null;
hasPassword: boolean;
usernameMode: boolean;
pendingApproval: boolean;
setupConfigured: boolean;
},
): boolean {
const localDevInstance = config.nodeEnv === 'development' && !config.dev.testModeEnabled;
const setupBootstrapOpen = !params.setupConfigured;
const claimedAccount = params.usernameMode ? params.hasPassword : params.rawEmail !== null;
return (
(config.instance.selfHosted || localDevInstance || setupBootstrapOpen) &&
params.rawEmail !== null &&
!params.pendingApproval
(config.instance.selfHosted || localDevInstance || setupBootstrapOpen) && claimedAccount && !params.pendingApproval
);
}
function assertUsernameModeRegistration(data: RegisterRequest): void {
if (data.password && !data.username && data.email == null) {
throw InputValidationError.fromCode('username', ValidationErrorCodes.USERNAME_LENGTH_INVALID);
}
}
function requestedUsernameFromOlderApp(config: APIConfig, email: string | null | undefined): string | undefined {
if (email == null) return undefined;
const localPart = getLocalPartAtInstance(config, email.trim());
if (localPart === null) {
throw InputValidationError.fromCode('email', ValidationErrorCodes.INSTANCE_ADDRESS_REQUIRED, {
host: getPrimaryInstanceHost(config),
});
}
const candidate = usernameFromInstanceLocalPart(localPart);
if (candidate === null) {
throw InputValidationError.fromCode('username', ValidationErrorCodes.USERNAME_INVALID_CHARACTERS);
}
const parsed = UsernameType.safeParse(candidate);
if (!parsed.success) {
throw inputValidationErrorFromZodIssues(parsed.error.issues.map((issue) => ({...issue, path: ['username']})));
}
return parsed.data;
}
async function claimRegistrationUrlUse(
instanceConfigRepository: InstanceConfigRepository,
registrationUrl: InstanceRegistrationUrl | null,
+31 -2
View File
@@ -6,6 +6,7 @@ import * as AuthEmailRevert from '@app/api/auth/AuthEmailRevert';
import * as AuthLogin from '@app/api/auth/AuthLogin';
import * as AuthMfa from '@app/api/auth/AuthMfa';
import * as AuthPassword from '@app/api/auth/AuthPassword';
import * as AuthRecoveryKit from '@app/api/auth/AuthRecoveryKit';
import * as AuthRegistration from '@app/api/auth/AuthRegistration';
import * as AuthSession from '@app/api/auth/AuthSession';
import {getTokenIdHash} from '@app/api/auth/AuthUtility';
@@ -21,6 +22,7 @@ import {
encodePushSessionIdHash,
recordPushSessionPredecessor,
} from '@app/api/user/services/WebPushOriginReplacement';
import {isUsernameTaken} from '@app/api/user/UniqueUsernames';
import {mapUserToPartialResponse} from '@app/api/user/UserMappers';
import {lookupGeoip} from '@app/api/utils/IpUtils';
import {parseJsonRecord} from '@app/api/utils/JsonBoundaryUtils';
@@ -46,10 +48,13 @@ import type {
LoginRequest,
LogoutAuthSessionsRequest,
MfaTicketRequest,
RecoverAccountRequest,
RecoverAccountResponse,
RegisterRequest,
ResetPasswordRequest,
SsoCompleteRequest,
SsoStartRequest,
UsernameAvailabilityResponse,
UsernameSuggestionsResponse,
VerifyEmailRequest,
WebAuthnAuthenticateRequest,
@@ -67,6 +72,7 @@ interface AuthLoginRequest {
data: LoginRequest;
request: Request;
requestCache: RequestCache;
captchaVerified?: boolean;
}
interface AuthForgotPasswordRequest {
@@ -79,6 +85,11 @@ interface AuthResetPasswordRequest {
request: Request;
}
interface AuthRecoverAccountRequest {
data: RecoverAccountRequest;
request: Request;
}
interface AuthRevertEmailChangeRequest {
data: EmailRevertRequest;
request: Request;
@@ -186,8 +197,13 @@ export class AuthRequestService {
return await this.toAuthLoginResponse(result);
}
async login({data, request, requestCache: _requestCache}: AuthLoginRequest): Promise<AuthLoginResponse> {
const result = await AuthLogin.login(this.apiContext, this.loginDependencies, {data, request});
async login({
data,
request,
requestCache: _requestCache,
captchaVerified,
}: AuthLoginRequest): Promise<AuthLoginResponse> {
const result = await AuthLogin.login(this.apiContext, this.loginDependencies, {data, request, captchaVerified});
return await this.toAuthLoginResponse(result);
}
@@ -229,6 +245,15 @@ export class AuthRequestService {
return await this.toAuthLoginResponse(result);
}
async recoverAccount({data, request}: AuthRecoverAccountRequest): Promise<RecoverAccountResponse> {
const {result, recoveryKey, createdAt} = await AuthRecoveryKit.recoverAccount(this.apiContext, {data, request});
return {
...(await this.toAuthLoginResponse(result)),
recovery_key: recoveryKey,
recovery_kit_created_at: createdAt.toISOString(),
};
}
async revertEmailChange({data, request}: AuthRevertEmailChangeRequest): Promise<AuthLoginResponse> {
const result = await AuthEmailRevert.revertEmailChange(this.apiContext, {
token: data.token,
@@ -310,6 +335,10 @@ export class AuthRequestService {
return {suggestions: generateUsernameSuggestions(globalName)};
}
async getUsernameAvailability(username: string): Promise<UsernameAvailabilityResponse> {
return {available: !(await isUsernameTaken(this.apiContext.services.users, username))};
}
async initiateHandoff({request}: AuthHandoffInitiateRequest): Promise<HandoffInitiateResponse> {
const origin = AuthSession.resolveSessionOrigin(this.apiContext, request);
const result = await this.desktopHandoffService.initiateHandoff({
@@ -1,5 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {usesUsernameSignIn} from '@app/api/instance/AccountIdentityModeCache';
import {
DirectMessageEmailVerificationRequiredError,
EmailVerificationRequiredError,
@@ -37,7 +38,7 @@ export function requireEmailVerified(
user: {emailVerified: boolean; isBot?: boolean},
reason?: EmailVerificationRequiredReason,
): void {
if (user.isBot) {
if (user.isBot || usesUsernameSignIn()) {
return;
}
if (!user.emailVerified) {
@@ -0,0 +1,53 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {APIConfig} from '@app/api/config/APIConfig';
import {parseLoginHandle} from '@app/api/user/UniqueUsernames';
import {USERNAME_MODE_DISCRIMINATOR} from '@app/api/user/UserTag';
import {normaliseUsernameCandidate} from '@app/api/utils/UsernameSuggestionUtils';
const ADDRESS_HOST_REGEX = /^[^\s/?#@\\]+$/u;
function normaliseHost(host: string): string | null {
const trimmed = host.trim();
if (!ADDRESS_HOST_REGEX.test(trimmed)) return null;
const hostname = URL.parse(`http://${trimmed}`)?.hostname.replace(/\.$/u, '');
return hostname ? hostname : null;
}
function hostOfUrl(url: string): string | null {
return URL.parse(url)?.host ?? null;
}
function getInstanceHosts(config: APIConfig): Set<string> {
const hosts = new Set<string>();
for (const host of [
hostOfUrl(config.endpoints.webApp),
...config.endpoints.webAppOrigins.map(hostOfUrl),
config.instance.baseDomain,
]) {
const normalised = host === null ? null : normaliseHost(host);
if (normalised !== null) hosts.add(normalised);
}
return hosts;
}
export function getPrimaryInstanceHost(config: APIConfig): string {
return URL.parse(config.endpoints.webApp)?.hostname ?? config.instance.baseDomain;
}
export function getLocalPartAtInstance(config: APIConfig, address: string): string | null {
const at = address.indexOf('@');
if (at <= 0 || address.lastIndexOf('@') !== at) return null;
const host = normaliseHost(address.slice(at + 1));
return host !== null && getInstanceHosts(config).has(host) ? address.slice(0, at) : null;
}
export function usernameFromInstanceLocalPart(localPart: string): string | null {
const handle = parseLoginHandle(localPart);
if (handle) {
return handle.discriminator === null || handle.discriminator === USERNAME_MODE_DISCRIMINATOR
? handle.username
: null;
}
return normaliseUsernameCandidate(localPart) || null;
}
@@ -0,0 +1,41 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {UserID} from '@app/api/BrandedTypes';
import {deleteOneOrMany, executeConditional, fetchOne, upsertOne} from '@app/api/database/CassandraQueryExecution';
import {Db} from '@app/api/database/CassandraTypes';
import type {UserRecoveryKitRow} from '@app/api/database/types/AuthTypes';
import {UserRecoveryKits} from '@app/api/Tables';
const FIND_RECOVERY_KIT_QUERY = UserRecoveryKits.select({
where: UserRecoveryKits.where.eq('user_id'),
limit: 1,
});
export class RecoveryKitRepository {
async find(userId: UserID): Promise<UserRecoveryKitRow | null> {
return await fetchOne<UserRecoveryKitRow>(FIND_RECOVERY_KIT_QUERY.bind({user_id: userId}));
}
async upsert(row: UserRecoveryKitRow): Promise<void> {
await upsertOne(UserRecoveryKits.upsertAll(row));
}
async replaceIfUnchanged(params: {
userId: UserID;
expectedSecretHash: string;
secretHash: string;
createdAt: Date;
}): Promise<boolean> {
return await executeConditional(
UserRecoveryKits.conditionalPatchByPk(
{user_id: params.userId},
{secret_hash: Db.set(params.secretHash), created_at: Db.set(params.createdAt)},
{secret_hash: params.expectedSecretHash},
),
);
}
async delete(userId: UserID): Promise<void> {
await deleteOneOrMany(UserRecoveryKits.deleteByPk({user_id: userId}));
}
}
+32 -10
View File
@@ -15,6 +15,7 @@ import type {ILogger} from '@app/api/ILogger';
import {emitActivity} from '@app/api/infrastructure/activity/ActivityEvents';
import type {IDiscriminatorService} from '@app/api/infrastructure/DiscriminatorService';
import type {KVActivityTracker} from '@app/api/infrastructure/KVActivityTracker';
import {usesUsernameSignIn} from '@app/api/instance/AccountIdentityModeCache';
import {
type InstanceConfigRepository,
type InstanceSsoConfig,
@@ -32,11 +33,14 @@ import {profileSubstringBlocklistCache} from '@app/api/middleware/ProfileSubstri
import type {RequestCache} from '@app/api/middleware/RequestCacheMiddleware';
import type {User} from '@app/api/models/User';
import {UserSettings} from '@app/api/models/UserSettings';
import {deriveAvailableUsername, reserveUsername, type UsernameReservation} from '@app/api/user/UniqueUsernames';
import {USERNAME_MODE_DISCRIMINATOR} from '@app/api/user/UserTag';
import {EXTERNAL_RESPONSE_LIMITS} from '@app/api/utils/ExternalResponseLimits';
import * as FetchUtils from '@app/api/utils/FetchUtils';
import {isJsonRecord, parseJsonRecord, parseJsonWithGuard} from '@app/api/utils/JsonBoundaryUtils';
import {generateRandomUsername} from '@app/api/utils/UsernameGenerator';
import {deriveUsernameFromDisplayName} from '@app/api/utils/UsernameSuggestionUtils';
import {AccountIdentityModes, TagStyles} from '@fluxer/constants/src/AccountIdentityConstants';
import {SSO_MOBILE_CALLBACK_URI, SSO_MOBILE_STATE_PREFIX} from '@fluxer/constants/src/SsoConstants';
import {ProfileFieldPrivacyFlags} from '@fluxer/constants/src/UserConstants';
import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes';
@@ -374,7 +378,10 @@ export class SsoService {
throw InputValidationError.fromCode('email_verified', ValidationErrorCodes.INVALID_SSO_TOKEN);
}
const emailLower = claims.email.toLowerCase();
getLogger().info({email: emailLower, has_sub: true}, 'SSO login with sub claim');
getLogger().info(
usesUsernameSignIn() ? {has_sub: true} : {email: emailLower, has_sub: true},
'SSO login with sub claim',
);
const identityUserId = await this.ssoIdentityRepository.findUserId(config.providerId, claims.sub);
if (identityUserId) {
const user = await this.apiContext.services.users.findUnique(identityUserId);
@@ -450,6 +457,14 @@ export class SsoService {
return users.patchUpsert(user.id, {traits}, user.toRow());
}
private async allocateDiscriminator(username: string): Promise<number> {
const result = await this.discriminatorService.generateDiscriminator({username});
if (!result.available) {
throw InputValidationError.fromCode('username', ValidationErrorCodes.SSO_UNABLE_TO_ALLOCATE_DISCRIMINATOR);
}
return result.discriminator;
}
private async provisionUserFromClaims(
claims: ResolvedSsoClaims,
config: ResolvedSsoConfig,
@@ -457,14 +472,15 @@ export class SsoService {
pendingApproval?: boolean;
},
): Promise<User> {
const {users, snowflake} = this.apiContext.services;
const {users, snowflake, cache} = this.apiContext.services;
const accountIdentity = await this.instanceConfigRepository.getAccountIdentity();
const usernameMode = accountIdentity.mode === AccountIdentityModes.USERNAME;
const uniqueUsernames = accountIdentity.tagStyle === TagStyles.NONE;
const userId = (await snowflake.generate()) as UserID;
const baseName = claims.name?.trim() || claims.email.split('@')[0] || generateRandomUsername();
const username = deriveUsernameFromDisplayName(baseName) ?? generateRandomUsername();
const discriminatorResult = await this.discriminatorService.generateDiscriminator({username});
if (!discriminatorResult.available) {
throw InputValidationError.fromCode('username', ValidationErrorCodes.SSO_UNABLE_TO_ALLOCATE_DISCRIMINATOR);
}
const derivedUsername = deriveUsernameFromDisplayName(baseName) ?? generateRandomUsername();
const username = uniqueUsernames ? await deriveAvailableUsername(users, derivedUsername) : derivedUsername;
const discriminator = uniqueUsernames ? USERNAME_MODE_DISCRIMINATOR : await this.allocateDiscriminator(username);
const now = new Date();
const traits = new Set<string>([
'sso',
@@ -485,11 +501,11 @@ export class SsoService {
const userRow = {
user_id: userId,
username,
discriminator: discriminatorResult.discriminator,
discriminator,
global_name: globalName,
bot: false,
system: false,
email: claims.email.toLowerCase(),
email: usernameMode ? null : claims.email.toLowerCase(),
email_verified: claims.emailVerified,
email_bounced: false,
password_hash: null,
@@ -544,12 +560,16 @@ export class SsoService {
await this.claimSsoIdentity(userId, claims.sub, config);
let createAttempted = false;
let userCreated = false;
let usernameReservation: UsernameReservation | null = null;
try {
if (uniqueUsernames) {
usernameReservation = await reserveUsername({users, cache}, username);
}
if (options?.pendingApproval) {
await this.instanceConfigRepository.addPendingRegistration({
user_id: userId.toString(),
username,
discriminator: discriminatorResult.discriminator,
discriminator,
global_name: globalName,
email: userRow.email,
requested_at: now.toISOString(),
@@ -606,6 +626,8 @@ export class SsoService {
}
}
throw error;
} finally {
await usernameReservation?.release();
}
}
@@ -9,8 +9,9 @@ import {
type TestAccount,
} from '@app/api/auth/tests/AuthTestUtils';
import type {ApiTestHarness} from '@app/api/test/ApiTestHarness';
import {NoopWorkerService} from '@app/api/test/NoopWorkerService';
import {createBuilder, createBuilderWithoutAuth} from '@app/api/test/TestRequestBuilder';
import {afterAll, beforeAll, beforeEach, describe, expect, it} from 'vitest';
import {afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi} from 'vitest';
interface BouncedEmailRequestNewResponse {
ticket: string;
@@ -51,12 +52,21 @@ describe('Bounced email recovery flow', () => {
await harness.reset();
await clearTestEmails(harness);
});
afterEach(() => {
vi.restoreAllMocks();
});
afterAll(async () => {
await harness?.shutdown();
});
it('allows bounced users to replace email without original-email verification', async () => {
const account = await createTestAccount(harness);
await markEmailAsBounced(harness, account);
await createBuilderWithoutAuth(harness)
.post(`/test/users/${account.userId}/premium`)
.body({stripe_customer_id: 'cus_bounced_email_sync'})
.expect(200)
.execute();
const addJob = vi.spyOn(NoopWorkerService.prototype, 'addJob');
const initialMe = await createBuilder<UserPrivateResponse>(harness, account.token)
.get('/users/@me')
.expect(200)
@@ -94,6 +104,7 @@ describe('Bounced email recovery flow', () => {
const finalMe = await createBuilder<UserPrivateResponse>(harness, account.token).get('/users/@me').execute();
expect(finalMe.email).toBe(replacementEmail);
expect(finalMe.email_bounced).toBe(false);
expect(addJob).toHaveBeenCalledWith('syncStripeCustomerEmail', {userId: account.userId});
});
it('rejects bounced-email recovery for accounts that are not marked as bounced', async () => {
const account = await createTestAccount(harness);
@@ -6,10 +6,13 @@ import {
createUniqueUsername,
registerUser,
} from '@app/api/auth/tests/AuthTestUtils';
import {getConfig} from '@app/api/Config';
import {getInstanceConfigRepository} from '@app/api/middleware/ServiceSingletons';
import type {ApiTestHarness} from '@app/api/test/ApiTestHarness';
import {CAPTCHA_TEST_HEADER, useCheapCaptcha} from '@app/api/test/CaptchaTestUtils';
import {HTTP_STATUS} from '@app/api/test/TestConstants';
import {createBuilderWithoutAuth} from '@app/api/test/TestRequestBuilder';
import {AccountIdentityModes} from '@fluxer/constants/src/AccountIdentityConstants';
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
import {afterAll, beforeAll, beforeEach, describe, expect, it} from 'vitest';
@@ -67,4 +70,21 @@ describe('Auth Captcha Bypass Flags', () => {
.expect(HTTP_STATUS.BAD_REQUEST, APIErrorCodes.CAPTCHA_REQUIRED)
.execute();
});
it('never looks up the email field for an exemption on a username instance', async () => {
const account = await registerAndFlag(harness, ['APP_STORE_REVIEWER']);
const config = getConfig();
const originalSelfHosted = config.instance.selfHosted;
config.instance.selfHosted = true;
try {
await getInstanceConfigRepository().setAccountIdentityMode(AccountIdentityModes.USERNAME, 'setup');
await createBuilderWithoutAuth(harness)
.post('/auth/login')
.header(CAPTCHA_TEST_HEADER, 'true')
.body({email: account.email, password: account.password})
.expect(HTTP_STATUS.BAD_REQUEST, APIErrorCodes.CAPTCHA_REQUIRED)
.execute();
} finally {
config.instance.selfHosted = originalSelfHosted;
}
});
});
@@ -12,8 +12,9 @@ import {
type TestAccount,
} from '@app/api/auth/tests/AuthTestUtils';
import type {ApiTestHarness} from '@app/api/test/ApiTestHarness';
import {NoopWorkerService} from '@app/api/test/NoopWorkerService';
import {createBuilder, createBuilderWithoutAuth} from '@app/api/test/TestRequestBuilder';
import {afterAll, beforeAll, beforeEach, describe, expect, it} from 'vitest';
import {afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi} from 'vitest';
interface EmailChangeStartResponse {
ticket: string;
@@ -129,6 +130,9 @@ describe('Email change flow', () => {
await harness.reset();
await clearTestEmails(harness);
});
afterEach(() => {
vi.restoreAllMocks();
});
afterAll(async () => {
await harness?.shutdown();
});
@@ -341,13 +345,14 @@ describe('Email change flow', () => {
.execute();
expect(updated.email).toBe(newEmail);
});
it('applies email changes for users who have ever purchased', async () => {
it('applies email changes for users who have ever purchased and syncs their Stripe customer', async () => {
const account = await createTestAccount(harness);
await createBuilderWithoutAuth(harness)
.post(`/test/users/${account.userId}/premium`)
.body({has_ever_purchased: true})
.body({has_ever_purchased: true, stripe_customer_id: 'cus_email_change_sync'})
.expect(200)
.execute();
const addJob = vi.spyOn(NoopWorkerService.prototype, 'addJob');
const startResp = await startEmailChange(harness, account, account.password);
let originalProof: string;
if (startResp.require_original) {
@@ -387,9 +392,11 @@ describe('Email change flow', () => {
.execute();
expect(updated.email).toBe(newEmail);
expect(updated.has_ever_purchased).toBe(true);
expect(addJob).toHaveBeenCalledWith('syncStripeCustomerEmail', {userId: account.userId});
});
it('applies ordinary claimed email changes', async () => {
const account = await createTestAccount(harness);
const addJob = vi.spyOn(NoopWorkerService.prototype, 'addJob');
const startResp = await startEmailChange(harness, account, account.password);
const emails = await listTestEmails(harness, {recipient: account.email});
const originalEmail = findLastTestEmail(emails, 'email_change_original');
@@ -425,6 +432,7 @@ describe('Email change flow', () => {
.execute();
expect(updated.email).toBe(newEmail);
expect(updated.verified).toBe(true);
expect(addJob).not.toHaveBeenCalledWith('syncStripeCustomerEmail', expect.anything());
});
it('requires MFA (not password) for email_token apply when user has TOTP enabled', async () => {
const account = await createTestAccount(harness);
@@ -10,8 +10,9 @@ import {
type TestAccount,
} from '@app/api/auth/tests/AuthTestUtils';
import type {ApiTestHarness} from '@app/api/test/ApiTestHarness';
import {NoopWorkerService} from '@app/api/test/NoopWorkerService';
import {createBuilder, createBuilderWithoutAuth} from '@app/api/test/TestRequestBuilder';
import {afterAll, beforeAll, beforeEach, describe, expect, it} from 'vitest';
import {afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi} from 'vitest';
interface EmailChangeStartResponse {
ticket: string;
@@ -130,11 +131,20 @@ describe('Email revert flow', () => {
await harness.reset();
await clearTestEmails(harness);
});
afterEach(() => {
vi.restoreAllMocks();
});
afterAll(async () => {
await harness?.shutdown();
});
it('restores original email and clears mfa', async () => {
const account = await createTestAccount(harness);
await createBuilderWithoutAuth(harness)
.post(`/test/users/${account.userId}/premium`)
.body({stripe_customer_id: 'cus_email_revert_sync'})
.expect(200)
.execute();
const addJob = vi.spyOn(NoopWorkerService.prototype, 'addJob');
const startResp = await startEmailChange(harness, account, account.password);
let originalProof: string;
if (startResp.require_original) {
@@ -178,6 +188,7 @@ describe('Email revert flow', () => {
expect(revertEmail?.metadata?.token).toBeDefined();
const revertToken = revertEmail!.metadata!.token!;
const newPassword = uniquePassword();
addJob.mockClear();
const revertResp = await createBuilderWithoutAuth<EmailRevertResponse>(harness)
.post('/auth/email-revert')
.body({
@@ -186,6 +197,7 @@ describe('Email revert flow', () => {
})
.execute();
expect(revertResp.token.length).toBeGreaterThan(0);
expect(addJob).toHaveBeenCalledWith('syncStripeCustomerEmail', {userId: account.userId});
await createBuilder(harness, account.token).get('/users/@me').expect(401).execute();
const user = await createBuilder<UserPrivateResponse>(harness, revertResp.token).get('/users/@me').execute();
expect(user.email).toBe(account.email);
@@ -0,0 +1,691 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import crypto from 'node:crypto';
import {resetPwnedPasswordCacheForTesting} from '@app/api/auth/AuthPassword';
import {findRecoveryKitCreatedAt} from '@app/api/auth/AuthRecoveryKit';
import {RecoveryKitRepository} from '@app/api/auth/services/RecoveryKitRepository';
import {
createAuthHarness,
createTestAccount,
createUniqueUsername,
type TestAccount,
totpCodeNow,
} from '@app/api/auth/tests/AuthTestUtils';
import {createUserID} from '@app/api/BrandedTypes';
import {getConfig} from '@app/api/Config';
import {
REGISTRATION_PENDING_APPROVAL_TRAIT,
REGISTRATION_REJECTED_TRAIT,
} from '@app/api/instance/InstanceConfigRepository';
import {
getAdminRepository,
getInstanceConfigRepository,
getUserRepository,
} from '@app/api/middleware/ServiceSingletons';
import type {ApiTestHarness} from '@app/api/test/ApiTestHarness';
import {server} from '@app/api/test/msw/server';
import {createBuilder, createBuilderWithoutAuth} from '@app/api/test/TestRequestBuilder';
import {AccountIdentityModes} from '@fluxer/constants/src/AccountIdentityConstants';
import {AdminACLs} from '@fluxer/constants/src/AdminACLs';
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
import {UserFlags} from '@fluxer/constants/src/UserConstants';
import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes';
import {HttpResponse, http} from 'msw';
import {afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi} from 'vitest';
const NEW_PASSWORD = 'recovered-strong-password-123';
const RECOVERY_KEY_PATTERN = /^[0-9A-HJKMNP-TV-Z]{4}(-[0-9A-HJKMNP-TV-Z]{4}){7}$/;
interface ValidationErrorBody {
code: string;
errors: Array<{path: string; code: string}>;
}
interface RecoveryKitStatus {
has_recovery_kit: boolean;
created_at: string | null;
}
interface RecoveryKitCreated {
recovery_key: string;
created_at: string;
}
interface RecoverTokenResponse {
token: string;
user_id: string;
recovery_key: string;
recovery_kit_created_at: string;
}
interface RecoverMfaResponse {
mfa: true;
ticket: string;
totp: boolean;
recovery_key: string;
recovery_kit_created_at: string;
}
describe('Recovery kit', () => {
let harness: ApiTestHarness;
let originalSelfHosted: boolean;
let originalBreachedPasswordCheck: boolean;
beforeAll(async () => {
harness = await createAuthHarness();
});
beforeEach(async () => {
await harness.reset();
const config = getConfig();
originalSelfHosted = config.instance.selfHosted;
originalBreachedPasswordCheck = config.breachedPasswordCheck.enabled;
config.instance.selfHosted = true;
await getInstanceConfigRepository().setAccountIdentityMode(AccountIdentityModes.USERNAME, 'setup');
});
afterEach(() => {
const config = getConfig();
config.instance.selfHosted = originalSelfHosted;
config.breachedPasswordCheck.enabled = originalBreachedPasswordCheck;
resetPwnedPasswordCacheForTesting();
});
afterAll(async () => {
await harness?.shutdown();
});
async function createAccount(): Promise<TestAccount & {username: string}> {
const config = getConfig();
config.instance.selfHosted = false;
try {
const username = createUniqueUsername('kit');
const account = await createTestAccount(harness, {username});
return {...account, username};
} finally {
config.instance.selfHosted = true;
}
}
async function createKit(account: TestAccount, password = account.password): Promise<RecoveryKitCreated> {
return await createBuilder<RecoveryKitCreated>(harness, account.token)
.post('/users/@me/recovery-kit')
.body({password})
.execute();
}
async function getStatus(account: TestAccount): Promise<RecoveryKitStatus> {
return await createBuilder<RecoveryKitStatus>(harness, account.token).get('/users/@me/recovery-kit').execute();
}
async function expectInvalidRecoveryKey(body: Record<string, unknown>): Promise<void> {
const {json} = await createBuilderWithoutAuth<ValidationErrorBody>(harness)
.post('/auth/recover')
.body(body)
.expect(400, 'INVALID_FORM_BODY')
.executeWithResponse();
expect(errorFields(json)).toEqual([{path: 'recovery_key', code: ValidationErrorCodes.INVALID_RECOVERY_KEY}]);
}
function errorFields(body: ValidationErrorBody): Array<{path: string; code: string}> {
return body.errors.map(({path, code}) => ({path, code}));
}
function sloppyKey(key: string): string {
return key.replace(/-/g, ' ').replace(/0/g, 'o').replace(/1/g, 'l').toLowerCase();
}
it('reports no kit, creates one behind sudo and reports it', async () => {
const account = await createAccount();
expect(await getStatus(account)).toEqual({has_recovery_kit: false, created_at: null});
await createBuilder(harness, account.token)
.post('/users/@me/recovery-kit')
.body({})
.expect(403, APIErrorCodes.SUDO_MODE_REQUIRED)
.execute();
await createBuilder(harness, account.token)
.post('/users/@me/recovery-kit')
.body({password: 'not-the-right-password'})
.expect(400, 'INVALID_FORM_BODY')
.execute();
expect(await getStatus(account)).toEqual({has_recovery_kit: false, created_at: null});
const kit = await createKit(account);
expect(kit.recovery_key).toMatch(RECOVERY_KEY_PATTERN);
expect(await getStatus(account)).toEqual({has_recovery_kit: true, created_at: kit.created_at});
});
it('replaces the previous kit when a new one is created', async () => {
const account = await createAccount();
const first = await createKit(account);
const second = await createKit(account);
expect(second.recovery_key).not.toBe(first.recovery_key);
await expectInvalidRecoveryKey({login: account.username, recovery_key: first.recovery_key, password: NEW_PASSWORD});
});
it('recovers the account, ends every session and rotates the kit', async () => {
const account = await createAccount();
const kit = await createKit(account);
const recovered = await createBuilderWithoutAuth<RecoverTokenResponse>(harness)
.post('/auth/recover')
.body({login: account.username, recovery_key: sloppyKey(kit.recovery_key), password: NEW_PASSWORD})
.execute();
expect(recovered.user_id).toBe(account.userId);
expect(recovered.token).toBeTruthy();
expect(recovered.recovery_key).toMatch(RECOVERY_KEY_PATTERN);
expect(recovered.recovery_key).not.toBe(kit.recovery_key);
await createBuilder(harness, account.token).get('/users/@me').expect(401).execute();
const recoveredAccount = {...account, token: recovered.token, password: NEW_PASSWORD};
expect(await getStatus(recoveredAccount)).toEqual({
has_recovery_kit: true,
created_at: recovered.recovery_kit_created_at,
});
await expectInvalidRecoveryKey({login: account.username, recovery_key: kit.recovery_key, password: NEW_PASSWORD});
await createBuilder(harness, recovered.token)
.post('/users/@me/recovery-kit')
.body({password: account.password})
.expect(400, 'INVALID_FORM_BODY')
.execute();
await createKit(recoveredAccount);
await createBuilderWithoutAuth(harness)
.post('/auth/login')
.body({login: account.username, password: account.password})
.expect(400, 'INVALID_FORM_BODY')
.execute();
const login = await createBuilderWithoutAuth<{user_id: string}>(harness)
.post('/auth/login')
.body({login: account.username, password: NEW_PASSWORD})
.execute();
expect(login.user_id).toBe(account.userId);
});
it('accepts the full tag and the new key works for the next recovery', async () => {
const account = await createAccount();
const kit = await createKit(account);
const me = await createBuilder<{discriminator: string}>(harness, account.token).get('/users/@me').execute();
const tag = `${account.username.toUpperCase()}#${me.discriminator}`;
const first = await createBuilderWithoutAuth<RecoverTokenResponse>(harness)
.post('/auth/recover')
.body({login: tag, recovery_key: kit.recovery_key, password: NEW_PASSWORD})
.execute();
const second = await createBuilderWithoutAuth<RecoverTokenResponse>(harness)
.post('/auth/recover')
.body({login: account.username, recovery_key: first.recovery_key, password: `${NEW_PASSWORD}-again`})
.execute();
expect(second.user_id).toBe(account.userId);
await expectInvalidRecoveryKey({
login: `${account.username}#${me.discriminator === '9999' ? '0001' : '9999'}`,
recovery_key: second.recovery_key,
password: NEW_PASSWORD,
});
});
it('gives the same error for a wrong key, a malformed key, an unknown user and a missing kit', async () => {
const account = await createAccount();
const kit = await createKit(account);
const otherKit = await createKit(await createAccount());
await expectInvalidRecoveryKey({
login: account.username,
recovery_key: otherKit.recovery_key,
password: NEW_PASSWORD,
});
await expectInvalidRecoveryKey({
login: account.username,
recovery_key: kit.recovery_key.replace(/^./, 'U'),
password: NEW_PASSWORD,
});
await expectInvalidRecoveryKey({login: account.username, recovery_key: 'not-a-key', password: NEW_PASSWORD});
await expectInvalidRecoveryKey({
login: createUniqueUsername('ghost'),
recovery_key: kit.recovery_key,
password: NEW_PASSWORD,
});
const noKit = await createAccount();
await expectInvalidRecoveryKey({login: noKit.username, recovery_key: kit.recovery_key, password: NEW_PASSWORD});
expect(await getStatus(account)).toEqual({has_recovery_kit: true, created_at: kit.created_at});
});
it('refuses a breached password without using up the kit', async () => {
const account = await createAccount();
const kit = await createKit(account);
const breached = 'breached-password-for-recovery';
const hash = crypto.createHash('sha1').update(breached).digest('hex').toUpperCase();
getConfig().breachedPasswordCheck.enabled = true;
resetPwnedPasswordCacheForTesting();
server.use(
http.get('https://api.pwnedpasswords.com/range/:prefix', () => HttpResponse.text(`${hash.slice(5)}:42`)),
);
const {json} = await createBuilderWithoutAuth<ValidationErrorBody>(harness)
.post('/auth/recover')
.body({login: account.username, recovery_key: kit.recovery_key, password: breached})
.expect(400, 'INVALID_FORM_BODY')
.executeWithResponse();
expect(errorFields(json)).toEqual([{path: 'password', code: ValidationErrorCodes.PASSWORD_IS_TOO_COMMON}]);
expect(await getStatus(account)).toEqual({has_recovery_kit: true, created_at: kit.created_at});
await createBuilderWithoutAuth<RecoverTokenResponse>(harness)
.post('/auth/recover')
.body({login: account.username, recovery_key: kit.recovery_key, password: NEW_PASSWORD})
.execute();
});
async function enableTotp(account: TestAccount): Promise<string> {
const secret = 'JBSWY3DPEHPK3PXP';
await createBuilder(harness, account.token)
.post('/users/@me/mfa/totp/enable')
.body({secret, code: totpCodeNow(secret), password: account.password})
.execute();
return secret;
}
it('changes nothing until the second factor passes on an account with two-factor authentication', async () => {
const account = await createAccount();
const kit = await createKit(account);
const secret = await enableTotp(account);
const recovered = await createBuilderWithoutAuth<RecoverMfaResponse>(harness)
.post('/auth/recover')
.body({login: account.username, recovery_key: kit.recovery_key, password: NEW_PASSWORD})
.execute();
expect(recovered.mfa).toBe(true);
expect(recovered.totp).toBe(true);
expect(recovered.ticket).toBeTruthy();
expect(recovered.recovery_key).toMatch(RECOVERY_KEY_PATTERN);
expect('token' in recovered).toBe(false);
await createBuilder(harness, account.token).get('/users/@me').expect(200).execute();
expect(await getStatus(account)).toEqual({has_recovery_kit: true, created_at: kit.created_at});
const oldPasswordLogin = await createBuilderWithoutAuth<{mfa: boolean}>(harness)
.post('/auth/login')
.body({login: account.username, password: account.password})
.execute();
expect(oldPasswordLogin.mfa).toBe(true);
const login = await createBuilderWithoutAuth<{token: string; user_id: string}>(harness)
.post('/auth/login/mfa/totp')
.body({ticket: recovered.ticket, code: totpCodeNow(secret)})
.execute();
expect(login.user_id).toBe(account.userId);
await createBuilder(harness, account.token).get('/users/@me').expect(401).execute();
const recoveredAccount = {...account, token: login.token, password: NEW_PASSWORD};
expect(await getStatus(recoveredAccount)).toEqual({
has_recovery_kit: true,
created_at: recovered.recovery_kit_created_at,
});
await expectInvalidRecoveryKey({login: account.username, recovery_key: kit.recovery_key, password: NEW_PASSWORD});
await createBuilderWithoutAuth(harness)
.post('/auth/login')
.body({login: account.username, password: account.password})
.expect(400, 'INVALID_FORM_BODY')
.execute();
const newPasswordLogin = await createBuilderWithoutAuth<{mfa: boolean}>(harness)
.post('/auth/login')
.body({login: account.username, password: NEW_PASSWORD})
.execute();
expect(newPasswordLogin.mfa).toBe(true);
const next = await createBuilderWithoutAuth<RecoverMfaResponse>(harness)
.post('/auth/recover')
.body({login: account.username, recovery_key: recovered.recovery_key, password: `${NEW_PASSWORD}-again`})
.execute();
expect(next.mfa).toBe(true);
});
it('keeps the old kit working when the second factor is never given', async () => {
const account = await createAccount();
const kit = await createKit(account);
await enableTotp(account);
const abandoned = await createBuilderWithoutAuth<RecoverMfaResponse>(harness)
.post('/auth/recover')
.body({login: account.username, recovery_key: kit.recovery_key, password: NEW_PASSWORD})
.execute();
await expectInvalidRecoveryKey({
login: account.username,
recovery_key: abandoned.recovery_key,
password: NEW_PASSWORD,
});
const retried = await createBuilderWithoutAuth<RecoverMfaResponse>(harness)
.post('/auth/recover')
.body({login: account.username, recovery_key: kit.recovery_key, password: NEW_PASSWORD})
.execute();
expect(retried.mfa).toBe(true);
expect(await getStatus(account)).toEqual({has_recovery_kit: true, created_at: kit.created_at});
});
it('refuses a second factor on a stale recovery once the kit changed', async () => {
const account = await createAccount();
const kit = await createKit(account);
const secret = await enableTotp(account);
const recovered = await createBuilderWithoutAuth<RecoverMfaResponse>(harness)
.post('/auth/recover')
.body({login: account.username, recovery_key: kit.recovery_key, password: NEW_PASSWORD})
.execute();
const replacement = await createBuilder<RecoveryKitCreated>(harness, account.token)
.post('/users/@me/recovery-kit')
.body({mfa_method: 'totp', mfa_code: totpCodeNow(secret)})
.execute();
const {json} = await createBuilderWithoutAuth<ValidationErrorBody>(harness)
.post('/auth/login/mfa/totp')
.body({ticket: recovered.ticket, code: totpCodeNow(secret)})
.expect(400, 'INVALID_FORM_BODY')
.executeWithResponse();
expect(errorFields(json)).toEqual([{path: 'recovery_key', code: ValidationErrorCodes.INVALID_RECOVERY_KEY}]);
await createBuilder(harness, account.token).get('/users/@me').expect(200).execute();
expect(await getStatus(account)).toEqual({has_recovery_kit: true, created_at: replacement.created_at});
});
it('puts the old kit back when the reset fails after the kit was rotated', async () => {
const account = await createAccount();
const kit = await createKit(account);
const users = getUserRepository();
const userId = createUserID(BigInt(account.userId));
const user = await users.findUniqueAssert(userId);
await users.patchUpsert(userId, {traits: new Set(['registration_pending_approval'])}, user.toRow());
await createBuilderWithoutAuth(harness)
.post('/auth/recover')
.body({login: account.username, recovery_key: kit.recovery_key, password: NEW_PASSWORD})
.expect(403)
.execute();
expect((await findRecoveryKitCreatedAt(userId))?.toISOString()).toBe(kit.created_at);
});
it('ends every outstanding admin reset link', async () => {
const admin = await createAccount();
await createBuilder(harness, '')
.post(`/test/users/${admin.userId}/acls`)
.body({acls: [AdminACLs.WILDCARD]})
.execute();
const account = await createAccount();
const link = await createBuilder<{url: string}>(harness, admin.token)
.post(`/admin/users/${account.userId}/password-reset-link`)
.execute();
const token = link.url.split('/reset#token=')[1]!;
const kit = await createKit(account);
await createBuilderWithoutAuth<RecoverTokenResponse>(harness)
.post('/auth/recover')
.body({login: account.username, recovery_key: kit.recovery_key, password: NEW_PASSWORD})
.execute();
const validation = await createBuilderWithoutAuth<{valid: boolean}>(harness).get(`/auth/reset/${token}`).execute();
expect(validation.valid).toBe(false);
});
it('lifts an expired temporary ban and sets the password on the lifted account', async () => {
const account = await createAccount();
const kit = await createKit(account);
const users = getUserRepository();
const userId = createUserID(BigInt(account.userId));
const user = await users.findUniqueAssert(userId);
await users.patchUpsert(
userId,
{flags: user.flags | UserFlags.DISABLED, temp_banned_until: new Date(Date.now() - 60_000)},
user.toRow(),
);
await createBuilderWithoutAuth<RecoverTokenResponse>(harness)
.post('/auth/recover')
.body({login: account.username, recovery_key: kit.recovery_key, password: NEW_PASSWORD})
.execute();
const after = await users.findUniqueAssert(userId);
expect(after.flags & UserFlags.DISABLED).toBe(0n);
expect(after.tempBannedUntil).toBeNull();
expect(after.passwordLastChangedAt).not.toBeNull();
});
it('reports when the kit was created for the data export', async () => {
const account = await createAccount();
const userId = createUserID(BigInt(account.userId));
expect(await findRecoveryKitCreatedAt(userId)).toBeNull();
const kit = await createKit(account);
expect((await findRecoveryKitCreatedAt(userId))?.toISOString()).toBe(kit.created_at);
await getInstanceConfigRepository().setAccountIdentityMode(AccountIdentityModes.EMAIL, 'setup');
expect(await findRecoveryKitCreatedAt(userId)).toBeNull();
});
it('limits attempts per account from one source without locking out others', async () => {
const account = await createAccount();
const kit = await createKit(account);
const wrongKit = await createKit(await createAccount());
for (let attempt = 0; attempt < 5; attempt++) {
await createBuilderWithoutAuth(harness)
.post('/auth/recover')
.header('x-forwarded-for', '198.51.100.20')
.body({login: account.username, recovery_key: wrongKit.recovery_key, password: NEW_PASSWORD})
.expect(400, 'INVALID_FORM_BODY')
.execute();
}
await createBuilderWithoutAuth(harness)
.post('/auth/recover')
.header('x-forwarded-for', '198.51.100.20')
.body({login: account.username.toUpperCase(), recovery_key: kit.recovery_key, password: NEW_PASSWORD})
.expect(429)
.execute();
await createBuilderWithoutAuth<RecoverTokenResponse>(harness)
.post('/auth/recover')
.header('x-forwarded-for', '198.51.100.21')
.body({login: account.username, recovery_key: kit.recovery_key, password: NEW_PASSWORD})
.execute();
});
it('keeps identifiers that are not usernames out of every named bucket', async () => {
const account = await createAccount();
const kit = await createKit(account);
for (let attempt = 0; attempt < 5; attempt++) {
await createBuilderWithoutAuth(harness)
.post('/auth/recover')
.header('x-forwarded-for', '198.51.100.25')
.body({
login: `${account.username}:198.51.100.${attempt}`,
recovery_key: kit.recovery_key,
password: NEW_PASSWORD,
})
.expect(400, 'INVALID_FORM_BODY')
.execute();
}
await createBuilderWithoutAuth(harness)
.post('/auth/recover')
.header('x-forwarded-for', '198.51.100.25')
.body({login: 'not a username', recovery_key: kit.recovery_key, password: NEW_PASSWORD})
.expect(429)
.execute();
await createBuilderWithoutAuth<RecoverTokenResponse>(harness)
.post('/auth/recover')
.header('x-forwarded-for', '198.51.100.25')
.body({login: account.username, recovery_key: kit.recovery_key, password: NEW_PASSWORD})
.execute();
});
it('limits attempts per IP address', async () => {
const account = await createAccount();
const kit = await createKit(account);
for (let attempt = 0; attempt < 10; attempt++) {
await createBuilderWithoutAuth(harness)
.post('/auth/recover')
.header('x-forwarded-for', '198.51.100.30')
.body({login: createUniqueUsername('ghost'), recovery_key: kit.recovery_key, password: NEW_PASSWORD})
.expect(400, 'INVALID_FORM_BODY')
.execute();
}
await createBuilderWithoutAuth(harness)
.post('/auth/recover')
.header('x-forwarded-for', '198.51.100.30')
.body({login: account.username, recovery_key: kit.recovery_key, password: NEW_PASSWORD})
.expect(429)
.execute();
await createBuilderWithoutAuth<RecoverTokenResponse>(harness)
.post('/auth/recover')
.header('x-forwarded-for', '198.51.100.31')
.body({login: account.username, recovery_key: kit.recovery_key, password: NEW_PASSWORD})
.execute();
});
it('is refused with USERNAME_SIGN_IN_ONLY on email instances', async () => {
const account = await createAccount();
const kit = await createKit(account);
await getInstanceConfigRepository().setAccountIdentityMode(AccountIdentityModes.EMAIL, 'setup');
await assertUsernameSignInOnly(account, kit.recovery_key);
getConfig().instance.selfHosted = false;
await assertUsernameSignInOnly(account, kit.recovery_key);
});
async function createAdmin(acls: Array<string>): Promise<TestAccount> {
const account = await createAccount();
await createBuilder(harness, '').post(`/test/users/${account.userId}/acls`).body({acls}).execute();
return account;
}
function hasKit(account: TestAccount): Promise<boolean> {
return findRecoveryKitCreatedAt(createUserID(BigInt(account.userId))).then((createdAt) => createdAt !== null);
}
it('deletes the kit when the password changes', async () => {
const account = await createAccount();
await createKit(account);
const changed = await createBuilder<{token: string}>(harness, account.token)
.post('/users/@me/password')
.body({password: account.password, new_password: NEW_PASSWORD})
.execute();
expect(await getStatus({...account, token: changed.token})).toEqual({has_recovery_kit: false, created_at: null});
});
it('deletes the kit when an admin creates a reset link and again when the link is used', async () => {
const admin = await createAdmin([AdminACLs.WILDCARD]);
const account = await createAccount();
const kit = await createKit(account);
const link = await createBuilder<{url: string}>(harness, admin.token)
.post(`/admin/users/${account.userId}/password-reset-link`)
.execute();
expect(await hasKit(account)).toBe(false);
await expectInvalidRecoveryKey({login: account.username, recovery_key: kit.recovery_key, password: NEW_PASSWORD});
await createKit(account);
const users = getUserRepository();
const userId = createUserID(BigInt(account.userId));
const user = await users.findUniqueAssert(userId);
await users.patchUpsert(userId, {email: null}, user.toRow());
await createBuilderWithoutAuth(harness)
.post('/auth/reset')
.body({token: link.url.split('/reset#token=')[1]!, password: NEW_PASSWORD})
.execute();
expect(await hasKit(account)).toBe(false);
});
it('lets an admin revoke a kit and audits it', async () => {
const admin = await createAdmin([AdminACLs.AUTHENTICATE, AdminACLs.USER_DELETE_RECOVERY_KIT]);
const account = await createAccount();
const kit = await createKit(account);
await createBuilder(harness, admin.token)
.delete(`/admin/users/${account.userId}/recovery-kit`)
.expect(204)
.execute();
expect(await getStatus(account)).toEqual({has_recovery_kit: false, created_at: null});
await expectInvalidRecoveryKey({login: account.username, recovery_key: kit.recovery_key, password: NEW_PASSWORD});
const audit = (await getAdminRepository().listAllAuditLogsPaginated(1000)).find(
(log) => log.action === 'revoke_recovery_kit',
);
expect(audit?.targetId.toString()).toBe(account.userId);
expect(audit?.adminUserId.toString()).toBe(admin.userId);
});
it('needs its own ACL to revoke a kit and refuses email instances', async () => {
const admin = await createAdmin([AdminACLs.AUTHENTICATE, AdminACLs.USER_CREATE_PASSWORD_RESET_LINK]);
const account = await createAccount();
await createKit(account);
await createBuilder(harness, admin.token)
.delete(`/admin/users/${account.userId}/recovery-kit`)
.expect(403, APIErrorCodes.MISSING_ACL)
.execute();
expect(await hasKit(account)).toBe(true);
const wildcard = await createAdmin([AdminACLs.WILDCARD]);
await getInstanceConfigRepository().setAccountIdentityMode(AccountIdentityModes.EMAIL, 'setup');
await createBuilder(harness, wildcard.token)
.delete(`/admin/users/${account.userId}/recovery-kit`)
.expect(400, APIErrorCodes.USERNAME_SIGN_IN_ONLY)
.execute();
});
it('refuses a reset link or a revocation for an account holding ACLs the caller lacks', async () => {
const admin = await createAdmin([
AdminACLs.AUTHENTICATE,
AdminACLs.USER_CREATE_PASSWORD_RESET_LINK,
AdminACLs.USER_DELETE_RECOVERY_KIT,
]);
const target = await createAdmin([AdminACLs.AUTHENTICATE, AdminACLs.USER_UPDATE_EMAIL]);
await createKit(target);
await createBuilder(harness, admin.token)
.post(`/admin/users/${target.userId}/password-reset-link`)
.expect(403, APIErrorCodes.MISSING_ACL)
.execute();
await createBuilder(harness, admin.token)
.delete(`/admin/users/${target.userId}/recovery-kit`)
.expect(403, APIErrorCodes.MISSING_ACL)
.execute();
expect(await hasKit(target)).toBe(true);
const peer = await createAdmin([AdminACLs.AUTHENTICATE]);
await createBuilder(harness, admin.token).post(`/admin/users/${peer.userId}/password-reset-link`).execute();
const wildcard = await createAdmin([AdminACLs.WILDCARD]);
await createBuilder(harness, wildcard.token).post(`/admin/users/${target.userId}/password-reset-link`).execute();
});
it('refuses recovery for a registration awaiting approval or rejected', async () => {
const account = await createAccount();
const kit = await createKit(account);
const users = getUserRepository();
const userId = createUserID(BigInt(account.userId));
const changedBefore = (await users.findUniqueAssert(userId)).passwordLastChangedAt;
for (const [trait, code] of [
[REGISTRATION_PENDING_APPROVAL_TRAIT, APIErrorCodes.REGISTRATION_PENDING_APPROVAL],
[REGISTRATION_REJECTED_TRAIT, APIErrorCodes.REGISTRATION_REJECTED],
] as const) {
const user = await users.findUniqueAssert(userId);
await users.patchUpsert(userId, {traits: new Set([trait])}, user.toRow());
await createBuilderWithoutAuth(harness)
.post('/auth/recover')
.body({login: account.username, recovery_key: kit.recovery_key, password: NEW_PASSWORD})
.expect(403, code)
.execute();
}
expect((await findRecoveryKitCreatedAt(userId))?.toISOString()).toBe(kit.created_at);
expect((await users.findUniqueAssert(userId)).passwordLastChangedAt).toEqual(changedBefore);
});
it('reads a kit row for an unknown name too', async () => {
const find = vi.spyOn(RecoveryKitRepository.prototype, 'find');
try {
await expectInvalidRecoveryKey({
login: createUniqueUsername('ghost'),
recovery_key: 'ABCD-EFGH-JKMN-PQRS-TVWX-YZ01-2345-6789',
password: NEW_PASSWORD,
});
expect(find).toHaveBeenCalledWith(createUserID(0n));
} finally {
find.mockRestore();
}
});
it('ignores a pending recovery once the instance is no longer a username instance', async () => {
const account = await createAccount();
const kit = await createKit(account);
const secret = await enableTotp(account);
const userId = createUserID(BigInt(account.userId));
const changedBefore = (await getUserRepository().findUniqueAssert(userId)).passwordLastChangedAt;
const recovered = await createBuilderWithoutAuth<RecoverMfaResponse>(harness)
.post('/auth/recover')
.body({login: account.username, recovery_key: kit.recovery_key, password: NEW_PASSWORD})
.execute();
await getInstanceConfigRepository().setAccountIdentityMode(AccountIdentityModes.EMAIL, 'setup');
const login = await createBuilderWithoutAuth<{token: string; user_id: string}>(harness)
.post('/auth/login/mfa/totp')
.body({ticket: recovered.ticket, code: totpCodeNow(secret)})
.execute();
expect(login.user_id).toBe(account.userId);
await createBuilder(harness, account.token).get('/users/@me').expect(200).execute();
const user = await getUserRepository().findUniqueAssert(userId);
expect(user.passwordLastChangedAt).toEqual(changedBefore);
});
async function assertUsernameSignInOnly(account: TestAccount & {username: string}, key: string): Promise<void> {
await createBuilder(harness, account.token)
.get('/users/@me/recovery-kit')
.expect(400, APIErrorCodes.USERNAME_SIGN_IN_ONLY)
.execute();
await createBuilder(harness, account.token)
.post('/users/@me/recovery-kit')
.body({password: account.password})
.expect(400, APIErrorCodes.USERNAME_SIGN_IN_ONLY)
.execute();
await createBuilderWithoutAuth(harness)
.post('/auth/recover')
.body({login: account.username, recovery_key: key, password: NEW_PASSWORD})
.expect(400, APIErrorCodes.USERNAME_SIGN_IN_ONLY)
.execute();
}
});
File diff suppressed because it is too large Load Diff
@@ -25,6 +25,7 @@ export interface MessageResponseAccessContext {
sourceGuildId: GuildID | null;
messageHistoryCutoff: string | null;
canReadMessageHistory: boolean;
includeHidden?: boolean;
}
interface ExtractedMentions {
@@ -105,6 +106,7 @@ export class MessageResponseDataService {
? new Date(params.access.messageHistoryCutoff).getTime()
: null,
can_read_message_history: params.access.canReadMessageHistory,
include_hidden: params.access.includeHidden ?? false,
media_endpoint: Config.endpoints.media,
media_proxy_secret_key: Config.mediaProxy.secretKey,
attachment_url_secret_base64: Config.mediaProxy.attachmentUrls.secretsBase64[0],
@@ -146,6 +148,7 @@ export class MessageResponseDataService {
? new Date(params.access.messageHistoryCutoff).getTime()
: null,
can_read_message_history: params.access.canReadMessageHistory,
include_hidden: params.access.includeHidden ?? false,
media_endpoint: Config.endpoints.media,
media_proxy_secret_key: Config.mediaProxy.secretKey,
attachment_url_secret_base64: Config.mediaProxy.attachmentUrls.secretsBase64[0],
@@ -177,6 +180,7 @@ export class MessageResponseDataService {
? new Date(params.access.messageHistoryCutoff).getTime()
: null,
can_read_message_history: params.access.canReadMessageHistory,
include_hidden: params.access.includeHidden ?? false,
media_endpoint: Config.endpoints.media,
media_proxy_secret_key: Config.mediaProxy.secretKey,
attachment_url_secret_base64: Config.mediaProxy.attachmentUrls.secretsBase64[0],
@@ -245,6 +249,7 @@ export class MessageResponseDataService {
? new Date(params.access.messageHistoryCutoff).getTime()
: null,
can_read_message_history: params.access.canReadMessageHistory,
include_hidden: params.access.includeHidden ?? false,
media_endpoint: Config.endpoints.media,
media_proxy_secret_key: Config.mediaProxy.secretKey,
attachment_url_secret_base64: Config.mediaProxy.attachmentUrls.secretsBase64[0],
@@ -52,7 +52,7 @@ import type {Webhook} from '@app/api/models/Webhook';
import {assertAccountNotLimited} from '@app/api/user/AccountLimit';
import type {IUserRepository} from '@app/api/user/IUserRepository';
import {assertMayStartConversation} from '@app/api/user/NewConversationLimit';
import {isDirectDeliverySuppressed} from '@app/api/user/UserHelpers';
import {isContentHidden, isDirectDeliverySuppressed} from '@app/api/user/UserHelpers';
import {assertGuildMemberCanCommunicate} from '@app/api/utils/GuildCommunicationUtils';
import {
ChannelTypes,
@@ -942,6 +942,7 @@ export class MessageSendService {
}
}
const suppressDmRecipientDelivery = dmRecipientId !== null && isDirectDeliverySuppressed(user);
const suppressDelivery = suppressDmRecipientDelivery || isContentHidden(user, messageId);
const channelHadMessages = channel.lastMessageId !== null;
const {message, enqueueDeferredEmbeds} = await this.deps.persistenceService.createMessage({
messageId,
@@ -973,7 +974,7 @@ export class MessageSendService {
messageId,
mentionChannels: mentionData?.mentionChannels,
});
if (!suppressDmRecipientDelivery) {
if (!suppressDelivery) {
await this.settlePostCreateWork(messageId, [
{
step: 'update_dm_recipients',
@@ -1000,7 +1001,7 @@ export class MessageSendService {
await this.settlePostCreateWork(messageId, [
{
step: 'dispatch',
promise: suppressDmRecipientDelivery
promise: suppressDelivery
? this.deps.dispatchService.dispatchMessageCreateToUser({
channel,
message,
@@ -1031,7 +1032,7 @@ export class MessageSendService {
guildOwnerId: guild?.owner_id ? createUserID(BigInt(guild.owner_id)) : null,
dmRecipientId,
channelHadMessages,
delivered: !suppressDmRecipientDelivery,
delivered: !suppressDelivery,
userRepository: this.deps.userRepository,
});
void enqueueDeferredEmbeds().catch((error) => {
@@ -0,0 +1,63 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {TestAccount} from '@app/api/auth/tests/AuthTestUtils';
import {createUserID} from '@app/api/BrandedTypes';
import {setupTestGuildWithMembers} from '@app/api/guild/tests/GuildTestUtils';
import {sendMessage} from '@app/api/message/tests/MessageTestUtils';
import {getUserRepository} from '@app/api/middleware/ServiceSingletons';
import {type ApiTestHarness, createApiTestHarness} from '@app/api/test/ApiTestHarness';
import {NoopGatewayService} from '@app/api/test/NoopGatewayService';
import {afterEach, beforeEach, describe, expect, test, vi} from 'vitest';
describe('messages from an author inside a hide window', () => {
let harness: ApiTestHarness;
let author: TestAccount;
let channelId: string;
beforeEach(async () => {
harness = await createApiTestHarness();
const setup = await setupTestGuildWithMembers(harness, 1);
author = setup.members[0]!;
channelId = setup.channels[0]!.id;
vi.spyOn(NoopGatewayService.prototype, 'dispatchGuild');
vi.spyOn(NoopGatewayService.prototype, 'dispatchPresence');
});
afterEach(async () => {
vi.restoreAllMocks();
await harness?.shutdown();
});
async function setHiddenSince(since: Date | null): Promise<void> {
await getUserRepository().patchUpsert(createUserID(BigInt(author.userId)), {content_hidden_since: since});
}
function createEvents(messageId: string) {
const guild = vi
.mocked(NoopGatewayService.prototype.dispatchGuild)
.mock.calls.filter(([call]) => call.event === 'MESSAGE_CREATE' && (call.data as {id: string}).id === messageId);
const presence = vi
.mocked(NoopGatewayService.prototype.dispatchPresence)
.mock.calls.filter(([call]) => call.event === 'MESSAGE_CREATE' && (call.data as {id: string}).id === messageId);
return {guild, presence: presence.map(([call]) => call.userId.toString())};
}
test('reach only the author and fan out again once the window is cleared', async () => {
await setHiddenSince(new Date(Date.now() - 60_000));
const hidden = await sendMessage(harness, author.token, channelId, 'inside the window');
expect(hidden.content).toBe('inside the window');
expect(createEvents(hidden.id)).toEqual({guild: [], presence: [author.userId]});
await setHiddenSince(null);
const shown = await sendMessage(harness, author.token, channelId, 'after restore');
const events = createEvents(shown.id);
expect(events.guild).toHaveLength(1);
expect(events.presence).toEqual([]);
});
test('a window that starts later leaves current messages alone', async () => {
await setHiddenSince(new Date(Date.now() + 3_600_000));
const message = await sendMessage(harness, author.token, channelId, 'before the window');
expect(createEvents(message.id).guild).toHaveLength(1);
});
});

Some files were not shown because too many files have changed in this diff Show More