Compare commits

...
Author SHA1 Message Date
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
HampusandGitHub 12a407aca8 fix(api): drop localized card checks and require pix for brazil (#3203) 2026-10-04 18:03:14 +02:00
HampusandGitHub 5ca458dada fix(mentions): ignore @everyone and @here in one-to-one DMs (#3199) 2026-10-04 16:59:04 +02:00
HampusandGitHub 0aeff01c2d feat(api): scope forwarded client ip trust per caller (#3198) 2026-10-04 16:36:41 +02:00
HampusandGitHub 2ac164d5b8 fix(voice): keep the mic graph on the real audio clock (#3197) 2026-10-04 16:18:34 +02:00
HampusandGitHub 14d475df9a fix(app): explain how direct input and desktop shortcuts relate (#3196) 2026-10-04 15:12:52 +02:00
HampusandGitHub f3c777b244 fix(gateway,api): reach NATS over IPv4 and survive boot races (#3189) 2026-10-04 03:06:12 +02:00
HampusandGitHub 1544e58e76 fix(desktop): show unsupported when non-GNOME portal bind fails (#3188) 2026-10-04 02:38:06 +02:00
HampusandGitHub 5d0c9c7cbe fix(desktop): stub the build channel in the Linux session test (#3186) 2026-10-04 01:14:18 +02:00
HampusandGitHub 8f58fcc4c4 feat(desktop): portal-based Linux global shortcuts and PTT (#3185) 2026-10-04 01:08:51 +02:00
HampusandGitHub 5799ef705d fix(app): send expired sessions to login on oauth authorize (#3181) 2026-10-03 20:39:34 +02:00
omsterandGitHub 0de7dde1ce feat(app): reveal external link destinations on hover (#3176) 2026-10-03 19:44:03 +02:00
HampusandGitHub 7b39e5a79d fix(api): treat typographic quotes as exact phrase search (#3180) 2026-10-03 19:43:08 +02:00
HampusandGitHub 583c791016 fix(app): keep the updater polling after async native results (#3179) 2026-10-03 19:42:41 +02:00
HampusandGitHub bc5dcdfe21 feat(app): show paused-messaging banner across the app (#3178) 2026-10-03 19:31:43 +02:00
HampusandGitHub 973aaced96 chore(static): drop unused fluxer_static assets (#3175) 2026-10-03 18:22:03 +02:00
HampusandGitHub 3e9ee908f8 fix(ci): accept the static image's compound license label (#3174) 2026-10-03 18:20:58 +02:00
HampusandGitHub e7347b582c chore(license): relicense artwork and move non-free media out (#3173) 2026-10-03 17:53:42 +02:00
HampusandGitHub 71b7cffabc fix(i18n): more natural French paused-messaging notice (#3172) 2026-10-03 17:04:40 +02:00
HampusandGitHub da9e9ff0be chore: tidy request handling across services (#3168) 2026-10-03 15:36:33 +02:00
HampusandGitHub c6941d5905 style: run rustfmt on attachment url signature tests (#3166) 2026-10-03 15:00:45 +02:00
HampusandGitHub a3cf960660 docs(discovery): document the channel preview route (#3165) 2026-10-03 14:48:09 +02:00
HampusandGitHub 7eebfca20b feat(blocklist): add url-domain host patterns (#3164) 2026-10-03 14:46:08 +02:00
HampusandGitHub e9167d96ec feat(admin): add optional expiry to admin IP bans (#3163) 2026-10-03 14:41:00 +02:00
HampusandGitHub 1664050ef7 fix(app): use sidebar channel icons in forwarded-from source (#3162) 2026-10-03 14:25:56 +02:00
HampusandGitHub 7fa00c0e89 chore(admin): remove user type toggles (#3161) 2026-10-03 14:22:13 +02:00
HampusandGitHub 81d69c41f5 feat(discovery): resolve message links into discoverable guilds (#3159) 2026-10-03 13:12:30 +02:00
HampusandGitHub 4e6b837ccc fix: tighten edge cases across services (#3158) 2026-10-03 13:04:29 +02:00
HampusandGitHub a9f7a23c0d fix(voice): point the corner volume at the focused stream (#3157) 2026-10-03 12:37:24 +02:00
HampusandGitHub 07301adc6d fix(messages): keep mention highlight on hover in blocked groups (#3156) 2026-10-03 12:37:20 +02:00
HampusandGitHub c6630008b5 fix(voice): enlarge participant avatars in the voice panel (#3155) 2026-10-03 12:19:37 +02:00
1010 changed files with 97016 additions and 52357 deletions
+7 -1
View File
@@ -16,4 +16,10 @@ Every commit made by a contributor must include the [Developer Certificate of Or
## Name and marks
The AGPL does not grant permission to use the Fluxer name, logo or other branding. Forks must use a distinct name and branding unless Fluxer Platform AB grants permission otherwise.
Fluxer and the Fluxer logo are trademarks of Fluxer Platform AB. Neither the AGPL nor the CC BY-SA 4.0 licence on Fluxer artwork grants trademark rights. Fluxer Platform AB grants everyone the following permissions.
- You may distribute unmodified builds of Fluxer, or builds with light patches, under the Fluxer name and logo. Light patches are changes for packaging, portability, security and bug fixes, configuration defaults and translations. Linux distributions, nixpkgs, Flathub and container images are all covered.
- A self-hosted instance running such a build may show the Fluxer name and logo under the instance's own name and domain, as long as it does not imply affiliation with or endorsement by Fluxer Platform AB.
- You may refer to Fluxer by name to describe compatibility, for example "works with Fluxer".
Forks with substantive functional changes must use their own name and logo. Any other use needs permission from Fluxer Platform AB. Contact support@fluxer.com.
+6 -7
View File
@@ -26,7 +26,7 @@
Fluxer is a free and open source instant messaging and VoIP chat app built for friends, groups, and communities.
<p align="center">
<img src="./fluxer_static/marketing/screenshots/desktop-readme-1920w.png" alt="Fluxer running side by side on a desktop monitor and a phone" width="640">
<img src="https://fluxer.app/static/img/screenshots-desktop-readme-1920w.70cb6ce340007e0a.png" alt="Fluxer running side by side on a desktop monitor and a phone" width="640">
</p>
## Download
@@ -143,14 +143,13 @@ Full setup notes, including canary, are in the [Linux repositories documentation
The source is licensed under the [AGPL-3.0-or-later](./LICENSE) license.
Fluxer branding, icons, default avatars, badge artwork, screenshots and marketing
imagery are copyright Fluxer, all rights reserved, as set out in
[fluxer_static/LICENSE](./fluxer_static/LICENSE). Third-party material keeps its own
terms, listed in
Fluxer artwork, such as the logo, icons, badges and default avatars, is
licensed under [CC BY-SA 4.0](./fluxer_static/LICENSE). Third-party material
keeps its own terms, listed in
[fluxer_static/THIRD_PARTY_LICENSES.md](./fluxer_static/THIRD_PARTY_LICENSES.md).
Public availability of this repository does not grant trademark, brand, or
endorsement rights.
Use of the Fluxer name and logo is covered by the
[name and marks policy](./.github/GOVERNANCE.md#name-and-marks).
[win-setup-x64]: https://pkgs.fluxer.com/desktop/stable/win32/x64/latest/setup
[win-setup-arm64]: https://pkgs.fluxer.com/desktop/stable/win32/arm64/latest/setup
+7 -2
View File
@@ -138,6 +138,7 @@ MEILI_MASTER_KEY=CHANGE_ME
#FLUXER_STRIPE_PRICES={}
#FLUXER_STRIPE_LEGACY_PRICES={}
#FLUXER_API_DONATION_PROXY_KEY=
#FLUXER_API_TRUSTED_CALLERS=[]
#FLUXER_VISIONARIES_GUILD_ID=
#FLUXER_VISIONARIES_GUILD_VISIONARY_ROLE_ID=
@@ -305,6 +306,7 @@ FLUXER_KLIPY_API_KEY=
# Hosts the api never unfurls, comma separated.
#FLUXER_API_UNFURL_IGNORED_HOSTS=
# Email delivery. Only an instance where members sign in with email needs it.
FLUXER_EMAIL_ENABLED=false
FLUXER_EMAIL_PROVIDER=none
FLUXER_EMAIL_FROM_EMAIL=[email protected]
@@ -332,6 +334,10 @@ FLUXER_DISCOVERY_ENABLED=true
#FLUXER_APP_STATUS_PAGE_URL=
#FLUXER_APP_STATUS_PAGE_INCIDENT_HISTORY_URL=
#FLUXER_INSTANCE_SETUP_CONFIGURED=false
# How members sign in on a new instance, username or email. Unset means username. Read only on the first start.
#FLUXER_ACCOUNT_IDENTITY=
# Username tags on a new email instance. none gives unique names with no tag, random gives name#4821. Unset means none. A username instance always uses none. Read only on the first start.
#FLUXER_TAG_STYLE=
#FLUXER_AUTO_JOIN_INVITE_CODE=
#FLUXER_DELETION_GRACE_PERIOD_HOURS=336
@@ -357,9 +363,8 @@ FLUXER_DISCOVERY_ENABLED=true
#FLUXER_GIFT_ENDPOINT=
#FLUXER_MEDIA_PROXY_UPLOAD_RELAY_ENDPOINT=
#PUBLIC_BOOTSTRAP_API_PUBLIC_ENDPOINT=
# These follow FLUXER_STATIC_CDN_ENDPOINT first, then the public origin.
# This follows FLUXER_STATIC_CDN_ENDPOINT first, then the public origin.
#FLUXER_GATEWAY_STATIC_CDN_ENDPOINT=
#FLUXER_UNFURL_STATIC_CDN_ENDPOINT=
# These follow FLUXER_MEDIA_ENDPOINT first, then the public origin.
#FLUXER_MEDIA_PROXY_PUBLIC_ENDPOINT=
#FLUXER_GATEWAY_MEDIA_PROXY_ENDPOINT=
+1 -1
View File
@@ -42,7 +42,7 @@
reverse_proxy admin:8080
}
@staticAssets path /web/* /emoji/* /libs/* /avatars/* /badges/* /desktop/* /embeds/*
@staticAssets path /web/* /emoji/* /libs/* /avatars/* /badges/* /desktop/*
handle @staticAssets {
reverse_proxy static-proxy:8080
}
+3 -2
View File
@@ -128,6 +128,7 @@ x-fluxer-env: &fluxer-env
FLUXER_STRIPE_PRICES: ${FLUXER_STRIPE_PRICES:-}
FLUXER_STRIPE_LEGACY_PRICES: ${FLUXER_STRIPE_LEGACY_PRICES:-}
FLUXER_API_DONATION_PROXY_KEY: ${FLUXER_API_DONATION_PROXY_KEY:-}
FLUXER_API_TRUSTED_CALLERS: ${FLUXER_API_TRUSTED_CALLERS:-}
FLUXER_VISIONARIES_GUILD_ID: ${FLUXER_VISIONARIES_GUILD_ID:-}
FLUXER_VISIONARIES_GUILD_VISIONARY_ROLE_ID: ${FLUXER_VISIONARIES_GUILD_VISIONARY_ROLE_ID:-}
@@ -151,6 +152,8 @@ x-fluxer-env: &fluxer-env
FLUXER_APP_STATUS_PAGE_URL: ${FLUXER_APP_STATUS_PAGE_URL:-}
FLUXER_APP_STATUS_PAGE_INCIDENT_HISTORY_URL: ${FLUXER_APP_STATUS_PAGE_INCIDENT_HISTORY_URL:-}
FLUXER_INSTANCE_SETUP_CONFIGURED: ${FLUXER_INSTANCE_SETUP_CONFIGURED:-}
FLUXER_ACCOUNT_IDENTITY: ${FLUXER_ACCOUNT_IDENTITY:-}
FLUXER_TAG_STYLE: ${FLUXER_TAG_STYLE:-}
FLUXER_AUTO_JOIN_INVITE_CODE: ${FLUXER_AUTO_JOIN_INVITE_CODE:-}
FLUXER_DISCOVERY_ENABLED: ${FLUXER_DISCOVERY_ENABLED:-}
FLUXER_DISCOVERY_MIN_MEMBER_COUNT: ${FLUXER_DISCOVERY_MIN_MEMBER_COUNT:-}
@@ -845,8 +848,6 @@ services:
FLUXER_SVC_MODE: shard
FLUXER_SVC_SHARD_ID: "0"
FLUXER_MEDIA_PROXY_ENDPOINT: http://media-proxy:8080
FLUXER_UNFURL_STATIC_CDN_ENDPOINT: ${FLUXER_UNFURL_STATIC_CDN_ENDPOINT:-}
FLUXER_STATIC_CDN_ENDPOINT: ${FLUXER_STATIC_CDN_ENDPOINT:-${FLUXER_PUBLIC_ORIGIN:-${FLUXER_PUBLIC_SCHEME:-https}://${FLUXER_DOMAIN}}}
healthcheck: *fluxer-svc-healthcheck
depends_on:
nats: {condition: service_healthy}
+1 -1
View File
@@ -600,7 +600,7 @@ fn select_faces(package_dir: &Path) -> Vec<Face> {
}
assert!(
face["unicodeRange"].is_null(),
"{wanted} face {} carries a unicode-range; Latin-core faces must not",
"{wanted} face {} has a unicode-range; Latin-core faces must not",
face["file"]
);
faces.push(Face {
+222 -168
View File
@@ -280,7 +280,7 @@
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
}
},
"description": "Renames an API key or replaces the access control lists (ACLs) it carries. The key may only carry permissions the acting admin already holds. Omitted fields are left unchanged and the key material is never rotated or returned.",
"description": "Renames an API key or replaces the access control lists (ACLs) it has. The key may only hold permissions the acting admin already holds. Omitted fields are left unchanged and the key material is never rotated or returned.",
"security": [{"adminApiKey": []}],
"parameters": [
{
@@ -1108,7 +1108,7 @@
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
}
},
"description": "List every blocklist this instance maintains, the request field that carries an entry value, the extra fields its entries accept, and which of the bulk and update operations it supports.",
"description": "List every blocklist this instance maintains, the request field that holds an entry value, the extra fields its entries accept, and which of the bulk and update operations it supports.",
"security": [{"adminApiKey": []}]
}
},
@@ -1451,7 +1451,7 @@
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
}
},
"description": "Report whether a value is currently blocked by a blocklist. The value is percent-encoded in the path. An IP address can still match a broader stored CIDR entry, and a URL can match a banned domain. The profile-substring blocklist requires a scope.",
"description": "Report whether a value is currently blocked by a blocklist. The value is percent-encoded in the path. An IP address can still match a broader stored CIDR entry, and a url-domain value can be a hostname or an http(s) URL that a stored domain or pattern covers. The profile-substring blocklist requires a scope.",
"security": [{"adminApiKey": []}],
"parameters": [
{
@@ -1529,7 +1529,7 @@
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
}
},
"description": "Rewrite the stored fields of a blocklist entry without removing and re-adding it. The stored metadata is replaced by the supplied fields, so fields left out fall back to their defaults. Only blocklists whose entries carry fields accept this operation, reported as supports_update by GET /admin/blocklists.",
"description": "Rewrite the stored fields of a blocklist entry without removing and re-adding it. The stored metadata is replaced by the supplied fields, so fields left out fall back to their defaults. Only blocklists whose entries have fields accept this operation, reported as supports_update by GET /admin/blocklists.",
"security": [{"adminApiKey": []}],
"parameters": [
{
@@ -4680,7 +4680,7 @@
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
}
},
"description": "Searches the messages of a channel by content, or resolves a single message by its ID or by one of its attachments. Passing message_id returns that message with the messages surrounding it; passing attachment_id together with filename returns the message carrying that attachment with its surrounding context. Requires MESSAGE_LOOKUP permission.",
"description": "Searches the messages of a channel by content, or resolves a single message by its ID or by one of its attachments. Passing message_id returns that message with the messages surrounding it; passing attachment_id together with filename returns the message with that attachment with its surrounding context. Requires MESSAGE_LOOKUP permission.",
"security": [{"adminApiKey": []}],
"parameters": [
{
@@ -4715,10 +4715,10 @@
"in": "query",
"required": false,
"schema": {
"description": "Return the single message carrying this attachment together with its surrounding context; requires filename",
"description": "Return the single message with this attachment together with its surrounding context; requires filename",
"allOf": [{"$ref": "#/components/schemas/SnowflakeType"}]
},
"description": "Return the single message carrying this attachment together with its surrounding context; requires filename"
"description": "Return the single message with this attachment together with its surrounding context; requires filename"
},
{
"name": "filename",
@@ -6137,72 +6137,6 @@
}
}
},
"/admin/users/{user_id}/bot-status": {
"put": {
"operationId": "set_admin_user_bot_status",
"summary": "Set user bot status",
"tags": ["Admin"],
"responses": {
"200": {
"description": "Success",
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/UserMutationResponse"}}}
},
"400": {
"description": "Bad Request - The request was malformed or contained invalid data",
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
},
"401": {
"description": "Unauthorized - Authentication is required or the token is invalid",
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
},
"403": {
"description": "Forbidden - You do not have permission to perform this action",
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
},
"429": {
"description": "Too Many Requests - You are being rate limited",
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/ThrottledError"}}},
"headers": {
"Retry-After": {
"description": "Number of seconds to wait before retrying (only on 429)",
"schema": {"type": "integer"}
},
"X-RateLimit-Limit": {
"description": "The number of requests that can be made in the current window",
"schema": {"type": "integer"}
},
"X-RateLimit-Remaining": {
"description": "The number of remaining requests that can be made",
"schema": {"type": "integer"}
},
"X-RateLimit-Reset": {
"description": "Unix timestamp when the rate limit resets",
"schema": {"type": "integer"}
}
}
},
"500": {
"description": "Internal Server Error - An unexpected error occurred",
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
}
},
"description": "Mark or unmark a user account as a bot. Controls bot badge visibility and API permissions. Creates audit log entry. Requires USER_UPDATE_BOT_STATUS permission.",
"security": [{"adminApiKey": []}],
"parameters": [
{
"name": "user_id",
"in": "path",
"required": true,
"schema": {"description": "The ID of the user", "allOf": [{"$ref": "#/components/schemas/SnowflakeType"}]},
"description": "The ID of the user"
}
],
"requestBody": {
"required": true,
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/AdminUserBotStatusRequest"}}}
}
}
},
"/admin/users/{user_id}/change-log": {
"get": {
"operationId": "list_admin_user_change_log",
@@ -7190,6 +7124,68 @@
]
}
},
"/admin/users/{user_id}/password-reset-link": {
"post": {
"operationId": "create_admin_user_password_reset_link",
"summary": "Create user password reset link",
"tags": ["Admin"],
"responses": {
"200": {
"description": "Success",
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/AdminPasswordResetLinkResponse"}}}
},
"400": {
"description": "Bad Request - The request was malformed or contained invalid data",
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
},
"401": {
"description": "Unauthorized - Authentication is required or the token is invalid",
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
},
"403": {
"description": "Forbidden - You do not have permission to perform this action",
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
},
"429": {
"description": "Too Many Requests - You are being rate limited",
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/ThrottledError"}}},
"headers": {
"Retry-After": {
"description": "Number of seconds to wait before retrying (only on 429)",
"schema": {"type": "integer"}
},
"X-RateLimit-Limit": {
"description": "The number of requests that can be made in the current window",
"schema": {"type": "integer"}
},
"X-RateLimit-Remaining": {
"description": "The number of remaining requests that can be made",
"schema": {"type": "integer"}
},
"X-RateLimit-Reset": {
"description": "Unix timestamp when the rate limit resets",
"schema": {"type": "integer"}
}
}
},
"500": {
"description": "Internal Server Error - An unexpected error occurred",
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
}
},
"description": "Create a one-time password reset link on an instance where people sign in with a username. Hand the link to the user yourself. It works once and expires after an hour. Deletes the recovery kit of the account. Creates audit log entry. Requires USER_CREATE_PASSWORD_RESET_LINK permission and every ACL the target account holds. Fails with USERNAME_SIGN_IN_ONLY on email instances.",
"security": [{"adminApiKey": []}],
"parameters": [
{
"name": "user_id",
"in": "path",
"required": true,
"schema": {"description": "The ID of the user", "allOf": [{"$ref": "#/components/schemas/SnowflakeType"}]},
"description": "The ID of the user"
}
]
}
},
"/admin/users/{user_id}/premium-flags": {
"patch": {
"operationId": "update_admin_user_premium_flags",
@@ -7324,6 +7320,65 @@
}
}
},
"/admin/users/{user_id}/recovery-kit": {
"delete": {
"operationId": "revoke_admin_user_recovery_kit",
"summary": "Revoke user recovery kit",
"tags": ["Admin"],
"responses": {
"204": {"description": "No Content"},
"400": {
"description": "Bad Request - The request was malformed or contained invalid data",
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
},
"401": {
"description": "Unauthorized - Authentication is required or the token is invalid",
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
},
"403": {
"description": "Forbidden - You do not have permission to perform this action",
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
},
"429": {
"description": "Too Many Requests - You are being rate limited",
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/ThrottledError"}}},
"headers": {
"Retry-After": {
"description": "Number of seconds to wait before retrying (only on 429)",
"schema": {"type": "integer"}
},
"X-RateLimit-Limit": {
"description": "The number of requests that can be made in the current window",
"schema": {"type": "integer"}
},
"X-RateLimit-Remaining": {
"description": "The number of remaining requests that can be made",
"schema": {"type": "integer"}
},
"X-RateLimit-Reset": {
"description": "Unix timestamp when the rate limit resets",
"schema": {"type": "integer"}
}
}
},
"500": {
"description": "Internal Server Error - An unexpected error occurred",
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
}
},
"description": "Deletes the recovery kit of an account on an instance where people sign in with a username, so its key stops working. Creates audit log entry. Requires USER_DELETE_RECOVERY_KIT permission and every ACL the target account holds. Fails with USERNAME_SIGN_IN_ONLY on email instances.",
"security": [{"adminApiKey": []}],
"parameters": [
{
"name": "user_id",
"in": "path",
"required": true,
"schema": {"description": "The ID of the user", "allOf": [{"$ref": "#/components/schemas/SnowflakeType"}]},
"description": "The ID of the user"
}
]
}
},
"/admin/users/{user_id}/relationships": {
"get": {
"operationId": "list_admin_user_relationships",
@@ -7783,72 +7838,6 @@
]
}
},
"/admin/users/{user_id}/system-status": {
"put": {
"operationId": "set_admin_user_system_status",
"summary": "Set user system status",
"tags": ["Admin"],
"responses": {
"200": {
"description": "Success",
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/UserMutationResponse"}}}
},
"400": {
"description": "Bad Request - The request was malformed or contained invalid data",
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
},
"401": {
"description": "Unauthorized - Authentication is required or the token is invalid",
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
},
"403": {
"description": "Forbidden - You do not have permission to perform this action",
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
},
"429": {
"description": "Too Many Requests - You are being rate limited",
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/ThrottledError"}}},
"headers": {
"Retry-After": {
"description": "Number of seconds to wait before retrying (only on 429)",
"schema": {"type": "integer"}
},
"X-RateLimit-Limit": {
"description": "The number of requests that can be made in the current window",
"schema": {"type": "integer"}
},
"X-RateLimit-Remaining": {
"description": "The number of remaining requests that can be made",
"schema": {"type": "integer"}
},
"X-RateLimit-Reset": {
"description": "Unix timestamp when the rate limit resets",
"schema": {"type": "integer"}
}
}
},
"500": {
"description": "Internal Server Error - An unexpected error occurred",
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
}
},
"description": "Mark or unmark a user as a system account. System accounts have special permissions for automated operations. Creates audit log entry. Requires USER_UPDATE_BOT_STATUS permission.",
"security": [{"adminApiKey": []}],
"parameters": [
{
"name": "user_id",
"in": "path",
"required": true,
"schema": {"description": "The ID of the user", "allOf": [{"$ref": "#/components/schemas/SnowflakeType"}]},
"description": "The ID of the user"
}
],
"requestBody": {
"required": true,
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/AdminUserSystemStatusRequest"}}}
}
}
},
"/admin/users/{user_id}/traits": {
"put": {
"operationId": "set_admin_user_traits",
@@ -9295,13 +9284,6 @@
},
"required": ["traits"]
},
"AdminUserSystemStatusRequest": {
"type": "object",
"properties": {
"system": {"type": "boolean", "description": "Whether the user should be marked as a system user"}
},
"required": ["system"]
},
"AdminStorePurchaseListResponse": {
"type": "object",
"properties": {
@@ -9456,6 +9438,20 @@
}
}
},
"AdminPasswordResetLinkResponse": {
"type": "object",
"properties": {
"url": {"type": "string", "description": "Password reset link to hand to the user. It is shown only once"},
"expires_at": {
"type": "string",
"format": "date-time",
"pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z))$",
"description": "ISO 8601 timestamp when the link stops working"
}
},
"required": ["url", "expires_at"],
"additionalProperties": false
},
"DeleteAllUserMessagesResponse": {
"type": "object",
"properties": {
@@ -9752,11 +9748,6 @@
"required": ["entries", "next_page_token"],
"additionalProperties": false
},
"AdminUserBotStatusRequest": {
"type": "object",
"properties": {"bot": {"type": "boolean", "description": "Whether the user should be marked as a bot"}},
"required": ["bot"]
},
"AdminUserBanNoteRequest": {
"type": "object",
"properties": {
@@ -9871,7 +9862,7 @@
"type": "object",
"properties": {
"acls": {
"maxItems": 104,
"maxItems": 105,
"type": "array",
"items": {"$ref": "#/components/schemas/AdminAclType"},
"description": "List of access control permissions to assign"
@@ -9999,6 +9990,17 @@
"default": true,
"description": "Whether to notify the reporter by system DM and email",
"type": "boolean"
},
"resolution": {
"description": "How the report was resolved",
"x-enumNames": ["actioned", "no_violation", "duplicate"],
"x-enumDescriptions": [
"The report was valid and action was taken",
"The report was reviewed and no violation was found",
"The report repeats one that was already handled"
],
"enum": ["actioned", "no_violation", "duplicate"],
"type": "string"
}
},
"required": ["status"]
@@ -10669,6 +10671,19 @@
"additionalProperties": false
},
"self_hosted": {"type": "boolean"},
"account_identity": {
"type": "object",
"properties": {
"mode": {
"description": "Sign-in method in effect on this instance",
"allOf": [{"$ref": "#/components/schemas/AccountIdentityModeSchema"}]
},
"locked": {"type": "boolean", "description": "Whether the sign-in method can no longer change"},
"tag_style": {"allOf": [{"$ref": "#/components/schemas/TagStyleSchema"}]}
},
"required": ["mode", "locked", "tag_style"],
"additionalProperties": false
},
"app_public": {
"type": "object",
"properties": {
@@ -10976,6 +10991,7 @@
"experiment_delivery",
"registration",
"self_hosted",
"account_identity",
"app_public",
"policy",
"integrations",
@@ -12325,8 +12341,15 @@
},
"BanCheckResponseSchema": {
"type": "object",
"properties": {"banned": {"type": "boolean"}},
"required": ["banned"],
"properties": {
"banned": {"type": "boolean"},
"expires_at": {
"nullable": true,
"description": "ISO 8601 timestamp when the matching ban expires. Null when the ban is permanent, when nothing matches, and on every blocklist other than ip.",
"type": "string"
}
},
"required": ["banned", "expires_at"],
"additionalProperties": false
},
"AdminBlocklistBulkDeleteRequest": {
@@ -12482,7 +12505,7 @@
},
"value_field": {
"type": "string",
"description": "The request body field that carries the entry value when adding to this blocklist"
"description": "The request body field that holds the entry value when adding to this blocklist"
},
"fields": {
"maxItems": 8,
@@ -12729,7 +12752,7 @@
},
"acls": {
"description": "Replacement list of access control permissions for the key",
"maxItems": 104,
"maxItems": 105,
"type": "array",
"items": {"$ref": "#/components/schemas/AdminAclType"}
}
@@ -12747,7 +12770,7 @@
"type": "string"
},
"acls": {
"maxItems": 104,
"maxItems": 105,
"type": "array",
"items": {"type": "string"},
"description": "List of access control permissions for the key"
@@ -12777,7 +12800,7 @@
"maximum": 365
},
"acls": {
"maxItems": 104,
"maxItems": 105,
"type": "array",
"items": {"$ref": "#/components/schemas/AdminAclType"},
"description": "List of access control permissions for the key"
@@ -12798,7 +12821,7 @@
"type": "string"
},
"acls": {
"maxItems": 104,
"maxItems": 105,
"type": "array",
"items": {"type": "string"},
"description": "List of access control permissions for the key"
@@ -12811,7 +12834,7 @@
"type": "object",
"properties": {
"acls": {
"maxItems": 104,
"maxItems": 105,
"type": "array",
"items": {"type": "string", "minLength": 1, "maxLength": 64},
"description": "Every admin access control permission the admin API recognises"
@@ -12899,6 +12922,8 @@
"report:view:reporter_pii",
"system_dm:send",
"user:cancel:bulk_message_deletion",
"user:create:password_reset_link",
"user:delete:recovery_kit",
"user:delete",
"user:list:dm_channels",
"user:list:guilds",
@@ -12911,7 +12936,6 @@
"user:view:email",
"user:view:ip",
"user:temp_ban",
"user:update:bot_status",
"user:update:dob",
"user:update:email",
"user:update:flags",
@@ -13106,10 +13130,13 @@
"BanUrlDomainRequest": {
"type": "object",
"properties": {
"domain": {"description": "Domain to ban (e.g. example.com)", "type": "string"},
"domain": {
"description": "Domain to ban (e.g. example.com), or a pattern whose leftmost label contains * under a registrable domain (e.g. *shop*.example.com). Internationalized names are stored in ASCII form.",
"type": "string"
},
"match_subdomains": {
"default": true,
"description": "If true, any subdomain rooted at this domain is also banned",
"description": "If true, any subdomain rooted at this domain, or at a host the pattern matches, is also banned",
"type": "boolean"
},
"category": {"description": "Category / source slug (defaults to \"manual\")", "type": "string"},
@@ -13162,7 +13189,15 @@
},
"BanIpRequest": {
"type": "object",
"properties": {"ip": {"description": "IPv4/IPv6 address or CIDR range to ban", "type": "string"}},
"properties": {
"ip": {"description": "IPv4/IPv6 address or CIDR range to ban", "type": "string"},
"duration_hours": {
"description": "Hours until the ban expires and its entry is removed. Omit it or use 0 for a permanent ban.",
"type": "integer",
"minimum": 0,
"maximum": 8760
}
},
"required": ["ip"]
},
"EmailBlocklistEntryType": {"type": "string"},
@@ -13205,7 +13240,7 @@
"properties": {
"match_subdomains": {
"default": true,
"description": "If true, any subdomain rooted at this domain is also banned",
"description": "If true, any subdomain rooted at this domain, or at a host the pattern matches, is also banned",
"type": "boolean"
},
"category": {"description": "Category / source slug (defaults to \"manual\")", "type": "string"},
@@ -13362,6 +13397,7 @@
"description": "Bot requires manual approval for friend requests"
},
{"name": "SPAMMER", "value": "64", "description": "User is flagged as a spammer"},
{"name": "PROFILE_HIDDEN", "value": "128", "description": "User profile details are hidden from other users"},
{"name": "DELETED", "value": "17179869184", "description": "User account has been deleted"},
{"name": "SELF_DELETED", "value": "68719476736", "description": "User account was self-deleted"},
{"name": "DISABLED", "value": "274877906944", "description": "User account is disabled"},
@@ -13542,7 +13578,7 @@
"additionalProperties": false
},
"referenced_message": {
"description": "The reply target. Present and populated when the target resolved, present and null when the target is gone, absent when this message carries no default reference. Clients must tell null apart from absent by key presence.",
"description": "The reply target. Present and populated when the target resolved, present and null when the target is gone, absent when this message has no default reference. Clients must tell null apart from absent by key presence.",
"nullable": true,
"type": "object",
"properties": {
@@ -14229,7 +14265,8 @@
"value": "32",
"description": "Bot requires manual approval for friend requests"
},
{"name": "SPAMMER", "value": "64", "description": "User is flagged as a spammer"}
{"name": "SPAMMER", "value": "64", "description": "User is flagged as a spammer"},
{"name": "PROFILE_HIDDEN", "value": "128", "description": "User profile details are hidden"}
]
},
"MessageEmbedChildResponse": {
@@ -15346,6 +15383,23 @@
],
"additionalProperties": false
},
"TagStyleSchema": {
"description": "How usernames are tagged",
"x-enumNames": ["NONE", "RANDOM"],
"x-enumDescriptions": ["Usernames are unique and shown without a tag", "Every account gets a random tag"],
"enum": ["none", "random"],
"type": "string"
},
"AccountIdentityModeSchema": {
"description": "How people identify themselves when they sign in",
"x-enumNames": ["EMAIL", "USERNAME"],
"x-enumDescriptions": [
"People sign in with an email address",
"People sign in with a username and no email is collected"
],
"enum": ["email", "username"],
"type": "string"
},
"ExperimentDeliveryConfigResponse": {
"type": "object",
"properties": {
@@ -15629,7 +15683,7 @@
"description": "ISO 8601 timestamp when the pending deletion was scheduled",
"type": "string"
},
"acls": {"maxItems": 104, "type": "array", "items": {"type": "string"}},
"acls": {"maxItems": 105, "type": "array", "items": {"type": "string"}},
"traits": {"maxItems": 100, "type": "array", "items": {"type": "string"}},
"has_totp": {"type": "boolean"},
"authenticator_types": {"maxItems": 10, "type": "array", "items": {"$ref": "#/components/schemas/Int32Type"}},
+4 -2
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";
@@ -88,7 +90,6 @@ pub const USER_VIEW_DOB: &str = "user:view:dob";
pub const USER_VIEW_EMAIL: &str = "user:view:email";
pub const USER_VIEW_IP: &str = "user:view:ip";
pub const USER_TEMP_BAN: &str = "user:temp_ban";
pub const USER_UPDATE_BOT_STATUS: &str = "user:update:bot_status";
pub const USER_UPDATE_DOB: &str = "user:update:dob";
pub const USER_UPDATE_EMAIL: &str = "user:update:email";
pub const USER_UPDATE_FLAGS: &str = "user:update:flags";
@@ -181,6 +182,8 @@ pub const ALL_ACLS: &[&str] = &[
REPORT_VIEW_REPORTER_PII,
SYSTEM_DM_SEND,
USER_CANCEL_BULK_MESSAGE_DELETION,
USER_CREATE_PASSWORD_RESET_LINK,
USER_DELETE_RECOVERY_KIT,
USER_DELETE,
USER_LIST_DM_CHANNELS,
USER_LIST_GUILDS,
@@ -193,7 +196,6 @@ pub const ALL_ACLS: &[&str] = &[
USER_VIEW_EMAIL,
USER_VIEW_IP,
USER_TEMP_BAN,
USER_UPDATE_BOT_STATUS,
USER_UPDATE_DOB,
USER_UPDATE_EMAIL,
USER_UPDATE_FLAGS,
+30 -3
View File
@@ -3,7 +3,7 @@
use crate::api::generated::{snowflake, types as generated_types};
use super::client::{AdminApiClient, ApiError, ApiResult};
use super::types::{BanAvatarResult, BanCheckResult, BulkBanResult};
use super::types::{BanAvatarResult, BanCheckResult, BlocklistEntryPage, BulkBanResult};
impl AdminApiClient {
pub async fn ban_email(&self, email: &str, audit_log_reason: Option<&str>) -> ApiResult<()> {
@@ -28,11 +28,23 @@ impl AdminApiClient {
self.check_blocklist_entry("email", email, None).await
}
pub async fn ban_ip(&self, ip: &str, audit_log_reason: Option<&str>) -> ApiResult<()> {
pub async fn ban_ip(
&self,
ip: &str,
duration_hours: u32,
audit_log_reason: Option<&str>,
) -> ApiResult<()> {
self.create_blocklist_entry(
"ip",
generated_types::AdminBlocklistEntryCreateRequest::from(
generated_types::BanIpRequest { ip: ip.to_owned() },
generated_types::BanIpRequest {
duration_hours: Some(
i32::try_from(duration_hours)
.map_err(|e| ApiError::Parse(e.to_string()))?
.into(),
),
ip: ip.to_owned(),
},
),
audit_log_reason,
)
@@ -136,6 +148,19 @@ impl AdminApiClient {
self.check_blocklist_entry("url-domain", domain, None).await
}
pub async fn list_url_domain_entries(
&self,
after: Option<&str>,
) -> ApiResult<BlocklistEntryPage> {
let list_type = blocklist_list_type("url-domain")?;
let response = self
.generated()
.list_admin_blocklist_entries(list_type, after, Some(BLOCKLIST_PAGE_SIZE), None)
.await
.map_err(|e| self.generated_error(e))?;
self.generated_value(response.into_inner())
}
pub async fn ban_file_sha(
&self,
sha256_hex: &str,
@@ -323,6 +348,8 @@ impl AdminApiClient {
const PROFILE_SUBSTRING_LIST: &str = "profile-substring";
const BLOCKLIST_PAGE_SIZE: &str = "200";
fn blocklist_list_type(list_type: &str) -> ApiResult<generated_types::AdminBlocklistListType> {
generated_types::AdminBlocklistListType::try_from(list_type)
.map_err(|e| ApiError::Parse(e.to_string()))
+1 -1
View File
@@ -432,7 +432,7 @@ mod tests {
use serde_json::{Value, json};
#[test]
fn audit_log_reason_header_carries_utf8_bytes() {
fn audit_log_reason_header_keeps_utf8_bytes() {
let reason = "§ 3 Regel – wiederholt 日本";
let value = audit_log_reason_header(reason).expect("valid reason header");
assert_eq!(value.as_bytes(), reason.as_bytes());
+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,
+21
View File
@@ -255,9 +255,30 @@ pub enum FlashLevel {
pub struct BanCheckResult {
pub banned: bool,
#[serde(default)]
pub expires_at: Option<String>,
#[serde(default)]
pub entries: Vec<serde_json::Value>,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct BlocklistEntry {
pub value: String,
#[serde(default)]
pub match_subdomains: Option<bool>,
#[serde(default)]
pub category: Option<String>,
#[serde(default)]
pub created_at: Option<String>,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct BlocklistEntryPage {
pub items: Vec<BlocklistEntry>,
pub has_more: bool,
#[serde(default)]
pub next_after: Option<String>,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct BulkBanResult {
pub job_id: String,
@@ -13,6 +13,8 @@ pub struct InstanceConfigResponse {
#[serde(default)]
pub self_hosted: bool,
#[serde(default)]
pub account_identity: AccountIdentityConfigResponse,
#[serde(default)]
pub app_public: AppPublicConfigResponse,
#[serde(default)]
pub policy: InstancePolicyResponse,
@@ -34,6 +36,79 @@ pub struct InstanceConfigResponse {
pub billing: InstanceBillingResponse,
}
#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum AccountIdentityMode {
#[default]
Email,
Username,
}
impl AccountIdentityMode {
pub fn is_username(self) -> bool {
matches!(self, Self::Username)
}
pub fn label(self) -> &'static str {
match self {
Self::Email => "Email",
Self::Username => "Username",
}
}
}
#[derive(Clone, Copy, Debug, Default, Deserialize, Serialize, Eq, PartialEq)]
#[serde(rename_all = "snake_case")]
pub enum TagStyle {
None,
#[default]
#[serde(other)]
Random,
}
impl TagStyle {
pub fn is_none(self) -> bool {
matches!(self, Self::None)
}
pub fn label(self) -> &'static str {
match self {
Self::None => "No tags",
Self::Random => "Random tags",
}
}
}
#[derive(Clone, Copy, Debug, Default, Deserialize, Serialize)]
pub struct AccountIdentityConfigResponse {
#[serde(default)]
pub mode: AccountIdentityMode,
#[serde(default)]
pub locked: Option<bool>,
#[serde(default)]
pub tag_style: TagStyle,
}
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
pub struct AccountIdentitySettings {
pub mode: AccountIdentityMode,
pub tag_style: TagStyle,
}
#[derive(Clone, Debug, Default, Deserialize)]
pub struct InstanceAccountIdentityDiscovery {
#[serde(default)]
pub features: InstanceAccountIdentityDiscoveryFeatures,
}
#[derive(Clone, Debug, Default, Deserialize)]
pub struct InstanceAccountIdentityDiscoveryFeatures {
#[serde(default)]
pub account_identity: AccountIdentityMode,
#[serde(default)]
pub tag_style: TagStyle,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct InstancePolicyResponse {
#[serde(default)]
@@ -232,3 +232,9 @@ pub struct WebAuthnCredential {
}
pub type WebAuthnCredentialListResponse = Vec<WebAuthnCredential>;
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct PasswordResetLinkResponse {
pub url: String,
pub expires_at: String,
}
+22 -23
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 {
@@ -305,28 +306,6 @@ impl AdminApiClient {
Ok(resp.user)
}
pub async fn set_bot_status(&self, user_id: &str, is_bot: bool) -> ApiResult<AdminUser> {
let body = generated_types::AdminUserBotStatusRequest { bot: is_bot };
let response = self
.generated()
.set_admin_user_bot_status(&snowflake(user_id), &body)
.await
.map_err(|e| self.generated_error(e))?;
let resp: UserMutationResponse = self.generated_value(response.into_inner())?;
Ok(resp.user)
}
pub async fn set_system_status(&self, user_id: &str, is_system: bool) -> ApiResult<AdminUser> {
let body = generated_types::AdminUserSystemStatusRequest { system: is_system };
let response = self
.generated()
.set_admin_user_system_status(&snowflake(user_id), &body)
.await
.map_err(|e| self.generated_error(e))?;
let resp: UserMutationResponse = self.generated_value(response.into_inner())?;
Ok(resp.user)
}
pub async fn change_username(
&self,
user_id: &str,
@@ -495,6 +474,26 @@ impl AdminApiClient {
Ok(())
}
pub async fn create_password_reset_link(
&self,
user_id: &str,
) -> ApiResult<PasswordResetLinkResponse> {
let response = self
.generated()
.create_admin_user_password_reset_link(&snowflake(user_id))
.await
.map_err(|e| self.generated_error(e))?;
self.generated_value(response.into_inner())
}
pub async fn revoke_recovery_kit(&self, user_id: &str) -> ApiResult<()> {
self.generated()
.revoke_admin_user_recovery_kit(&snowflake(user_id))
.await
.map_err(|e| self.generated_error(e))?;
Ok(())
}
pub async fn remove_relationship(
&self,
user_id: &str,
@@ -0,0 +1,25 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use crate::{
api::client::AdminApiClient, middleware::auth::AuthContext, state::AppState,
utils::user_tag::with_unique_usernames,
};
use axum::{
extract::{Request, State},
middleware::Next,
response::Response,
};
pub async fn scope_account_identity(
State(state): State<AppState>,
request: Request,
next: Next,
) -> Response {
let Some(auth) = request.extensions().get::<AuthContext>() else {
return next.run(request).await;
};
let client = AdminApiClient::new(state.http_client(), state.config(), &auth.session);
let settings = state.account_identity_settings(&client).await;
let unique_usernames = settings.mode.is_username() || settings.tag_style.is_none();
with_unique_usernames(unique_usernames, next.run(request)).await
}
+1
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;
+107 -44
View File
@@ -1,7 +1,10 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use crate::{
api::client::AdminApiClient,
api::{
client::{AdminApiClient, ApiError},
types::FlashMessage,
},
middleware::{auth::AuthContext, csrf, htmx},
state::AppState,
templates,
@@ -13,10 +16,12 @@ use axum::{
response::{Html, IntoResponse, Response},
routing::get,
};
use serde::Deserialize;
use super::ActionQuery;
use super::bans_actions::{
BanFormData, custom_flash, execute_ban, extract_value, flash_response, render_inline_flash,
to_flash,
};
pub fn router() -> Router<AppState> {
@@ -43,17 +48,41 @@ pub fn router() -> Router<AppState> {
)
}
fn render_ban_page(state: &AppState, auth: &AuthContext, key: &str, req: &Request) -> Response {
async fn render_ban_page(
state: &AppState,
auth: &AuthContext,
key: &str,
csrf_token: String,
) -> Response {
let config = state.config();
let ban_cfg = match templates::pages::bans::get_ban_config(key) {
Some(c) => c,
None => return axum::http::StatusCode::NOT_FOUND.into_response(),
};
let csrf_token = csrf::get_csrf_token(req);
let markup = templates::pages::bans::bans_page(config, auth, ban_cfg, None, &csrf_token);
let username_sign_in = email_bans_on_username_instance(state, auth, key).await;
let markup = templates::pages::bans::bans_page(
config,
auth,
ban_cfg,
None,
&csrf_token,
username_sign_in,
);
Html(markup.into_string()).into_response()
}
async fn email_bans_on_username_instance(state: &AppState, auth: &AuthContext, key: &str) -> bool {
key == "email-bans"
&& state
.account_identity(&AdminApiClient::new(
state.http_client(),
state.config(),
&auth.session,
))
.await
.is_username()
}
macro_rules! ban_get {
($name:ident, $key:expr) => {
async fn $name(
@@ -61,7 +90,8 @@ macro_rules! ban_get {
auth: axum::Extension<AuthContext>,
request: Request,
) -> Response {
render_ban_page(&state, &auth.0, $key, &request)
let csrf_token = csrf::get_csrf_token(&request);
render_ban_page(&state, &auth.0, $key, csrf_token).await
}
};
}
@@ -90,17 +120,18 @@ async fn generic_ban_post(
};
let value = extract_value(form, ban_cfg.input_name);
let is_htmx = htmx::is_htmx_request(headers);
let (level, msg) = execute_ban(
&client,
ban_key,
action,
&value,
form.hashes.as_deref(),
form.sha256_list.as_deref(),
form.audit_log_reason.as_deref(),
let (level, msg) = execute_ban(&client, ban_key, action, &value, form).await;
let username_sign_in = !is_htmx && email_bans_on_username_instance(state, auth, ban_key).await;
flash_response(
config,
auth,
is_htmx,
level,
&msg,
ban_cfg,
csrf_token,
username_sign_in,
)
.await;
flash_response(config, auth, is_htmx, level, &msg, ban_cfg, csrf_token)
}
macro_rules! ban_post {
@@ -118,14 +149,18 @@ macro_rules! ban_post {
let form: BanFormData = match Form::from_request(request, &state).await {
Ok(Form(f)) => f,
Err(_) => {
let is_htmx = htmx::is_htmx_request(&headers);
let username_sign_in =
!is_htmx && email_bans_on_username_instance(&state, &auth.0, $key).await;
return flash_response(
state.config(),
&auth.0,
htmx::is_htmx_request(&headers),
is_htmx,
"error",
"Invalid form data",
templates::pages::bans::get_ban_config($key).unwrap(),
&csrf_token,
username_sign_in,
);
}
};
@@ -141,16 +176,56 @@ ban_post!(url_bans_post, "url-bans");
ban_post!(file_sha_bans_post, "file-sha-bans");
ban_post!(avatar_hash_bans_post, "avatar-hash-bans");
#[derive(Deserialize)]
struct UrlDomainListQuery {
after: Option<String>,
}
async fn render_url_domain_page(
state: &AppState,
auth: &AuthContext,
flash: Option<&FlashMessage>,
csrf_token: &str,
after: Option<&str>,
) -> Response {
let config = state.config();
let client = AdminApiClient::new(state.http_client(), config, &auth.session);
let entries = match client.list_url_domain_entries(after).await {
Ok(page) => Some(page),
Err(error) => {
tracing::warn!(%error, "admin API request failed: list URL domain blocklist");
None
}
};
let markup = templates::pages::url_domain_bans::url_domain_bans_page(
config,
auth,
flash,
csrf_token,
entries.as_ref(),
);
Html(markup.into_string()).into_response()
}
fn ban_url_domain_error(domain: &str, error: &ApiError) -> String {
match error {
ApiError::Http { status: 400, .. } => {
format!("Failed to ban {domain}: not a valid domain, or the pattern is too broad")
}
_ => format!("Failed to ban {domain}"),
}
}
async fn url_domain_bans(
State(state): State<AppState>,
auth: axum::Extension<AuthContext>,
request: Request,
) -> Response {
let config = state.config();
let csrf_token = csrf::get_csrf_token(&request);
let markup =
templates::pages::url_domain_bans::url_domain_bans_page(config, &auth.0, None, &csrf_token);
Html(markup.into_string()).into_response()
let Query(query): Query<UrlDomainListQuery> =
Query::try_from_uri(request.uri()).unwrap_or(Query(UrlDomainListQuery { after: None }));
let after = query.after.as_deref().filter(|value| !value.is_empty());
render_url_domain_page(&state, &auth.0, None, &csrf_token, after).await
}
async fn url_domain_bans_post(
@@ -181,10 +256,10 @@ async fn url_domain_bans_post(
.ban_url_domain(&domain, m_sub, form.audit_log_reason.as_deref())
.await
{
Ok(()) => ("success", format!("Domain {domain} banned successfully")),
Ok(()) => ("success", format!("{domain} banned successfully")),
Err(error) => {
tracing::warn!(%error, domain, "admin API request failed: ban URL domain");
("error", format!("Failed to ban domain {domain}"))
("error", ban_url_domain_error(&domain, &error))
}
}
}
@@ -192,15 +267,15 @@ async fn url_domain_bans_post(
.unban_url_domain(&domain, form.audit_log_reason.as_deref())
.await
{
Ok(()) => ("success", format!("Domain {domain} unbanned")),
Ok(()) => ("success", format!("{domain} unbanned")),
Err(error) => {
tracing::warn!(%error, domain, "admin API request failed: unban URL domain");
("error", format!("Failed to unban domain {domain}"))
("error", format!("Failed to unban {domain}"))
}
},
"check" => match client.check_url_domain_ban(&domain).await {
Ok(r) if r.banned => ("info", format!("Domain {domain} is banned")),
Ok(_) => ("info", format!("Domain {domain} is NOT banned")),
Ok(r) if r.banned => ("info", format!("{domain} is blocked")),
Ok(_) => ("info", format!("{domain} is NOT blocked")),
Err(error) => {
tracing::warn!(%error, domain, "admin API request failed: check URL domain ban");
("error", "Error checking ban status".into())
@@ -208,15 +283,11 @@ async fn url_domain_bans_post(
},
_ => ("error", "Unknown action".into()),
};
custom_flash(
config,
&auth.0,
is_htmx,
level,
&msg,
&csrf_token,
"url-domain",
)
if is_htmx {
return render_inline_flash(level, &msg);
}
let flash = to_flash(level, &msg);
render_url_domain_page(&state, &auth.0, Some(&flash), &csrf_token, None).await
}
async fn profile_substring_bans(
@@ -288,13 +359,5 @@ async fn profile_substring_bans_post(
},
_ => ("error", "Unknown action".into()),
};
custom_flash(
config,
&auth.0,
is_htmx,
level,
&msg,
&csrf_token,
"profile-substring",
)
custom_flash(config, &auth.0, is_htmx, level, &msg, &csrf_token)
}
+58 -22
View File
@@ -34,6 +34,8 @@ pub struct BanFormData {
#[serde(default)]
pub substring: Option<String>,
#[serde(default)]
pub duration_hours: Option<String>,
#[serde(default)]
pub audit_log_reason: Option<String>,
#[serde(default)]
pub _csrf: Option<String>,
@@ -58,10 +60,12 @@ pub async fn execute_ban(
ban_type: &str,
action: &str,
value: &str,
bulk_hashes: Option<&str>,
bulk_sha256_list: Option<&str>,
audit_log_reason: Option<&str>,
form: &BanFormData,
) -> (&'static str, String) {
let bulk_hashes = form.hashes.as_deref();
let bulk_sha256_list = form.sha256_list.as_deref();
let duration_hours = form.duration_hours.as_deref();
let audit_log_reason = form.audit_log_reason.as_deref();
if (action == "bulk-ban" || action == "bulk-ban-files") && ban_type == "file-sha-bans" {
let raw_hashes = if action == "bulk-ban-files" {
bulk_sha256_list
@@ -74,6 +78,9 @@ pub async fn execute_ban(
return ("error", "Value is required".into());
}
match action {
"ban" if ban_type == "ip-bans" => {
execute_ip_ban(client, value, duration_hours, audit_log_reason).await
}
"ban" => execute_single_ban(client, ban_type, value, audit_log_reason).await,
"unban" => execute_single_unban(client, ban_type, value, audit_log_reason).await,
"check" => execute_check(client, ban_type, value).await,
@@ -107,6 +114,34 @@ async fn execute_bulk_ban(
}
}
async fn execute_ip_ban(
client: &AdminApiClient,
value: &str,
duration_hours: Option<&str>,
audit_log_reason: Option<&str>,
) -> (&'static str, String) {
let duration_hours = match duration_hours.map(str::trim).filter(|v| !v.is_empty()) {
None => 0,
Some(raw) => match raw.parse::<u32>() {
Ok(hours) => hours,
Err(_) => return ("error", "Invalid ban duration".into()),
},
};
let success_message = if duration_hours == 0 {
format!("{value} banned permanently")
} else {
format!(
"{value} banned for {}",
crate::templates::pages::bans::ip_ban_duration_label(duration_hours)
)
};
ban_action_result(
client.ban_ip(value, duration_hours, audit_log_reason).await,
success_message,
format!("Failed to ban {value}"),
)
}
async fn execute_single_ban(
client: &AdminApiClient,
ban_type: &str,
@@ -114,7 +149,6 @@ async fn execute_single_ban(
audit_log_reason: Option<&str>,
) -> (&'static str, String) {
let result = match ban_type {
"ip-bans" => client.ban_ip(value, audit_log_reason).await,
"email-bans" => client.ban_email(value, audit_log_reason).await,
"phrase-bans" => client.ban_phrase(value, audit_log_reason).await,
"url-bans" => client.ban_url(value, audit_log_reason).await,
@@ -166,7 +200,10 @@ async fn execute_check(
_ => return ("error", "Unknown ban type".into()),
};
match result {
Ok(r) if r.banned => ("info", format!("{value} is banned")),
Ok(r) if r.banned => match r.expires_at {
Some(expires_at) => ("info", format!("{value} is banned until {expires_at}")),
None => ("info", format!("{value} is banned")),
},
Ok(_) => ("info", format!("{value} is NOT banned")),
Err(error) => {
tracing::warn!(%error, ban_type, value, "admin API request failed: check ban status");
@@ -189,6 +226,7 @@ fn ban_action_result(
}
}
#[allow(clippy::too_many_arguments)]
pub fn flash_response(
config: &crate::config::AdminConfig,
auth: &AuthContext,
@@ -197,13 +235,20 @@ pub fn flash_response(
message: &str,
ban_cfg: &templates::pages::bans::BanConfig,
csrf_token: &str,
username_sign_in: bool,
) -> Response {
if is_htmx {
render_inline_flash(level, message)
} else {
let flash = to_flash(level, message);
let markup =
templates::pages::bans::bans_page(config, auth, ban_cfg, Some(&flash), csrf_token);
let markup = templates::pages::bans::bans_page(
config,
auth,
ban_cfg,
Some(&flash),
csrf_token,
username_sign_in,
);
Html(markup.into_string()).into_response()
}
}
@@ -243,25 +288,16 @@ pub fn custom_flash(
level: &str,
message: &str,
csrf_token: &str,
page_type: &str,
) -> Response {
if is_htmx {
return render_inline_flash(level, message);
}
let flash = to_flash(level, message);
let markup = match page_type {
"url-domain" => templates::pages::url_domain_bans::url_domain_bans_page(
config,
auth,
Some(&flash),
csrf_token,
),
_ => templates::pages::profile_substring_bans::profile_substring_bans_page(
config,
auth,
Some(&flash),
csrf_token,
),
};
let markup = templates::pages::profile_substring_bans::profile_substring_bans_page(
config,
auth,
Some(&flash),
csrf_token,
);
Html(markup.into_string()).into_response()
}
+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(
+9 -17
View File
@@ -178,22 +178,6 @@ pub async fn dispatch(
"Failed to clear user fields",
)
}
"set_bot_status" => {
let val = form.bool_value("bot");
DispatchOutcome::from_result(
client.set_bot_status(user_id, val).await,
"Bot status updated successfully",
"Failed to update bot status",
)
}
"set_system_status" => {
let val = form.bool_value("system");
DispatchOutcome::from_result(
client.set_system_status(user_id, val).await,
"System status updated successfully",
"Failed to update system status",
)
}
"change_username" => {
let Some(username) = get("username") else {
return DispatchOutcome::error("Username is required");
@@ -259,8 +243,11 @@ pub async fn dispatch(
let Some(ip) = get("ip") else {
return DispatchOutcome::error("IP address is required");
};
let Ok(duration) = form.parse_value::<u32>("duration_hours") else {
return DispatchOutcome::error("Invalid ban duration");
};
DispatchOutcome::from_result(
client.ban_ip(&ip, None).await,
client.ban_ip(&ip, duration.unwrap_or(0), None).await,
"IP banned successfully",
"Failed to ban IP",
)
@@ -380,6 +367,11 @@ pub async fn dispatch(
"Password reset sent successfully",
"Failed to send password reset",
),
"revoke_recovery_kit" => DispatchOutcome::from_result(
client.revoke_recovery_kit(user_id).await,
"Recovery kit revoked",
"Failed to revoke recovery kit",
),
"remove_relationship" => {
let Some(target_id) = get("target_user_id").or_else(|| get("target_id")) else {
return DispatchOutcome::error("Target user ID is required");
+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();
+54 -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,
},
};
@@ -20,6 +20,24 @@ pub struct BanConfig {
pub entity_name: &'static str,
pub active_page: &'static str,
pub show_bulk_tools: bool,
pub show_duration: bool,
}
const IP_BAN_DURATIONS: &[(u32, &str)] = &[
(24, "1 day"),
(168, "7 days"),
(720, "30 days"),
(0, "Permanent"),
];
pub fn ip_ban_duration_label(hours: u32) -> String {
IP_BAN_DURATIONS
.iter()
.find(|(value, _)| *value == hours)
.map_or_else(
|| format!("{hours} hours"),
|(_, label)| (*label).to_owned(),
)
}
pub const BAN_CONFIGS: &[BanConfig] = &[
@@ -33,6 +51,7 @@ pub const BAN_CONFIGS: &[BanConfig] = &[
entity_name: "IP/CIDR",
active_page: "ip-bans",
show_bulk_tools: false,
show_duration: true,
},
BanConfig {
title: "Email Bans",
@@ -44,6 +63,7 @@ pub const BAN_CONFIGS: &[BanConfig] = &[
entity_name: "Email",
active_page: "email-bans",
show_bulk_tools: false,
show_duration: false,
},
BanConfig {
title: "Phrase Bans",
@@ -55,6 +75,7 @@ pub const BAN_CONFIGS: &[BanConfig] = &[
entity_name: "Phrase",
active_page: "phrase-bans",
show_bulk_tools: false,
show_duration: false,
},
BanConfig {
title: "URL Blocklist",
@@ -66,6 +87,7 @@ pub const BAN_CONFIGS: &[BanConfig] = &[
entity_name: "URL",
active_page: "url-bans",
show_bulk_tools: false,
show_duration: false,
},
BanConfig {
title: "File SHA Blocklist",
@@ -77,6 +99,7 @@ pub const BAN_CONFIGS: &[BanConfig] = &[
entity_name: "SHA-256",
active_page: "file-sha-bans",
show_bulk_tools: true,
show_duration: false,
},
BanConfig {
title: "Avatar Hash Blocklist",
@@ -88,6 +111,7 @@ pub const BAN_CONFIGS: &[BanConfig] = &[
entity_name: "Avatar Hash",
active_page: "avatar-hash-bans",
show_bulk_tools: false,
show_duration: false,
},
BanConfig {
title: "URL Domain Blocklist",
@@ -99,6 +123,7 @@ pub const BAN_CONFIGS: &[BanConfig] = &[
entity_name: "Domain",
active_page: "url-domain-bans",
show_bulk_tools: false,
show_duration: false,
},
BanConfig {
title: "Profile Substring Blocklist",
@@ -110,6 +135,7 @@ pub const BAN_CONFIGS: &[BanConfig] = &[
entity_name: "Substring",
active_page: "profile-substring-bans",
show_bulk_tools: false,
show_duration: false,
},
];
@@ -123,10 +149,16 @@ pub fn bans_page(
ban_cfg: &BanConfig,
flash: Option<&crate::api::types::FlashMessage>,
csrf_token: &str,
username_sign_in: bool,
) -> Markup {
let base = &config.base_path;
let content = html! {
(page_header(ban_cfg.title, None))
@if username_sign_in && ban_cfg.active_page == "email-bans" {
div class="mb-6" {
(alert_info(html! { "People sign in with a username on this instance. Accounts have no email address, so email bans have no effect." }))
}
}
div class="grid gap-6 lg:grid-cols-2" {
(ban_card(base, ban_cfg, csrf_token))
(check_ban_card(base, ban_cfg, csrf_token))
@@ -163,6 +195,9 @@ fn ban_card(base: &str, cfg: &BanConfig, csrf_token: &str) -> Markup {
(csrf_input(csrf_token))
div class="space-y-4" {
(form_field(cfg.input_name, cfg.input_label, cfg.input_type, cfg.placeholder, true))
@if cfg.show_duration {
(duration_field())
}
(form_field("audit_log_reason", "Private reason (audit log, optional)", "text", "Why is this ban being applied?", false))
(submit_btn("Ban", cfg.entity_name, false))
}
@@ -394,6 +429,24 @@ fn form_field(
}
}
fn duration_field() -> Markup {
html! {
div class="space-y-1" {
label for="duration_hours" class="block text-sm font-medium text-neutral-700" {
"Duration"
}
select id="duration_hours" name="duration_hours"
class="block w-full rounded-md border border-neutral-300 px-3 py-2 text-sm \
shadow-sm focus:border-brand-primary focus:outline-none focus:ring-1 \
focus:ring-brand-primary" {
@for &(value, label) in IP_BAN_DURATIONS {
option value=(value) { (label) }
}
}
}
}
}
fn textarea_field(name: &str, label: &str, required: bool) -> Markup {
html! {
div class="space-y-1" {
@@ -177,7 +177,12 @@ fn guild_feature_label(feature: &str) -> String {
}
}
pub fn bulk_actions_page(config: &AdminConfig, auth: &AuthContext, csrf_token: &str) -> Markup {
pub fn bulk_actions_page(
config: &AdminConfig,
auth: &AuthContext,
csrf_token: &str,
username_sign_in: bool,
) -> Markup {
let base = &config.base_path;
let admin_acls = auth
.admin_user
@@ -198,7 +203,7 @@ pub fn bulk_actions_page(config: &AdminConfig, auth: &AuthContext, csrf_token: &
(bulk_add_guild_members_section(base, csrf_token))
}
@if acl::has_permission(admin_acls, acl::BULK_DELETE_USERS) {
(bulk_schedule_deletion_section(base, csrf_token))
(bulk_schedule_deletion_section(base, csrf_token, username_sign_in))
}
@if acl::has_permission(admin_acls, acl::BULK_DELETE_USER_MESSAGES) {
(bulk_delete_user_messages_section(base, csrf_token))
@@ -331,7 +336,7 @@ fn bulk_add_guild_members_section(base: &str, csrf_token: &str) -> Markup {
)
}
fn bulk_schedule_deletion_section(base: &str, csrf_token: &str) -> Markup {
fn bulk_schedule_deletion_section(base: &str, csrf_token: &str, username_sign_in: bool) -> Markup {
section_card_simple(
"Bulk Schedule User Deletion",
html! {
@@ -370,7 +375,11 @@ fn bulk_schedule_deletion_section(base: &str, csrf_token: &str) -> Markup {
},
))
(text_input("audit_log_reason", "Audit Log Reason (optional)", "", "Reason for this bulk operation"))
(opt_out_checkbox("notify_user", "Email each user about the scheduled deletion"))
@if username_sign_in {
input type="hidden" name="notify_user_present" value="1";
} @else {
(opt_out_checkbox("notify_user", "Email each user about the scheduled deletion"))
}
(form_actions(html! {
(danger_button("Schedule Deletion"))
}))
@@ -416,7 +425,7 @@ mod tests {
#[test]
fn deletion_form_has_no_preselected_reason() {
let markup = bulk_schedule_deletion_section("/admin", "csrf").into_string();
let markup = bulk_schedule_deletion_section("/admin", "csrf", false).into_string();
assert!(markup.contains(r#"<option value="" selected>Select a reason</option>"#));
for (value, _) in DELETION_REASONS {
assert!(!markup.contains(&format!(r#"<option value="{value}" selected>"#)));
@@ -425,17 +434,25 @@ mod tests {
#[test]
fn deletion_form_defaults_to_the_moderation_retention_floor() {
let markup = bulk_schedule_deletion_section("/admin", "csrf").into_string();
let markup = bulk_schedule_deletion_section("/admin", "csrf", false).into_string();
assert!(markup.contains(r#"name="days_until_deletion" value="60" min="14" max="365""#));
}
#[test]
fn deletion_form_emails_each_user_by_default() {
let markup = bulk_schedule_deletion_section("/admin", "csrf").into_string();
let markup = bulk_schedule_deletion_section("/admin", "csrf", false).into_string();
assert!(markup.contains(r#"name="notify_user" value="true" checked"#));
assert!(markup.contains(r#"name="notify_user_present" value="1""#));
}
#[test]
fn username_mode_hides_the_email_choices() {
let deletion = bulk_schedule_deletion_section("/admin", "csrf", true).into_string();
assert!(!deletion.contains("Email each user"));
assert!(!deletion.contains(r#"name="notify_user" value="true""#));
assert!(deletion.contains(r#"name="notify_user_present" value="1""#));
}
#[test]
fn remove_grid_can_clear_the_deprecated_clone_features() {
let markup = guild_feature_checkbox_grid("remove_features[]", true).into_string();
@@ -18,6 +18,7 @@ use crate::{
},
utils::bigint::format_discriminator,
utils::timestamps::format_admin_timestamp,
utils::user_tag::user_tag,
};
use maud::{Markup, html};
@@ -74,7 +75,7 @@ fn owner_display(
let Some(discriminator) = discriminator else {
return owner_id.to_owned();
};
let tag = format!("{username}#{}", format_discriminator(discriminator));
let tag = user_tag(username, &format_discriminator(discriminator), false);
match global_name.filter(|value| !value.trim().is_empty()) {
Some(global_name) => format!("{global_name} ({tag})"),
None => tag,
@@ -31,6 +31,7 @@ pub const GUILD_TABS: &[(&str, &str)] = &[
("reports", "Reports"),
];
#[allow(clippy::too_many_arguments)]
pub fn guild_detail_with_tab(
config: &AdminConfig,
auth: &AuthContext,
@@ -39,9 +40,12 @@ pub fn guild_detail_with_tab(
active_tab: &str,
tab_body: Option<Markup>,
is_htmx: bool,
username_sign_in: bool,
) -> Markup {
let content = match guild {
Some(guild) => render_guild_detail(config, auth, guild, active_tab, tab_body),
Some(guild) => {
render_guild_detail(config, auth, guild, active_tab, tab_body, username_sign_in)
}
None => not_found_state("Guild", guild_id, None, None),
};
let title = if guild.is_some() {
@@ -62,6 +66,7 @@ pub fn simple_tab_content(
tab: &str,
csrf_token: &str,
admin_acls: &[String],
username_sign_in: bool,
) -> Markup {
let guild_info = GuildInfo::from(guild.clone());
match tab {
@@ -69,9 +74,13 @@ pub fn simple_tab_content(
"features" => {
guild_detail_tabs::features::features_tab(config, &guild_info, csrf_token, admin_acls)
}
"settings" => {
guild_detail_tabs::settings::settings_tab(config, guild, csrf_token, admin_acls)
}
"settings" => guild_detail_tabs::settings::settings_tab(
config,
guild,
csrf_token,
admin_acls,
username_sign_in,
),
"moderation" => guild_detail_tabs::moderation::moderation_tab(
config,
&guild_info,
@@ -92,6 +101,7 @@ fn render_guild_detail(
guild: &GuildDetailInfo,
active_tab: &str,
tab_body: Option<Markup>,
username_sign_in: bool,
) -> Markup {
let base = &config.base_path;
let admin_acls = auth
@@ -111,8 +121,16 @@ fn render_guild_detail(
effective_tab,
|tab_id| guild_tab_visible(config, tab_id, admin_acls),
);
let body = tab_body
.unwrap_or_else(|| simple_tab_content(config, guild, effective_tab, "", admin_acls));
let body = tab_body.unwrap_or_else(|| {
simple_tab_content(
config,
guild,
effective_tab,
"",
admin_acls,
username_sign_in,
)
});
html! {
div class="space-y-6" {
a href={(base) "/guilds"}
@@ -4,7 +4,9 @@ use crate::{
api::types::{GuildAuditLogEntry, GuildAuditLogUser, GuildInfo},
config::AdminConfig,
templates::components::{page_container::card_with_header, table::data_table},
utils::{bigint::format_discriminator, timestamps::snowflake_creation_date},
utils::{
bigint::format_discriminator, timestamps::snowflake_creation_date, user_tag::user_tag,
},
};
use maud::{Markup, html};
@@ -120,7 +122,7 @@ fn format_user(user: Option<&GuildAuditLogUser>) -> String {
};
let disc = u.discriminator.as_deref().unwrap_or("0000");
let disc = format_discriminator(disc);
let tag = format!("{}#{}", u.username, disc);
let tag = user_tag(&u.username, &disc, false);
match &u.global_name {
Some(gn) if !gn.trim().is_empty() => format!("{} ({})", gn, tag),
_ => tag,
@@ -9,7 +9,7 @@ use crate::{
media::user_avatar_url,
page_container::card_with_header,
},
utils::bigint::format_discriminator,
utils::{bigint::format_discriminator, user_tag::user_tag},
};
use maud::{Markup, html};
@@ -103,12 +103,14 @@ fn member_card(
member: &GuildMember,
csrf_token: &str,
) -> Markup {
let disc = format_discriminator(&member.user.discriminator);
let tag = user_tag(
&member.user.username,
&format_discriminator(&member.user.discriminator),
member.user.bot,
);
let display = match &member.user.global_name {
Some(gn) if !gn.trim().is_empty() => {
format!("{} ({}#{})", gn, member.user.username, disc)
}
_ => format!("{}#{}", member.user.username, disc),
Some(gn) if !gn.trim().is_empty() => format!("{gn} ({tag})"),
_ => tag,
};
let user_url = format!("{base}/users/{}", member.user.id);
let avatar_url = user_avatar_url(
@@ -12,7 +12,7 @@ pub mod reports;
pub mod settings;
pub mod stickers;
use crate::api::types::GuildDetailInfo;
use crate::{api::types::GuildDetailInfo, utils::user_tag::user_tag};
pub(crate) fn owner_display(guild: &GuildDetailInfo) -> String {
let Some(username) = guild.owner_username.as_deref() else {
@@ -21,7 +21,7 @@ pub(crate) fn owner_display(guild: &GuildDetailInfo) -> String {
let Some(discriminator) = guild.owner_discriminator.as_deref() else {
return guild.owner_id.clone();
};
let tag = format!("{username}#{discriminator}");
let tag = user_tag(username, discriminator, false);
if let Some(global_name) = guild
.owner_global_name
.as_deref()
@@ -4,6 +4,7 @@ use crate::{
api::types::{GuildInfo, ReportEntry},
config::AdminConfig,
templates::components::{page_container::card_with_header, table::data_table},
utils::user_tag::user_tag,
};
use maud::{Markup, html};
@@ -93,7 +94,7 @@ fn format_status(status: i32) -> &'static str {
fn format_reporter(report: &ReportEntry) -> String {
if let Some(ref username) = report.reporter_username {
let disc = report.reporter_discriminator.as_deref().unwrap_or("0000");
let tag = format!("{username}#{disc}");
let tag = user_tag(username, disc, false);
if let Some(ref gn) = report.reporter_global_name {
let trimmed = gn.trim();
if !trimmed.is_empty() {
@@ -28,16 +28,25 @@ const DISABLED_OPERATIONS: &[(&str, i32)] = &[
("MEMBER_LIST_UPDATES", 1 << 6),
];
fn low_verification_label(username_sign_in: bool) -> &'static str {
if username_sign_in {
"Low (claimed account)"
} else {
"Low (verified email)"
}
}
pub fn settings_tab(
config: &AdminConfig,
guild: &GuildDetailInfo,
csrf_token: &str,
admin_acls: &[String],
username_sign_in: bool,
) -> Markup {
let can_edit = acl::has_permission(admin_acls, acl::GUILD_UPDATE_SETTINGS);
if !can_edit {
return settings_tab_readonly(guild);
return settings_tab_readonly(guild, username_sign_in);
}
let base = &config.base_path;
@@ -55,7 +64,7 @@ pub fn settings_tab(
guild.verification_level.unwrap_or(0).min(3),
&[
(0, "None"),
(1, "Low (verified email)"),
(1, low_verification_label(username_sign_in)),
(2, "Medium (5+ minutes)"),
(3, "High (10+ minutes)"),
],
@@ -223,10 +232,10 @@ fn select_field(
}
}
fn settings_tab_readonly(guild: &GuildDetailInfo) -> Markup {
fn settings_tab_readonly(guild: &GuildDetailInfo, username_sign_in: bool) -> Markup {
let verification_label = match guild.verification_level.unwrap_or(0).min(3) {
0 => "None",
1 => "Low (verified email)",
1 => low_verification_label(username_sign_in),
2 => "Medium (5+ minutes)",
3 => "High (10+ minutes)",
_ => "Unknown",
@@ -285,3 +294,32 @@ fn readonly_field(label: &str, value: &str) -> Markup {
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn guild() -> GuildDetailInfo {
serde_json::from_value(json!({
"id": "1500000000000000001",
"owner_id": "1400000000000000001",
"name": "Guild",
"verification_level": 1
}))
.expect("valid guild detail")
}
#[test]
fn low_verification_names_a_claimed_account_in_username_mode() {
let markup = settings_tab_readonly(&guild(), true).into_string();
assert!(markup.contains("Low (claimed account)"));
assert!(!markup.contains("verified email"));
}
#[test]
fn low_verification_names_a_verified_email_in_email_mode() {
let markup = settings_tab_readonly(&guild(), false).into_string();
assert!(markup.contains("Low (verified email)"));
}
}
@@ -14,7 +14,7 @@ use crate::{
},
layout::admin_layout,
},
utils::forms::parse_comma_separated,
utils::{forms::parse_comma_separated, user_tag::user_tag},
};
use maud::{Markup, html};
@@ -270,7 +270,7 @@ fn owner_display(guild: &GuildInfo) -> String {
let Some(discriminator) = guild.owner_discriminator.as_deref() else {
return guild.owner_id.clone();
};
let tag = format!("{username}#{discriminator}");
let tag = user_tag(username, discriminator, false);
if let Some(global_name) = guild
.owner_global_name
.as_deref()
@@ -2,14 +2,15 @@
use crate::{
api::types::{
AppPublicConfigResponse, CAPTCHA_COST_RANGE, CAPTCHA_MAX_COUNTER_RANGE,
CaptchaConfigResponse, DOMAIN_MIGRATION_DEFAULT_SALT, DomainMigrationConfigResponse,
AccountIdentityConfigResponse, AccountIdentityMode, AppPublicConfigResponse,
CAPTCHA_COST_RANGE, CAPTCHA_MAX_COUNTER_RANGE, CaptchaConfigResponse,
DOMAIN_MIGRATION_DEFAULT_SALT, DomainMigrationConfigResponse,
EXPERIMENT_MAX_TARGETED_USERS, ExperimentDeliveryConfigResponse,
GatewayRolloutConfigResponse, InstanceConfigResponse, InstanceIntegrationsResponse,
InstanceMediaResponse, InstancePolicyResponse, InstanceRegistrationResponse,
LimitConfigResponse, PLUTONIUM_PAGE_DEFAULT_SALT, PendingRegistrationResponse,
PlutoniumPageConfigResponse, PushRelayConfigResponse, RegistrationUrlResponse,
SsoConfigResponse,
SsoConfigResponse, TagStyle,
},
config::AdminConfig,
middleware::auth::AuthContext,
@@ -111,6 +112,9 @@ pub fn instance_config_page(
"Access & accounts",
"Who can sign in and create accounts on this instance.",
html! {
@if instance_config.self_hosted {
(account_identity_section(&instance_config.account_identity))
}
(registration_config_section(
config,
csrf_token,
@@ -159,7 +163,12 @@ pub fn instance_config_page(
"Runtime integrations",
"Credentials and provider choices that override environment variables at runtime.",
html! {
(integrations_config_section(base, csrf_token, &instance_config.integrations))
(integrations_config_section(
base,
csrf_token,
&instance_config.integrations,
instance_config.account_identity.mode,
))
},
))
(config_group(
@@ -500,10 +509,65 @@ fn password_input(name: &str, label: &str, helper: Option<&str>) -> Markup {
)
}
fn account_identity_section(account_identity: &AccountIdentityConfigResponse) -> Markup {
let description = match account_identity.mode {
AccountIdentityMode::Username => {
"Members sign in with a username and password. The instance never collects an email \
address. A member who forgets their password uses their recovery kit or a reset link \
from an admin."
}
AccountIdentityMode::Email => "Members sign in with an email address and password.",
};
section_card_with_description(
"Sign-in Method",
"How members identify themselves when they sign in.",
html! {
div class="space-y-3" {
div class="flex flex-wrap items-center gap-2" {
h3 class="text-sm font-semibold text-neutral-900" {
(account_identity.mode.label())
}
@match account_identity.locked {
Some(true) => (badge("Fixed", BadgeVariant::Default)),
Some(false) => (badge("Not fixed yet", BadgeVariant::Warning)),
None => {}
}
}
p class="text-sm text-neutral-600" { (description) }
@if !account_identity.mode.is_username() {
div class="flex flex-wrap items-center gap-2" {
h3 class="text-sm font-semibold text-neutral-900" {
(account_identity.tag_style.label())
}
}
p class="text-sm text-neutral-600" {
@match account_identity.tag_style {
TagStyle::None => {
"Each name belongs to one person and is shown without a tag."
}
TagStyle::Random => {
"Names have a random tag, like alex#4821, so several people can share a name."
}
}
}
}
p class="text-xs text-neutral-500" {
@if account_identity.mode.is_username() {
"The sign-in method is chosen during setup. It cannot be changed once setup is complete or the first account exists."
} @else {
"The sign-in method and the username tags are chosen during setup. They cannot be changed once setup is complete or the first account exists."
}
}
}
},
)
}
fn integrations_config_section(
base: &str,
csrf_token: &str,
integrations: &InstanceIntegrationsResponse,
account_identity: AccountIdentityMode,
) -> Markup {
let smtp_port = integrations
.email
@@ -536,62 +600,65 @@ fn integrations_config_section(
(password_input("integration_youtube_api_key", "YouTube API key", Some("Leave blank to keep the current key.")))
}
div class="space-y-4 border-t border-neutral-200 pt-6" {
div class="flex flex-wrap items-center gap-2" {
h3 class="text-sm font-semibold text-neutral-900" { "Email delivery" }
@if integrations.email.effective_enabled {
(badge("Effective: enabled", BadgeVariant::Success))
} @else {
(badge("Effective: disabled", BadgeVariant::Default))
@if !account_identity.is_username() {
div class="space-y-4 border-t border-neutral-200 pt-6" {
div class="flex flex-wrap items-center gap-2" {
h3 class="text-sm font-semibold text-neutral-900" { "Email delivery" }
@if integrations.email.effective_enabled {
(badge("Effective: enabled", BadgeVariant::Success))
} @else {
(badge("Effective: disabled", BadgeVariant::Default))
}
@if integrations.email.effective_disable_new_ip_authorization {
(badge("IP auth disabled", BadgeVariant::Warning))
} @else {
(badge("IP auth required", BadgeVariant::Default))
}
(secret_badge("SMTP password", integrations.email.smtp.password_set))
}
@if integrations.email.effective_disable_new_ip_authorization {
(badge("IP auth disabled", BadgeVariant::Warning))
} @else {
(badge("IP auth required", BadgeVariant::Default))
input type="hidden" name="integration_email_present" value="1";
(checkbox("integration_email_enabled", "true", "Enable email delivery", integrations.email.effective_enabled, true))
div class="grid grid-cols-1 gap-4 sm:grid-cols-2" {
(text_input(
"integration_email_from_email",
"From email",
integrations.email.from_email.as_deref().unwrap_or(""),
"[email protected]",
))
(text_input(
"integration_email_from_name",
"From name",
integrations.email.from_name.as_deref().unwrap_or(""),
"Fluxer",
))
(text_input(
"integration_smtp_host",
"SMTP host",
integrations.email.smtp.host.as_deref().unwrap_or(""),
"smtp.example.com",
))
(text_input(
"integration_smtp_port",
"SMTP port",
&smtp_port,
"587",
))
(text_input(
"integration_smtp_username",
"SMTP username",
integrations.email.smtp.username.as_deref().unwrap_or(""),
"[email protected]",
))
(password_input("integration_smtp_password", "SMTP password", Some("Leave blank to keep the current password.")))
}
(secret_badge("SMTP password", integrations.email.smtp.password_set))
}
(checkbox("integration_email_enabled", "true", "Enable email delivery", integrations.email.effective_enabled, true))
div class="grid grid-cols-1 gap-4 sm:grid-cols-2" {
(text_input(
"integration_email_from_email",
"From email",
integrations.email.from_email.as_deref().unwrap_or(""),
"[email protected]",
))
(text_input(
"integration_email_from_name",
"From name",
integrations.email.from_name.as_deref().unwrap_or(""),
"Fluxer",
))
(text_input(
"integration_smtp_host",
"SMTP host",
integrations.email.smtp.host.as_deref().unwrap_or(""),
"smtp.example.com",
))
(text_input(
"integration_smtp_port",
"SMTP port",
&smtp_port,
"587",
))
(text_input(
"integration_smtp_username",
"SMTP username",
integrations.email.smtp.username.as_deref().unwrap_or(""),
"[email protected]",
))
(password_input("integration_smtp_password", "SMTP password", Some("Leave blank to keep the current password.")))
}
(checkbox("integration_smtp_secure", "true", "Use TLS", integrations.email.smtp.secure.unwrap_or(true), true))
(checkbox("integration_email_disable_new_ip_authorization", "true", "Disable new IP login authorisation", integrations.email.disable_new_ip_authorization, true))
div class="flex flex-wrap gap-2" {
button type="submit"
formaction={(base) "/instance-config?action=test_smtp"}
class="inline-flex w-fit items-center justify-center gap-2 rounded-lg border border-neutral-300 bg-neutral-50 px-4 py-2 font-medium text-base text-neutral-700 transition-all duration-150 hover:border-neutral-400 hover:text-neutral-900 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-white" {
span { "Test SMTP connection" }
(checkbox("integration_smtp_secure", "true", "Use TLS", integrations.email.smtp.secure.unwrap_or(true), true))
(checkbox("integration_email_disable_new_ip_authorization", "true", "Disable new IP login authorisation", integrations.email.disable_new_ip_authorization, true))
div class="flex flex-wrap gap-2" {
button type="submit"
formaction={(base) "/instance-config?action=test_smtp"}
class="inline-flex w-fit items-center justify-center gap-2 rounded-lg border border-neutral-300 bg-neutral-50 px-4 py-2 font-medium text-base text-neutral-700 transition-all duration-150 hover:border-neutral-400 hover:text-neutral-900 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-white" {
span { "Test SMTP connection" }
}
}
}
}
@@ -2032,6 +2099,95 @@ fn limit_config_section(base: &str, limit_config: &LimitConfigResponse) -> Marku
mod tests {
use super::*;
#[test]
fn username_instances_hide_email_delivery_and_the_smtp_test() {
let integrations = InstanceIntegrationsResponse::default();
let username = integrations_config_section(
"/admin",
"csrf",
&integrations,
AccountIdentityMode::Username,
)
.into_string();
assert!(!username.contains("Email delivery"));
assert!(!username.contains("test_smtp"));
assert!(!username.contains("integration_email_present"));
assert!(username.contains("Bluesky OAuth"));
let email = integrations_config_section(
"/admin",
"csrf",
&integrations,
AccountIdentityMode::Email,
)
.into_string();
assert!(email.contains("Email delivery"));
assert!(email.contains("test_smtp"));
assert!(email.contains(r#"name="integration_email_present" value="1""#));
}
#[test]
fn account_identity_section_has_no_tag_choice_in_username_mode() {
let markup = account_identity_section(&AccountIdentityConfigResponse {
mode: AccountIdentityMode::Username,
locked: Some(true),
tag_style: TagStyle::None,
})
.into_string();
assert!(markup.contains("Sign-in Method"));
assert!(markup.contains("Username"));
assert!(markup.contains("Fixed"));
assert!(!markup.contains("No tags"));
assert!(!markup.contains("Random tags"));
assert!(!markup.contains("username tags"));
assert!(!markup.contains("<form"));
assert!(!markup.contains("<input"));
}
#[test]
fn account_identity_section_shows_random_tags_in_email_mode() {
let markup = account_identity_section(&AccountIdentityConfigResponse {
mode: AccountIdentityMode::Email,
locked: Some(true),
tag_style: TagStyle::Random,
})
.into_string();
assert!(markup.contains("Random tags"));
assert!(!markup.contains("No tags"));
assert!(markup.contains("username tags"));
}
#[test]
fn account_identity_section_shows_no_tags_in_email_mode() {
let markup = account_identity_section(&AccountIdentityConfigResponse {
mode: AccountIdentityMode::Email,
locked: Some(true),
tag_style: TagStyle::None,
})
.into_string();
assert!(markup.contains("No tags"));
assert!(!markup.contains("Random tags"));
assert!(!markup.contains("<input"));
}
#[test]
fn account_identity_section_shows_the_lock_state_only_when_known() {
let render = |locked| {
account_identity_section(&AccountIdentityConfigResponse {
mode: AccountIdentityMode::Email,
locked,
tag_style: TagStyle::Random,
})
.into_string()
};
let unlocked = render(Some(false));
assert!(unlocked.contains("Not fixed yet"));
let unknown = render(None);
assert!(!unknown.contains("Fixed"));
assert!(!unknown.contains("Not fixed yet"));
assert!(unknown.contains("Random tags"));
}
#[test]
fn captcha_section_posts_the_switch_and_difficulty_fields() {
let markup =
@@ -22,7 +22,7 @@ use crate::{
},
layout::admin_layout,
},
utils::timestamps::format_admin_timestamp,
utils::{timestamps::format_admin_timestamp, user_tag::user_tag},
};
use maud::{Markup, html};
@@ -54,7 +54,7 @@ fn reporter_label(report: &ReportEntry) -> String {
}
if let Some(username) = &report.reporter_username {
let discriminator = report.reporter_discriminator.as_deref().unwrap_or("0000");
return format!("{username}#{discriminator}");
return user_tag(username, discriminator, false);
}
if let Some(email) = &report.reporter_email {
return email.to_owned();
@@ -71,7 +71,7 @@ fn reported_user_label(report: &ReportEntry) -> String {
.reported_user_discriminator
.as_deref()
.unwrap_or("0000");
return format!("{username}#{discriminator}");
return user_tag(username, discriminator, false);
}
format!(
"User {}",
@@ -15,6 +15,7 @@ use crate::{
},
layout::admin_layout,
},
utils::user_tag::user_tag,
};
use maud::{Markup, PreEscaped, html};
@@ -189,7 +190,7 @@ fn format_category(category: Option<&str>) -> String {
fn reporter_label(report: &ReportEntry) -> String {
if let Some(username) = &report.reporter_username {
let discriminator = report.reporter_discriminator.as_deref().unwrap_or("0000");
let tag = format!("{username}#{discriminator}");
let tag = user_tag(username, discriminator, false);
if let Some(display) = report
.reporter_global_name
.as_ref()
@@ -214,7 +215,7 @@ fn reported_user_label(report: &ReportEntry) -> String {
.reported_user_discriminator
.as_deref()
.unwrap_or("0000");
let tag = format!("{username}#{discriminator}");
let tag = user_tag(username, discriminator, false);
if let Some(display) = report
.reported_user_global_name
.as_ref()
@@ -1,10 +1,16 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use crate::{
api::types::{BlocklistEntry, BlocklistEntryPage},
config::AdminConfig,
middleware::auth::AuthContext,
templates::{
components::{form::checkbox, page_container::page_header},
components::{
badge::{BadgeVariant, badge},
form::{checkbox, csrf_input},
page_container::page_header,
table::{data_table, empty_state, table_cell, table_row},
},
layout::admin_layout,
pages::blocklist_helpers::{
BlocklistActionVariant, blocklist_action_card, blocklist_text_field,
@@ -13,15 +19,21 @@ use crate::{
};
use maud::{Markup, html};
const PAGE_DESCRIPTION: &str = "A domain entry blocks that host and, when it matches subdomains, every host under it. \
A pattern such as *shop*.example.com matches the one label left of a registrable domain, so it blocks \
shop.example.com and my-shop-2.example.com but never example.com itself. Patterns are matched against the \
ASCII form of a host.";
pub fn url_domain_bans_page(
config: &AdminConfig,
auth: &AuthContext,
flash: Option<&crate::api::types::FlashMessage>,
csrf_token: &str,
entries: Option<&BlocklistEntryPage>,
) -> Markup {
let base = &config.base_path;
let content = html! {
(page_header("URL Domain Blocklist", None))
(page_header("URL Domain Blocklist", Some(PAGE_DESCRIPTION)))
div class="grid gap-6 lg:grid-cols-2" {
(ban_card(base, csrf_token))
(check_card(base, csrf_token))
@@ -29,6 +41,9 @@ pub fn url_domain_bans_page(
div class="mt-6" {
(unban_card(base, csrf_token))
}
div class="mt-6" {
(entries_card(base, csrf_token, entries))
}
};
admin_layout(
config,
@@ -43,15 +58,15 @@ pub fn url_domain_bans_page(
fn ban_card(base: &str, csrf_token: &str) -> Markup {
let action_url = format!("{base}/url-domain-bans?action=ban&_csrf={csrf_token}");
blocklist_action_card(
"Ban URL Domain",
"Ban URL Domain or Pattern",
&action_url,
csrf_token,
html! {
(blocklist_text_field("domain", "Domain", "example.com", true))
(blocklist_text_field("domain", "Domain or pattern", "example.com or *shop*.example.com", true))
(checkbox("match_subdomains", "true", "Match subdomains (e.g. sub.example.com)", true, true))
(blocklist_text_field("audit_log_reason", "Private reason (audit log, optional)", "Why is this ban being applied?", false))
},
"Ban Domain",
"Ban",
BlocklistActionVariant::Primary,
)
}
@@ -59,13 +74,13 @@ fn ban_card(base: &str, csrf_token: &str) -> Markup {
fn check_card(base: &str, csrf_token: &str) -> Markup {
let action_url = format!("{base}/url-domain-bans?action=check&_csrf={csrf_token}");
blocklist_action_card(
"Check Domain Ban Status",
"Test a Host or URL",
&action_url,
csrf_token,
html! {
(blocklist_text_field("domain", "Domain", "example.com", true))
(blocklist_text_field("domain", "Host or URL", "shop-2.example.com or https://shop.example.com/x", true))
},
"Check Status",
"Test",
BlocklistActionVariant::Primary,
)
}
@@ -73,14 +88,131 @@ fn check_card(base: &str, csrf_token: &str) -> Markup {
fn unban_card(base: &str, csrf_token: &str) -> Markup {
let action_url = format!("{base}/url-domain-bans?action=unban&_csrf={csrf_token}");
blocklist_action_card(
"Remove Domain Ban",
"Remove Domain or Pattern",
&action_url,
csrf_token,
html! {
(blocklist_text_field("domain", "Domain", "example.com", true))
(blocklist_text_field("domain", "Domain or pattern", "example.com or *shop*.example.com", true))
(blocklist_text_field("audit_log_reason", "Private reason (audit log, optional)", "Why is this ban being removed?", false))
},
"Unban Domain",
"Unban",
BlocklistActionVariant::Danger,
)
}
fn entries_card(base: &str, csrf_token: &str, entries: Option<&BlocklistEntryPage>) -> Markup {
html! {
div class="rounded-lg border border-neutral-200 bg-white p-4 shadow-sm sm:p-6" {
div class="mb-4 flex items-center justify-between gap-4" {
h3 class="text-base font-medium text-neutral-900" { "Blocked Domains and Patterns" }
a href={(base) "/url-domain-bans"} class="text-sm text-brand-primary hover:underline" { "Refresh" }
}
@match entries {
None => {
p class="text-sm text-red-700" { "Failed to load the blocklist entries" }
}
Some(page) => {
(entries_table(base, csrf_token, page))
}
}
}
}
}
fn entries_table(base: &str, csrf_token: &str, page: &BlocklistEntryPage) -> Markup {
if page.items.is_empty() {
return empty_state("No domains or patterns are blocked");
}
let next_after = page.next_after.as_deref().filter(|_| page.has_more);
html! {
(data_table(
&["Value", "Kind", "Subdomains", "Category", "Added", ""],
html! {
@for entry in &page.items {
(entry_row(base, csrf_token, entry))
}
},
))
@if let Some(next) = next_after {
div class="mt-4" {
a href={(base) "/url-domain-bans?after=" (urlencoding::encode(next))}
class="text-sm text-brand-primary hover:underline" {
"Next page"
}
}
}
}
}
fn entry_row(base: &str, csrf_token: &str, entry: &BlocklistEntry) -> Markup {
let action_url = format!("{base}/url-domain-bans?action=unban&_csrf={csrf_token}");
let is_pattern = entry.value.contains('*');
table_row(html! {
(table_cell(false, html! { code class="break-all" { (entry.value) } }))
(table_cell(false, html! {
@if is_pattern {
(badge("Pattern", BadgeVariant::Info))
} @else {
(badge("Domain", BadgeVariant::Default))
}
}))
(table_cell(true, html! {
@if entry.match_subdomains.unwrap_or(true) { "Yes" } @else { "No" }
}))
(table_cell(true, html! { (entry.category.as_deref().unwrap_or("")) }))
(table_cell(true, html! { (entry.created_at.as_deref().unwrap_or("")) }))
(table_cell(false, html! {
form method="post" action=(action_url) {
(csrf_input(csrf_token))
input type="hidden" name="domain" value=(entry.value);
button type="submit" class="text-sm font-medium text-red-600 hover:text-red-700" {
"Remove"
}
}
}))
})
}
#[cfg(test)]
mod tests {
use super::*;
fn entry(value: &str, match_subdomains: bool) -> BlocklistEntry {
BlocklistEntry {
value: value.to_owned(),
match_subdomains: Some(match_subdomains),
category: Some("manual".to_owned()),
created_at: None,
}
}
#[test]
fn entries_table_lists_patterns_with_remove_forms() {
let page = BlocklistEntryPage {
items: vec![
entry("*shop*.example.com", false),
entry("store.example.com", true),
],
has_more: true,
next_after: Some("store.example.com".to_owned()),
};
let markup = entries_table("/admin", "token", &page).into_string();
assert!(markup.contains("*shop*.example.com"));
assert!(markup.contains(">Pattern</span>"));
assert!(markup.contains(">Domain</span>"));
assert!(markup.contains(r#"name="domain" value="*shop*.example.com""#));
assert!(markup.contains("/admin/url-domain-bans?action=unban&amp;_csrf=token"));
assert!(markup.contains("/admin/url-domain-bans?after=store.example.com"));
}
#[test]
fn entries_table_reports_an_empty_list() {
let page = BlocklistEntryPage {
items: Vec::new(),
has_more: false,
next_after: None,
};
let markup = entries_table("/admin", "token", &page).into_string();
assert!(markup.contains("No domains or patterns are blocked"));
}
}
@@ -15,7 +15,7 @@ use crate::{
layout::admin_layout,
pages::user_detail_tabs,
},
utils::bigint::format_discriminator,
utils::{bigint::format_discriminator, user_tag::user_tag},
};
use maud::{Markup, html};
@@ -163,7 +163,7 @@ fn render_user_detail(
))
}
p class="break-words text-sm text-neutral-500" {
(user.username) "#" (format_discriminator(&user.discriminator))
(user_tag(&user.username, &format_discriminator(&user.discriminator), user.bot))
}
p class="break-all text-sm text-neutral-500" {
(user.id)
@@ -1,12 +1,17 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use crate::{
api::types::{AdminUser, UserSession, WebAuthnCredential},
acl,
api::types::{
AccountIdentityMode, AdminUser, PasswordResetLinkResponse, UserSession, WebAuthnCredential,
},
config::AdminConfig,
templates::components::{
alert::{AlertVariant, alert},
form::{checkbox, csrf_input, form_actions, submit_button},
page_container::card_with_header,
},
utils::timestamps::format_admin_timestamp,
};
use maud::{Markup, html};
@@ -17,28 +22,113 @@ const BTN_CLS: &str = "w-full inline-flex items-center justify-center rounded-md
bg-brand-primary px-4 py-2 text-sm font-medium text-white \
shadow-sm hover:bg-brand-primary-dark";
pub struct AccountTabOptions<'a> {
pub admin_acls: &'a [String],
pub account_identity: AccountIdentityMode,
pub password_reset_link: Option<&'a PasswordResetLinkResponse>,
}
pub fn account_tab(
config: &AdminConfig,
user: &AdminUser,
sessions: &[UserSession],
webauthn_credentials: &[WebAuthnCredential],
csrf_token: &str,
options: &AccountTabOptions<'_>,
) -> Markup {
let base = &config.base_path;
let username_sign_in = options.account_identity.is_username();
let can_create_reset_link = username_sign_in
&& acl::has_permission(options.admin_acls, acl::USER_CREATE_PASSWORD_RESET_LINK);
let can_revoke_recovery_kit = username_sign_in
&& !user.bot
&& acl::has_permission(options.admin_acls, acl::USER_DELETE_RECOVERY_KIT);
html! {
div class="space-y-6" {
(edit_account_card(base, user, csrf_token))
@if can_create_reset_link {
(password_reset_link_card(base, user, csrf_token, options.password_reset_link))
}
(edit_account_card(base, user, csrf_token, username_sign_in))
(sessions_card(config, sessions))
(quick_actions_card(base, user, csrf_token))
(quick_actions_card(base, user, csrf_token, username_sign_in, can_revoke_recovery_kit))
(clear_fields_card(base, user, csrf_token))
(user_status_card(base, user, csrf_token))
(security_actions_card(base, user, csrf_token))
(webauthn_credentials_card(base, user, webauthn_credentials, csrf_token))
}
}
}
fn edit_account_card(base: &str, user: &AdminUser, csrf_token: &str) -> Markup {
fn password_reset_link_card(
base: &str,
user: &AdminUser,
csrf_token: &str,
link: Option<&PasswordResetLinkResponse>,
) -> Markup {
let action_url = format!(
"{base}/users/{}?action=create_password_reset_link&tab=account",
user.id
);
html! {
(card_with_header("Password Reset Link", html! {
div class="space-y-4" {
(password_reset_link_result(link))
p class="text-sm text-neutral-600" {
"Create a one-time link that lets this user choose a new password. \
Hand it to them yourself. It works once and expires after an hour."
}
form method="post"
action=(&action_url)
data-admin-result-form="true"
hx-post=(&action_url)
hx-target={"#" (PASSWORD_RESET_LINK_RESULT_ID)}
hx-swap="outerHTML"
hx-push-url="false" {
(csrf_input(csrf_token))
button type="submit" class=(BTN_CLS) { "Create Password Reset Link" }
}
}
}))
}
}
pub const PASSWORD_RESET_LINK_RESULT_ID: &str = "password-reset-link-result";
pub fn password_reset_link_result(link: Option<&PasswordResetLinkResponse>) -> Markup {
html! {
div id=(PASSWORD_RESET_LINK_RESULT_ID) hx-history=[link.is_some().then_some("false")] {
@if let Some(link) = link {
(alert(AlertVariant::Success, Some("Password reset link created"), html! {
div class="flex flex-col gap-2" {
p class="text-sm" {
"Copy this link now. It is shown only once."
}
div class="flex items-center gap-2" {
input type="url" readonly value=(link.url)
aria-label="Password reset link"
class="h-8 min-w-0 flex-1 rounded-lg border border-green-200 bg-white px-3 py-1.5 text-xs text-neutral-900";
button type="button"
class="inline-flex h-8 shrink-0 items-center justify-center rounded-lg border border-neutral-300 bg-neutral-50 px-3 text-xs font-medium text-neutral-700 hover:border-neutral-400 hover:text-neutral-900"
data-copy-value=(link.url)
onclick="window.__adminCopyToClipboard && window.__adminCopyToClipboard(this.dataset.copyValue, this, 'Copied')" {
"Copy Link"
}
}
p class="text-xs" {
"Expires " (format_admin_timestamp(&link.expires_at))
}
}
}))
}
}
}
}
fn edit_account_card(
base: &str,
user: &AdminUser,
csrf_token: &str,
username_sign_in: bool,
) -> Markup {
html! {
(card_with_header("Edit Account Information", html! {
div class="grid gap-4 md:grid-cols-2" {
@@ -47,22 +137,26 @@ fn edit_account_card(base: &str, user: &AdminUser, csrf_token: &str) -> Markup {
p class="text-sm font-medium text-neutral-700" { "Change Username:" }
input type="text" name="username" placeholder="New username"
required class=(INPUT_CLS);
input type="text" name="discriminator"
placeholder="Discriminator (optional)" inputmode="numeric" pattern="[0-9]{1,4}" maxlength="4"
class=(INPUT_CLS);
@if !crate::utils::user_tag::unique_usernames() || user.bot {
input type="text" name="discriminator"
placeholder="Discriminator (optional)" inputmode="numeric" pattern="[0-9]{1,4}" maxlength="4"
class=(INPUT_CLS);
}
(form_actions(html! {
(submit_button("Change Username"))
}))
}, csrf_token))
(post_form(base, &user.id, "change_email", "account",
"Are you sure you want to change this user\\'s email address?", html! {
p class="text-sm font-medium text-neutral-700" { "Change Email:" }
input type="email" name="email" placeholder="New email address"
required class=(INPUT_CLS);
(form_actions(html! {
(submit_button("Change Email"))
}))
}, csrf_token))
@if !username_sign_in {
(post_form(base, &user.id, "change_email", "account",
"Are you sure you want to change this user\\'s email address?", html! {
p class="text-sm font-medium text-neutral-700" { "Change Email:" }
input type="email" name="email" placeholder="New email address"
required class=(INPUT_CLS);
(form_actions(html! {
(submit_button("Change Email"))
}))
}, csrf_token))
}
(post_form(base, &user.id, "change_dob", "account",
"Are you sure you want to change this user\\'s date of birth?", html! {
p class="text-sm font-medium text-neutral-700" { "Change Date of Birth:" }
@@ -153,16 +247,28 @@ fn session_entry(base: &str, s: &UserSession, is_tombstone: bool) -> Markup {
}
}
fn quick_actions_card(base: &str, user: &AdminUser, csrf_token: &str) -> Markup {
fn quick_actions_card(
base: &str,
user: &AdminUser,
csrf_token: &str,
username_sign_in: bool,
can_revoke_recovery_kit: bool,
) -> Markup {
html! {
(card_with_header("Quick Actions", html! {
div class="flex flex-wrap gap-3" {
@if !user.email_verified {
@if !user.email_verified && !username_sign_in {
(action_form(base, &user.id, "verify_email", "account", None,
"Verify Email", csrf_token))
}
(action_form(base, &user.id, "send_password_reset", "account", None,
"Send Password Reset", csrf_token))
@if !username_sign_in {
(action_form(base, &user.id, "send_password_reset", "account", None,
"Send Password Reset", csrf_token))
}
@if can_revoke_recovery_kit {
(action_form(base, &user.id, "revoke_recovery_kit", "account", None,
"Revoke Recovery Kit", csrf_token))
}
}
}))
}
@@ -204,19 +310,6 @@ fn clear_fields_card(base: &str, user: &AdminUser, csrf_token: &str) -> Markup {
}
}
fn user_status_card(base: &str, user: &AdminUser, csrf_token: &str) -> Markup {
let is_bot = user.bot;
let is_sys = user.system;
html! {
(card_with_header("User Status", html! {
div class="grid grid-cols-1 gap-4 md:grid-cols-2" {
(status_toggle(base, &user.id, "set_bot_status", is_bot, "bot", csrf_token))
(status_toggle(base, &user.id, "set_system_status", is_sys, "system", csrf_token))
}
}))
}
}
fn security_actions_card(base: &str, user: &AdminUser, csrf_token: &str) -> Markup {
html! {
(card_with_header("Security Actions", html! {
@@ -357,40 +450,3 @@ fn action_form(
}
}
}
fn status_toggle(
base: &str,
uid: &str,
action: &str,
active: bool,
kind: &str,
csrf: &str,
) -> Markup {
let status_val = if active { "false" } else { "true" };
let label = format!(
"{} {} Status",
if active { "Remove" } else { "Set" },
capitalize(kind)
);
let action_url = format!("{base}/users/{uid}?action={action}&status={status_val}&tab=account");
html! {
form method="post"
action=(&action_url)
hx-post=(&action_url)
hx-target="#flash-container"
hx-swap="none"
hx-push-url="false" {
(csrf_input(csrf))
input type="hidden" name=(kind) value=(status_val);
button type="submit" class=(BTN_CLS) { (label) }
}
}
}
fn capitalize(s: &str) -> String {
let mut c = s.chars();
match c.next() {
None => String::new(),
Some(f) => f.to_uppercase().chain(c).collect(),
}
}
@@ -1,6 +1,9 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use crate::{api::types::AdminResolvedUser, utils::bigint::format_discriminator};
use crate::{
api::types::AdminResolvedUser,
utils::{bigint::format_discriminator, user_tag::user_tag},
};
pub mod account;
pub mod applications;
@@ -15,8 +18,11 @@ pub mod reports;
pub mod settings;
pub(super) fn resolved_user_display(user: &AdminResolvedUser) -> String {
let disc = format_discriminator(&user.discriminator);
let tag = format!("{}#{}", user.username, disc);
let tag = user_tag(
&user.username,
&format_discriminator(&user.discriminator),
false,
);
match &user.global_name {
Some(gn) if !gn.trim().is_empty() => format!("{} ({})", gn, tag),
_ => tag,
@@ -63,6 +63,7 @@ pub struct CurrentBan<'a> {
pub struct ModerationContext<'a> {
pub deletion_scheduler: Option<&'a AdminUser>,
pub current_ban: Option<CurrentBan<'a>>,
pub username_sign_in: bool,
}
pub fn find_current_ban<'a>(user: &AdminUser, logs: &'a [AuditLogEntry]) -> Option<CurrentBan<'a>> {
@@ -118,8 +119,8 @@ pub fn moderation_tab(
html! {
div class="space-y-6" {
div class="grid grid-cols-1 gap-6 md:grid-cols-2" {
(ban_actions_card(base, user, csrf_token, context.current_ban.as_ref()))
(deletion_card(base, user, csrf_token, context.deletion_scheduler))
(ban_actions_card(base, user, csrf_token, context.current_ban.as_ref(), context.username_sign_in))
(deletion_card(base, user, csrf_token, context.deletion_scheduler, context.username_sign_in))
}
@if can_delete_all_messages {
(delete_all_messages_card(base, user, csrf_token, delete_all_messages_dry_run))
@@ -131,11 +132,20 @@ pub fn moderation_tab(
}
}
fn notify_user_checkbox(username_sign_in: bool, label: &str) -> Markup {
if username_sign_in {
html! { input type="hidden" name="notify_user_present" value="1"; }
} else {
opt_out_checkbox("notify_user", label)
}
}
fn ban_actions_card(
base: &str,
user: &AdminUser,
csrf_token: &str,
current_ban: Option<&CurrentBan<'_>>,
username_sign_in: bool,
) -> Markup {
html! {
(card_with_header("Ban Actions", html! {
@@ -159,7 +169,7 @@ fn ban_actions_card(
px-3 py-2 text-sm shadow-sm \
focus:border-brand-primary focus:outline-none \
focus:ring-1 focus:ring-brand-primary";
(opt_out_checkbox("notify_user", "Email the user that the suspension was lifted"))
(notify_user_checkbox(username_sign_in, "Email the user that the suspension was lifted"))
(form_actions(html! {
(submit_button("Unban User"))
}))
@@ -194,7 +204,7 @@ fn ban_actions_card(
px-3 py-2 text-sm shadow-sm \
focus:border-brand-primary focus:outline-none \
focus:ring-1 focus:ring-brand-primary";
(opt_out_checkbox("notify_user", "Email the user about this suspension (temporary bans only)"))
(notify_user_checkbox(username_sign_in, "Email the user about this suspension (temporary bans only)"))
(form_actions(html! {
(submit_button("Ban/Suspend User"))
}))
@@ -335,6 +345,7 @@ fn deletion_card(
user: &AdminUser,
csrf_token: &str,
scheduler: Option<&AdminUser>,
username_sign_in: bool,
) -> Markup {
html! {
(card_with_header("Account Deletion", html! {
@@ -353,7 +364,9 @@ fn deletion_card(
px-3 py-2 text-sm shadow-sm \
focus:border-brand-primary focus:outline-none \
focus:ring-1 focus:ring-brand-primary";
(checkbox("notify_user", "true", "Email the user that the deletion was cancelled", false, true))
@if !username_sign_in {
(checkbox("notify_user", "true", "Email the user that the deletion was cancelled", false, true))
}
(checkbox("confirm", "true", &confirmation, false, true))
(form_actions(html! {
(danger_button("Cancel Deletion"))
@@ -397,7 +410,7 @@ fn deletion_card(
px-3 py-2 text-sm shadow-sm \
focus:border-brand-primary focus:outline-none \
focus:ring-1 focus:ring-brand-primary";
(opt_out_checkbox("notify_user", "Email the user about the scheduled deletion"))
(notify_user_checkbox(username_sign_in, "Email the user about the scheduled deletion"))
(form_actions(html! {
(submit_button("Schedule Deletion"))
}))
@@ -776,7 +789,8 @@ mod tests {
"deletion_scheduled_at": "2026-08-31T17:40:29.690Z"
}));
let scheduler = user(json!({"id": "1400000000000000001", "username": "lilith"}));
let markup = deletion_card("/admin", &target, "csrf", Some(&scheduler)).into_string();
let markup =
deletion_card("/admin", &target, "csrf", Some(&scheduler), false).into_string();
assert!(markup.contains(r#"href="/admin/users/1400000000000000001""#));
assert!(markup.contains("lilith"));
assert!(markup.contains("Report batch 12"));
@@ -793,7 +807,7 @@ mod tests {
#[test]
fn schedule_form_makes_the_reason_an_explicit_choice() {
let markup = deletion_card("/admin", &user(json!({})), "csrf", None).into_string();
let markup = deletion_card("/admin", &user(json!({})), "csrf", None, false).into_string();
assert!(markup.contains(r#"<option value="" disabled selected>Choose a reason</option>"#));
assert!(!markup.contains(r#"<option value="1" selected>"#));
assert!(!markup.contains("replace_pending_deletion_at"));
@@ -801,22 +815,46 @@ mod tests {
#[test]
fn schedule_form_emails_the_user_by_default() {
let markup = deletion_card("/admin", &user(json!({})), "csrf", None).into_string();
let markup = deletion_card("/admin", &user(json!({})), "csrf", None, false).into_string();
assert!(markup.contains(r#"name="notify_user" value="true" checked"#));
assert!(markup.contains(r#"name="notify_user_present" value="1""#));
}
#[test]
fn temp_ban_form_emails_the_user_by_default() {
let markup = ban_actions_card("/admin", &user(json!({})), "csrf", None).into_string();
let markup =
ban_actions_card("/admin", &user(json!({})), "csrf", None, false).into_string();
assert!(markup.contains(r#"name="notify_user" value="true" checked"#));
assert!(markup.contains(r#"name="notify_user_present" value="1""#));
}
#[test]
fn username_mode_offers_no_email_and_sends_none() {
let pending = user(json!({
"pending_deletion_at": "2026-10-30T17:40:29.690Z",
"deletion_reason_code": 3
}));
let banned = user(json!({"temp_banned_until": "2026-10-01T00:00:00.000Z"}));
let forms = [
deletion_card("/admin", &user(json!({})), "csrf", None, true).into_string(),
deletion_card("/admin", &pending, "csrf", None, true).into_string(),
ban_actions_card("/admin", &user(json!({})), "csrf", None, true).into_string(),
ban_actions_card("/admin", &banned, "csrf", None, true).into_string(),
];
for markup in &forms {
assert!(!markup.contains("Email the user"));
assert!(!markup.contains(r#"name="notify_user" value="true""#));
}
assert!(forms[0].contains(r#"name="notify_user_present" value="1""#));
assert!(!forms[1].contains("notify_user"));
assert!(forms[2].contains(r#"name="notify_user_present" value="1""#));
assert!(forms[3].contains(r#"name="notify_user_present" value="1""#));
}
#[test]
fn unban_form_separates_the_public_and_private_reasons() {
let target = user(json!({"temp_banned_until": "2026-10-01T00:00:00.000Z"}));
let markup = ban_actions_card("/admin", &target, "csrf", None).into_string();
let markup = ban_actions_card("/admin", &target, "csrf", None, false).into_string();
assert!(markup.contains("?action=unban&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)
@@ -431,11 +437,6 @@ fn acls_card(
(flag_checkbox("acls[]", item.to_string(), item, checked, true))
}
}
@for item in &user.acls {
@if !acl::ALL_ACLS.iter().any(|known| known == &item.as_str()) {
input type="hidden" name="acls[]" value=(item);
}
}
(form_actions(html! {
(submit_button("Save ACLs"))
}))
@@ -578,3 +579,64 @@ fn custom_traits<'a>(user: &'a AdminUser, trait_definitions: &[&str]) -> Vec<&'a
.filter(|trait_name| !DERIVED_TRAITS.contains(trait_name))
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
fn test_config() -> AdminConfig {
AdminConfig {
env: crate::config::RuntimeEnv::Test,
host: String::new(),
port: 3020,
secret_key_base: "test-secret".to_owned(),
base_path: "/admin".to_owned(),
api_endpoint: String::new(),
media_endpoint: String::new(),
static_cdn_endpoint: String::new(),
admin_endpoint: String::new(),
web_app_endpoint: String::new(),
oauth_client_id: String::new(),
oauth_client_secret: String::new(),
oauth_redirect_uri: String::new(),
build_version: "test".to_owned(),
self_hosted: true,
proxy: crate::config::ProxyConfig {
trust_client_ip_header: false,
client_ip_header_name: String::new(),
},
}
}
fn render_email_row(username_sign_in: bool) -> String {
let user: AdminUser = serde_json::from_value(serde_json::json!({
"id": "1500000000000000001",
"username": "target",
"discriminator": "0001",
"email": "[email protected]"
}))
.expect("valid admin user");
let acls = vec![acl::USER_VIEW_EMAIL.to_owned()];
render_overview_tab(
&test_config(),
&user,
&acls,
"csrf",
None,
None,
false,
username_sign_in,
)
.into_string()
}
#[test]
fn username_mode_hides_the_email_row() {
assert!(!render_email_row(true).contains("[email protected]"));
}
#[test]
fn email_mode_shows_the_email_row() {
assert!(render_email_row(false).contains("[email protected]"));
}
}
@@ -8,6 +8,7 @@ use crate::{
page_container::card_with_header,
table::data_table,
},
utils::user_tag::user_tag,
};
use maud::{Markup, html};
@@ -162,7 +163,7 @@ fn format_status(status: i32) -> &'static str {
fn format_reporter(report: &ReportEntry) -> String {
if let Some(ref username) = report.reporter_username {
let disc = report.reporter_discriminator.as_deref().unwrap_or("0000");
let tag = format!("{username}#{disc}");
let tag = user_tag(username, disc, false);
if let Some(ref gn) = report.reporter_global_name {
let trimmed = gn.trim();
if !trimmed.is_empty() {
@@ -260,7 +261,7 @@ fn format_reported_entity(report: &ReportEntry) -> String {
.reported_user_discriminator
.as_deref()
.unwrap_or("0000");
let tag = format!("{username}#{disc}");
let tag = user_tag(username, disc, false);
if let Some(ref gn) = report.reported_user_global_name {
let trimmed = gn.trim();
if !trimmed.is_empty() {
@@ -3,7 +3,10 @@
use crate::{
api::types::AdminUser,
templates::components::page_container::{card_with_header, detail_row},
utils::bigint::{format_discriminator, has_flag, list_flags},
utils::{
bigint::{format_discriminator, has_flag, list_flags},
user_tag::user_tag,
},
};
use maud::{Markup, html};
@@ -13,7 +16,7 @@ pub fn settings_tab(user: &AdminUser) -> Markup {
(card_with_header("Profile Settings", html! {
dl class="divide-y divide-neutral-100" {
(detail_row("Username", html! {
(user.username) "#" (format_discriminator(&user.discriminator))
(user_tag(&user.username, &format_discriminator(&user.discriminator), user.bot))
}))
(detail_row("Display Name", html! {
@if let Some(ref name) = user.global_name {
@@ -10,7 +10,9 @@ use crate::{
media::user_avatar_url,
user_profile_badges::user_profile_badges,
},
utils::{bigint::format_discriminator, timestamps::snowflake_creation_date},
utils::{
bigint::format_discriminator, timestamps::snowflake_creation_date, user_tag::user_tag,
},
};
use maud::{Markup, html};
@@ -72,7 +74,7 @@ pub fn user_peek_fragment(
))
}
p class="break-words text-sm text-neutral-500" {
(user.username) "#" (format_discriminator(&user.discriminator))
(user_tag(&user.username, &format_discriminator(&user.discriminator), user.bot))
}
div class="flex flex-wrap items-center justify-center gap-2 \
sm:justify-start" {
+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());
}
}
+23
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));
@@ -859,6 +866,14 @@ fn deserialize_ban_check_response() {
assert!(resp.banned);
}
#[test]
fn deserialize_ban_check_response_with_expiry() {
let json = r#"{"banned": true, "expires_at": "2026-10-04T12:00:00.000Z"}"#;
let resp: types::BanCheckResult = serde_json::from_str(json).unwrap();
assert!(resp.banned);
assert_eq!(resp.expires_at.as_deref(), Some("2026-10-04T12:00:00.000Z"));
}
#[test]
fn deserialize_codes_response() {
let json = r#"{"codes": ["ABC-DEF", "GHI-JKL"]}"#;
@@ -1061,3 +1076,11 @@ fn deserialize_list_admin_api_key_entry() {
assert_eq!(resp.created_by_user_id, "1130650140672000000");
assert_eq!(resp.acls.len(), 2);
}
#[test]
fn account_identity_lock_is_unknown_when_the_api_omits_it() {
let identity: types::AccountIdentityConfigResponse =
serde_json::from_str("{}").expect("empty account identity");
assert_eq!(identity.mode, types::AccountIdentityMode::Email);
assert_eq!(identity.locked, None);
}
+1 -1
View File
@@ -195,7 +195,7 @@ async fn post_form(app: &TestApp, uri: &str, body: &str) -> StatusCode {
let csrf = body
.split('&')
.find_map(|pair| pair.strip_prefix("_csrf="))
.expect("form carries a csrf token");
.expect("form has a csrf token");
let response = app
.router
.clone()
+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(),
},
}
}
@@ -163,7 +163,7 @@ async fn post_form(app: &TestApp, uri: &str, body: &str) -> StatusCode {
let csrf = body
.split('&')
.find_map(|pair| pair.strip_prefix("_csrf="))
.expect("form carries a csrf token");
.expect("form has a csrf token");
let response = app
.router
.clone()
+1
View File
@@ -79,6 +79,7 @@
"sharp": "catalog:",
"stripe": "catalog:",
"tempy": "catalog:",
"tldts": "catalog:",
"transliteration": "catalog:",
"tsx": "catalog:",
"uint8array-extras": "catalog:",
@@ -80,7 +80,7 @@ describe('reconstructOriginalUrl', () => {
).toBe('https://static.klipy.com/ii/c8/28/HkAKKCzZ.webp?v=query_param&goes=here');
});
it('does not double the question mark when the query segment carries one', () => {
it('does not double the question mark when the query segment has one', () => {
const decoded = reconstructOriginalUrl('%3Fa%3D1/https/example.com/x.png');
expect(decoded).toBe('https://example.com/x.png?a=1');
expect(decoded).not.toContain('??');
+76 -4
View File
@@ -43,13 +43,13 @@ describe('buildAPIServerOptions', () => {
expect(server.requestTimeout).toBe(120_000);
});
test('carries the operator header timeout from the environment into the server', async () => {
test('passes the operator header timeout from the environment into the server', async () => {
const server = await listenWithEnv({FLUXER_API_HEADERS_TIMEOUT_MS: '45000'});
expect(server.headersTimeout).toBe(45_000);
expect(server.requestTimeout).toBe(120_000);
});
test('carries the operator request timeout from the environment into the server', async () => {
test('passes the operator request timeout from the environment into the server', async () => {
const server = await listenWithEnv({FLUXER_API_REQUEST_TIMEOUT_MS: '600000'});
expect(server.headersTimeout).toBe(30_000);
expect(server.requestTimeout).toBe(600_000);
@@ -134,7 +134,7 @@ describe('buildAPIConfigFromMaster stripe legacy prices', () => {
master = await loadConfig();
});
it('carries the retired stripe price map from master config onto the api config', () => {
it('copies the retired stripe price map from master config onto the api config', () => {
const legacyPrices = {
monthly_brl: ['price_retired_monthly_brl'],
yearly_brl: ['price_retired_yearly_brl_a', 'price_retired_yearly_brl_b'],
@@ -145,7 +145,7 @@ describe('buildAPIConfigFromMaster stripe legacy prices', () => {
);
});
it('carries the retired price map even when no live prices are configured', () => {
it('copies the retired price map even when no live prices are configured', () => {
const withoutPrices: MasterConfig = {
...master,
integrations: {
@@ -211,3 +211,75 @@ describe('buildAPIConfigFromMaster optional outbound lookups', () => {
expect(config.breachedPasswordCheck.enabled).toBe(false);
});
});
async function trustedCallersFromEnv(env: Record<string, string>) {
for (const [key, value] of Object.entries(env)) {
vi.stubEnv(key, value);
}
resetConfig();
return buildAPIConfigFromMaster(await loadConfig()).internal.trustedCallers;
}
describe('buildAPIConfigFromMaster trusted callers', () => {
const bugsKey = 'b'.repeat(32);
const donationKey = 'd'.repeat(32);
test('reads callers from FLUXER_API_TRUSTED_CALLERS', async () => {
const callers = await trustedCallersFromEnv({
FLUXER_API_TRUSTED_CALLERS: JSON.stringify([
{name: 'bugs', key: bugsKey, buckets: ['oauth:token', 'oauth:revoke']},
]),
});
expect(callers).toEqual([{name: 'bugs', key: bugsKey, buckets: ['oauth:token', 'oauth:revoke']}]);
});
test('turns FLUXER_API_DONATION_PROXY_KEY into a caller scoped to the donation buckets', async () => {
const callers = await trustedCallersFromEnv({FLUXER_API_DONATION_PROXY_KEY: donationKey});
expect(callers).toEqual([
{
name: 'donation',
key: donationKey,
buckets: ['donation:request_link', 'donation:manage', 'donation:checkout'],
},
]);
});
test('keeps both forms side by side', async () => {
const callers = await trustedCallersFromEnv({
FLUXER_API_DONATION_PROXY_KEY: donationKey,
FLUXER_API_TRUSTED_CALLERS: JSON.stringify([{name: 'bugs', key: bugsKey, buckets: ['oauth:token']}]),
});
expect(callers.map((caller) => caller.name)).toEqual(['bugs', 'donation']);
});
test('tolerates bucket names and fields this build does not know', async () => {
const callers = await trustedCallersFromEnv({
FLUXER_API_TRUSTED_CALLERS: JSON.stringify([
{name: 'bugs', key: bugsKey, buckets: ['oauth:token', 'future:bucket'], note: 'added later'},
]),
});
expect(callers).toEqual([{name: 'bugs', key: bugsKey, buckets: ['oauth:token', 'future:bucket']}]);
});
test('fails at boot on a short key', async () => {
await expect(
trustedCallersFromEnv({
FLUXER_API_TRUSTED_CALLERS: JSON.stringify([{name: 'bugs', key: 'short', buckets: ['oauth:token']}]),
}),
).rejects.toThrow('FLUXER_API_TRUSTED_CALLERS entry 1 key must be at least 32 characters');
});
test('fails at boot on an entry with no buckets', async () => {
await expect(
trustedCallersFromEnv({
FLUXER_API_TRUSTED_CALLERS: JSON.stringify([{name: 'bugs', key: bugsKey, buckets: []}]),
}),
).rejects.toThrow('FLUXER_API_TRUSTED_CALLERS entry 1 buckets must be a non-empty list of bucket names');
});
test('fails at boot on a value that is not a JSON array', async () => {
await expect(trustedCallersFromEnv({FLUXER_API_TRUSTED_CALLERS: '{"name":"bugs"}'})).rejects.toThrow(
'FLUXER_API_TRUSTED_CALLERS must be a JSON array',
);
});
});
+53 -6
View File
@@ -1,6 +1,7 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {APIConfig, BlueskyOAuthConfig} from '@app/api/config/APIConfig';
import type {APIConfig, BlueskyOAuthConfig, TrustedCallerConfig} from '@app/api/config/APIConfig';
import {DonationRateLimitConfigs} from '@app/api/rate_limit_configs/DonationRateLimitConfig';
import {parseIpBanEntry} from '@app/api/utils/IpRangeUtils';
import type {WorkerTaskName} from '@app/api/worker/WorkerLaneConfig';
import type {MasterConfig} from '@fluxer/config/src/MasterConfig';
@@ -99,6 +100,52 @@ function mapApnsApps(
});
}
const TRUSTED_CALLER_MIN_KEY_LENGTH = 32;
function parseTrustedCaller(entry: unknown, index: number): TrustedCallerConfig {
const label = `FLUXER_API_TRUSTED_CALLERS entry ${index + 1}`;
if (typeof entry !== 'object' || entry === null || Array.isArray(entry)) {
throw new Error(`${label} must be a JSON object`);
}
const name = Reflect.get(entry, 'name');
if (typeof name !== 'string' || name.trim().length === 0) {
throw new Error(`${label} must have a name`);
}
const key = Reflect.get(entry, 'key');
if (typeof key !== 'string' || key.trim().length < TRUSTED_CALLER_MIN_KEY_LENGTH) {
throw new Error(`${label} key must be at least ${TRUSTED_CALLER_MIN_KEY_LENGTH} characters`);
}
const buckets = Reflect.get(entry, 'buckets');
if (
!Array.isArray(buckets) ||
buckets.length === 0 ||
!buckets.every((bucket) => typeof bucket === 'string' && bucket.trim().length > 0)
) {
throw new Error(`${label} buckets must be a non-empty list of bucket names`);
}
return {
name: name.trim(),
key: key.trim(),
buckets: buckets.map((bucket: string) => bucket.trim()),
};
}
function buildTrustedCallers(master: MasterConfig): Array<TrustedCallerConfig> {
const trustedCallers = (master.services.api.trusted_callers ?? []).map(parseTrustedCaller);
const donationProxyKey = (master.services.api.donation_proxy_key ?? '').trim();
if (donationProxyKey.length > 0 && donationProxyKey.length < TRUSTED_CALLER_MIN_KEY_LENGTH) {
throw new Error(`FLUXER_API_DONATION_PROXY_KEY must be at least ${TRUSTED_CALLER_MIN_KEY_LENGTH} characters`);
}
if (donationProxyKey.length > 0) {
trustedCallers.push({
name: 'donation',
key: donationProxyKey,
buckets: Object.values(DonationRateLimitConfigs).map((routeConfig) => routeConfig.bucket),
});
}
return trustedCallers;
}
export function buildAPIConfigFromMaster(master: MasterConfig): APIConfig {
if (!master.internal) {
throw new Error('internal configuration is required for the API');
@@ -118,10 +165,7 @@ export function buildAPIConfigFromMaster(master: MasterConfig): APIConfig {
if (Buffer.from(uploadRelaySecretBase64, 'base64').length < 32) {
throw new Error('FLUXER_MEDIA_PROXY_UPLOAD_RELAY_SECRET_BASE64 must decode to at least 32 bytes');
}
const donationProxyKey = (master.services.api.donation_proxy_key ?? '').trim();
if (donationProxyKey.length > 0 && donationProxyKey.length < 32) {
throw new Error('FLUXER_API_DONATION_PROXY_KEY must be at least 32 characters');
}
const trustedCallers = buildTrustedCallers(master);
if (!s3Config) {
throw new Error('S3 configuration is required for the API');
}
@@ -227,7 +271,7 @@ export function buildAPIConfigFromMaster(master: MasterConfig): APIConfig {
},
internal: {
gatewayRpcAuthToken: master.services.gateway.rpc_auth_token ?? '',
donationProxyKey,
trustedCallers,
},
hosts: {
marketing: extractHostname(master.endpoints.marketing),
@@ -368,6 +412,7 @@ export function buildAPIConfigFromMaster(master: MasterConfig): APIConfig {
},
instance: {
selfHosted: master.instance.self_hosted,
baseDomain: master.domain.base_domain,
autoJoinInviteCode: master.instance.auto_join_invite_code,
visionariesGuildId: master.instance.visionaries_guild_id,
visionariesGuildVisionaryRoleId: master.instance.visionaries_guild_visionary_role_id,
@@ -385,6 +430,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) {
+33 -25
View File
@@ -161,38 +161,43 @@ export class AdminRepository implements IAdminRepository {
return false;
}
async banIp(ip: string): Promise<void> {
if (isIpBanExempt(ip)) {
return;
}
const canonicalIp = canonicalizeBannedIpEntry(ip);
await upsertOne(
BannedIps.insert({
ip: canonicalIp,
ban_kind: 'permanent',
reason: 'platform_admin_enforcement',
expires_at: null,
created_at: new Date(),
}),
);
async banIp(ip: string, ttlSeconds: number | null = null): Promise<void> {
await this.writeIpBan(ip, 'platform_admin_enforcement', ttlSeconds);
}
async banIpTemp(ip: string, ttlSeconds: number): Promise<void> {
if (!Number.isInteger(ttlSeconds) || ttlSeconds <= 0) {
await this.writeIpBan(ip, 'abusive_api_access_patterns', ttlSeconds);
}
private async writeIpBan(ip: string, reason: string, ttlSeconds: number | null): Promise<void> {
if (ttlSeconds !== null && (!Number.isInteger(ttlSeconds) || ttlSeconds <= 0)) {
throw new RangeError('Temporary IP ban TTL must be a positive integer');
}
if (isIpBanExempt(ip)) {
return;
}
const canonicalIp = canonicalizeBannedIpEntry(ip);
const createdAt = new Date();
if (ttlSeconds === null) {
await upsertOne(
BannedIps.insert({
ip: canonicalIp,
ban_kind: 'permanent',
reason,
expires_at: null,
created_at: createdAt,
}),
);
return;
}
await upsertOne(
BannedIps.insertWithTtl(
{
ip: canonicalIp,
ban_kind: 'temporary_24h',
reason: 'abusive_api_access_patterns',
expires_at: new Date(Date.now() + ttlSeconds * 1000),
created_at: new Date(),
reason,
expires_at: new Date(createdAt.getTime() + ttlSeconds * 1000),
created_at: createdAt,
},
ttlSeconds,
),
@@ -228,13 +233,16 @@ export class AdminRepository implements IAdminRepository {
expires_at?: Date | null;
created_at?: Date | null;
}>(LOAD_ALL_BANNED_IPS_QUERY.bind({}));
return rows.map((row) => ({
ip: row.ip,
kind: parseBannedIpKind(row.ban_kind),
reason: row.reason ?? null,
expiresAt: row.expires_at ?? null,
createdAt: row.created_at ?? null,
}));
const now = Date.now();
return rows
.filter((row) => !row.expires_at || row.expires_at.getTime() > now)
.map((row) => ({
ip: row.ip,
kind: parseBannedIpKind(row.ban_kind),
reason: row.reason ?? null,
expiresAt: row.expires_at ?? null,
createdAt: row.created_at ?? null,
}));
}
async isEmailBanned(email: string): Promise<boolean> {
+1 -1
View File
@@ -43,7 +43,7 @@ export abstract class IAdminRepository {
abstract isIpBanned(ip: string): Promise<boolean>;
abstract banIp(ip: string): Promise<void>;
abstract banIp(ip: string, ttlSeconds?: number | null): Promise<void>;
abstract banIpTemp(ip: string, ttlSeconds: number): Promise<void>;
@@ -9,7 +9,7 @@ import {OpenAPI} from '@app/api/middleware/ResponseTypeMiddleware';
import {RateLimitConfigs} from '@app/api/RateLimitConfig';
import type {HonoApp} from '@app/api/types/HonoEnv';
import {Validator} from '@app/api/Validator';
import {AdminACLs} from '@fluxer/constants/src/AdminACLs';
import {AdminACLs, filterKnownAdminACLs} from '@fluxer/constants/src/AdminACLs';
import {
AdminApiKeyListResponse,
CreateAdminApiKeyRequest,
@@ -30,7 +30,7 @@ function toApiKeyResponse(key: AdminApiKeyView): ListAdminApiKeyResponseType {
last_used_at: key.lastUsedAt?.toISOString() ?? null,
expires_at: key.expiresAt?.toISOString() ?? null,
created_by_user_id: String(key.createdById),
acls: Array.from(key.acls),
acls: filterKnownAdminACLs(key.acls),
};
}
@@ -62,7 +62,7 @@ export function AdminApiKeyAdminController(app: HonoApp) {
name: result.apiKey.name,
created_at: result.apiKey.createdAt.toISOString(),
expires_at: result.apiKey.expiresAt?.toISOString() ?? null,
acls: Array.from(result.apiKey.acls),
acls: filterKnownAdminACLs(result.apiKey.acls),
};
await recordAdminWrite(ctx, {
targetType: 'admin_api_key',
@@ -146,7 +146,7 @@ export function AdminApiKeyAdminController(app: HonoApp) {
security: ['adminApiKey'],
tags: ['Admin'],
description:
'Renames an API key or replaces the access control lists (ACLs) it carries. The key may only carry permissions the acting admin already holds. Omitted fields are left unchanged and the key material is never rotated or returned.',
'Renames an API key or replaces the access control lists (ACLs) it has. The key may only hold permissions the acting admin already holds. Omitted fields are left unchanged and the key material is never rotated or returned.',
}),
async (ctx) => {
const adminApiKeyService = ctx.get('adminApiKeyService');
@@ -57,9 +57,9 @@ const BLOCKLIST_CATALOG = [
{
list_type: 'ip' as const,
description:
'IPv4/IPv6 addresses and CIDR ranges denied service. Applies to live connections and can be applied retroactively.',
'IPv4/IPv6 addresses and CIDR ranges denied service. Applies to live connections and can be applied retroactively. An entry can carry an expiry, after which it stops applying and is removed.',
value_field: 'ip',
fields: [],
fields: ['duration_hours'],
scoped: false,
supports_bulk_create: false,
supports_bulk_delete: false,
@@ -99,7 +99,8 @@ const BLOCKLIST_CATALOG = [
},
{
list_type: 'url-domain' as const,
description: 'Domains blocked from being linked, optionally covering every subdomain rooted at the domain.',
description:
'Domains blocked from being linked, optionally covering every subdomain rooted at the domain. A value whose leftmost label contains * is a pattern that matches that one label under a registrable domain.',
value_field: 'domain',
fields: ['match_subdomains', 'category', 'severity', 'source_url', 'notes'],
scoped: false,
@@ -232,7 +233,7 @@ async function checkBlocklistEntry(
listType: AdminBlocklistListType,
entryValue: string,
scope: ProfileSubstringScope | undefined,
): Promise<{banned: boolean}> {
): Promise<{banned: boolean; expires_at?: string | null}> {
switch (listType) {
case 'ip':
return bans.checkIpBan({ip: entryValue});
@@ -269,7 +270,7 @@ export function BanAdminController(app: HonoApp) {
security: ['adminApiKey'],
tags: ['Admin'],
description:
'List every blocklist this instance maintains, the request field that carries an entry value, the extra fields its entries accept, and which of the bulk and update operations it supports.',
'List every blocklist this instance maintains, the request field that holds an entry value, the extra fields its entries accept, and which of the bulk and update operations it supports.',
}),
async (ctx) => {
await recordAdminRead(ctx, {
@@ -488,7 +489,7 @@ export function BanAdminController(app: HonoApp) {
security: ['adminApiKey'],
tags: ['Admin'],
description:
'Report whether a value is currently blocked by a blocklist. The value is percent-encoded in the path. An IP address can still match a broader stored CIDR entry, and a URL can match a banned domain. The profile-substring blocklist requires a scope.',
'Report whether a value is currently blocked by a blocklist. The value is percent-encoded in the path. An IP address can still match a broader stored CIDR entry, and a url-domain value can be a hostname or an http(s) URL that a stored domain or pattern covers. The profile-substring blocklist requires a scope.',
}),
async (ctx) => {
const adminService = ctx.get('adminService');
@@ -507,7 +508,7 @@ export function BanAdminController(app: HonoApp) {
banned: result.banned,
},
});
return ctx.json(result);
return ctx.json({banned: result.banned, expires_at: result.expires_at ?? null});
},
);
app.patch(
@@ -524,7 +525,7 @@ export function BanAdminController(app: HonoApp) {
tags: ['Admin'],
requestSchema: AdminBlocklistEntryUpdateRequest,
description:
'Rewrite the stored fields of a blocklist entry without removing and re-adding it. The stored metadata is replaced by the supplied fields, so fields left out fall back to their defaults. Only blocklists whose entries carry fields accept this operation, reported as supports_update by GET /admin/blocklists.',
'Rewrite the stored fields of a blocklist entry without removing and re-adding it. The stored metadata is replaced by the supplied fields, so fields left out fall back to their defaults. Only blocklists whose entries have fields accept this operation, reported as supports_update by GET /admin/blocklists.',
}),
async (ctx) => {
const adminService = ctx.get('adminService');
@@ -85,14 +85,17 @@ async function buildInstanceConfigResponse(): Promise<InstanceConfigResponse> {
instanceConfigRepository.getRegistrationUrlsForAdmin(),
instanceConfigRepository.getPendingRegistrations(),
]);
const [appPublic, policy, resolvedServices, integrations, media, billing] = await Promise.all([
instanceConfigRepository.getAppPublicConfig(),
instanceConfigRepository.getInstancePolicyConfig(),
instanceConfigRepository.getResolvedServicesConfig(),
instanceConfigRepository.getInstanceIntegrationsAdminConfig(),
instanceConfigRepository.getInstanceMediaAdminConfig(),
instanceConfigRepository.getInstanceBillingAdminConfig(),
]);
const [appPublic, policy, resolvedServices, integrations, media, billing, accountIdentity, accountIdentityLocked] =
await Promise.all([
instanceConfigRepository.getAppPublicConfig(),
instanceConfigRepository.getInstancePolicyConfig(),
instanceConfigRepository.getResolvedServicesConfig(),
instanceConfigRepository.getInstanceIntegrationsAdminConfig(),
instanceConfigRepository.getInstanceMediaAdminConfig(),
instanceConfigRepository.getInstanceBillingAdminConfig(),
instanceConfigRepository.getAccountIdentity(),
instanceConfigRepository.isAccountIdentityLocked(),
]);
return {
sso: {
enabled: ssoConfig.enabled,
@@ -122,6 +125,11 @@ async function buildInstanceConfigResponse(): Promise<InstanceConfigResponse> {
pending_registrations: pendingRegistrations,
},
self_hosted: Config.instance.selfHosted,
account_identity: {
mode: accountIdentity.mode,
locked: accountIdentityLocked,
tag_style: accountIdentity.tagStyle,
},
app_public: appPublic,
policy: {
single_community_enabled: policy.single_community_enabled,
@@ -49,7 +49,7 @@ export function MessageAdminController(app: HonoApp) {
operationId: 'search_admin_messages',
summary: 'Search messages',
description:
'Searches the messages of a channel by content, or resolves a single message by its ID or by one of its attachments. Passing message_id returns that message with the messages surrounding it; passing attachment_id together with filename returns the message carrying that attachment with its surrounding context. Requires MESSAGE_LOOKUP permission.',
'Searches the messages of a channel by content, or resolves a single message by its ID or by one of its attachments. Passing message_id returns that message with the messages surrounding it; passing attachment_id together with filename returns the message with that attachment with its surrounding context. Requires MESSAGE_LOOKUP permission.',
responseSchema: AdminMessageSearchResponse,
statusCode: 200,
security: 'adminApiKey',
@@ -178,7 +178,7 @@ export function ReportAdminController(app: HonoApp) {
const adminUserId = ctx.get('adminUserId');
const auditLogReason = ctx.get('auditLogReason');
const {report_id} = ctx.req.valid('param');
const {public_comment, notify_reporter} = ctx.req.valid('json');
const {public_comment, notify_reporter, resolution} = ctx.req.valid('json');
return ctx.json(
await adminService.reportServiceAggregate.resolveReport(
createReportID(report_id),
@@ -186,6 +186,7 @@ export function ReportAdminController(app: HonoApp) {
public_comment || null,
auditLogReason,
notify_reporter,
resolution,
),
);
},
@@ -4,6 +4,10 @@ import {AdminAuditReadActions} from '@app/api/admin/AdminAuditActions';
import {recordAdminRead} from '@app/api/admin/AdminAuditRecorder';
import {mapUserToAdminResponse} from '@app/api/admin/models/UserTypes';
import {createUserID} from '@app/api/BrandedTypes';
import {
RequireEmailAccountIdentity,
RequireUsernameAccountIdentity,
} from '@app/api/middleware/AccountIdentityMiddleware';
import {requireAdminACL} from '@app/api/middleware/AdminMiddleware';
import {RateLimitMiddleware} from '@app/api/middleware/RateLimitMiddleware';
import {OpenAPI} from '@app/api/middleware/ResponseTypeMiddleware';
@@ -16,10 +20,10 @@ import {ListUserGuildsResponse} from '@fluxer/schema/src/domains/admin/AdminGuil
import {SearchUsersResponse} from '@fluxer/schema/src/domains/admin/AdminSchemas';
import {
AdminAclListResponse,
AdminPasswordResetLinkResponse,
AdminUserAclsRequest,
AdminUserBanNoteRequest,
AdminUserBanRequest,
AdminUserBotStatusRequest,
AdminUserChangeLogQuery,
AdminUserClearFieldsRequest,
AdminUserDeletionCancelRequest,
@@ -34,7 +38,6 @@ import {
AdminUserPremiumFlagsUpdateRequest,
AdminUserRelationshipCategoryQuery,
AdminUserRelationshipParam,
AdminUserSystemStatusRequest,
AdminUsersMeResponse,
AdminUserTraitsRequest,
AdminUserUnbanRequest,
@@ -598,70 +601,6 @@ export function UserAdminController(app: HonoApp) {
);
},
);
app.put(
'/admin/users/:user_id/bot-status',
RateLimitMiddleware(RateLimitConfigs.ADMIN_USER_MODIFY),
requireAdminACL(AdminACLs.USER_UPDATE_BOT_STATUS),
Validator('param', UserIdParam),
Validator('json', AdminUserBotStatusRequest),
OpenAPI({
operationId: 'set_admin_user_bot_status',
summary: 'Set user bot status',
responseSchema: UserMutationResponse,
statusCode: 200,
security: 'adminApiKey',
tags: 'Admin',
description:
'Mark or unmark a user account as a bot. Controls bot badge visibility and API permissions. Creates audit log entry. Requires USER_UPDATE_BOT_STATUS permission.',
}),
async (ctx) => {
const adminService = ctx.get('adminService');
const adminUserId = ctx.get('adminUserId');
const auditLogReason = ctx.get('auditLogReason');
const adminUserAcls = ctx.get('adminUserAcls');
const {user_id: userId} = ctx.req.valid('param');
return ctx.json(
await adminService.userService.profileService.setUserBotStatus(
{user_id: userId, ...ctx.req.valid('json')},
adminUserId,
auditLogReason,
adminUserAcls,
),
);
},
);
app.put(
'/admin/users/:user_id/system-status',
RateLimitMiddleware(RateLimitConfigs.ADMIN_USER_MODIFY),
requireAdminACL(AdminACLs.USER_UPDATE_BOT_STATUS),
Validator('param', UserIdParam),
Validator('json', AdminUserSystemStatusRequest),
OpenAPI({
operationId: 'set_admin_user_system_status',
summary: 'Set user system status',
responseSchema: UserMutationResponse,
statusCode: 200,
security: 'adminApiKey',
tags: 'Admin',
description:
'Mark or unmark a user as a system account. System accounts have special permissions for automated operations. Creates audit log entry. Requires USER_UPDATE_BOT_STATUS permission.',
}),
async (ctx) => {
const adminService = ctx.get('adminService');
const adminUserId = ctx.get('adminUserId');
const auditLogReason = ctx.get('auditLogReason');
const adminUserAcls = ctx.get('adminUserAcls');
const {user_id: userId} = ctx.req.valid('param');
return ctx.json(
await adminService.userService.profileService.setUserSystemStatus(
{user_id: userId, ...ctx.req.valid('json')},
adminUserId,
auditLogReason,
adminUserAcls,
),
);
},
);
app.patch(
'/admin/users/:user_id/username',
RateLimitMiddleware(RateLimitConfigs.ADMIN_USER_MODIFY),
@@ -698,6 +637,7 @@ export function UserAdminController(app: HonoApp) {
'/admin/users/:user_id/email',
RateLimitMiddleware(RateLimitConfigs.ADMIN_USER_MODIFY),
requireAdminACL(AdminACLs.USER_UPDATE_EMAIL),
RequireEmailAccountIdentity,
Validator('param', UserIdParam),
Validator('json', AdminUserEmailUpdateRequest),
OpenAPI({
@@ -730,6 +670,7 @@ export function UserAdminController(app: HonoApp) {
'/admin/users/:user_id/email-verification',
RateLimitMiddleware(RateLimitConfigs.ADMIN_USER_MODIFY),
requireAdminACL(AdminACLs.USER_UPDATE_EMAIL),
RequireEmailAccountIdentity,
Validator('param', UserIdParam),
OpenAPI({
operationId: 'verify_admin_user_email',
@@ -761,6 +702,7 @@ export function UserAdminController(app: HonoApp) {
'/admin/users/:user_id/verification-email',
RateLimitMiddleware(RateLimitConfigs.ADMIN_USER_MODIFY),
requireAdminACL(AdminACLs.USER_UPDATE_EMAIL),
RequireEmailAccountIdentity,
Validator('param', UserIdParam),
OpenAPI({
operationId: 'resend_admin_user_verification_email',
@@ -789,6 +731,7 @@ export function UserAdminController(app: HonoApp) {
'/admin/users/:user_id/password-reset',
RateLimitMiddleware(RateLimitConfigs.ADMIN_USER_MODIFY),
requireAdminACL(AdminACLs.USER_UPDATE_EMAIL),
RequireEmailAccountIdentity,
Validator('param', UserIdParam),
OpenAPI({
operationId: 'send_admin_user_password_reset',
@@ -809,6 +752,65 @@ export function UserAdminController(app: HonoApp) {
return ctx.body(null, 204);
},
);
app.post(
'/admin/users/:user_id/password-reset-link',
RateLimitMiddleware(RateLimitConfigs.ADMIN_USER_MODIFY),
requireAdminACL(AdminACLs.USER_CREATE_PASSWORD_RESET_LINK),
RequireUsernameAccountIdentity,
Validator('param', UserIdParam),
OpenAPI({
operationId: 'create_admin_user_password_reset_link',
summary: 'Create user password reset link',
responseSchema: AdminPasswordResetLinkResponse,
statusCode: 200,
security: 'adminApiKey',
tags: 'Admin',
description:
'Create a one-time password reset link on an instance where people sign in with a username. Hand the link to the user yourself. It works once and expires after an hour. Deletes the recovery kit of the account. Creates audit log entry. Requires USER_CREATE_PASSWORD_RESET_LINK permission and every ACL the target account holds. Fails with USERNAME_SIGN_IN_ONLY on email instances.',
}),
async (ctx) => {
const adminService = ctx.get('adminService');
const adminUserId = ctx.get('adminUserId');
const auditLogReason = ctx.get('auditLogReason');
const {user_id: userId} = ctx.req.valid('param');
return ctx.json(
await adminService.userService.securityService.createPasswordResetLink(
{user_id: userId},
adminUserId,
auditLogReason,
ctx.get('adminUserAcls'),
),
);
},
);
app.delete(
'/admin/users/:user_id/recovery-kit',
RateLimitMiddleware(RateLimitConfigs.ADMIN_USER_MODIFY),
requireAdminACL(AdminACLs.USER_DELETE_RECOVERY_KIT),
RequireUsernameAccountIdentity,
Validator('param', UserIdParam),
OpenAPI({
operationId: 'revoke_admin_user_recovery_kit',
summary: 'Revoke user recovery kit',
responseSchema: null,
statusCode: 204,
security: 'adminApiKey',
tags: 'Admin',
description:
'Deletes the recovery kit of an account on an instance where people sign in with a username, so its key stops working. Creates audit log entry. Requires USER_DELETE_RECOVERY_KIT permission and every ACL the target account holds. Fails with USERNAME_SIGN_IN_ONLY on email instances.',
}),
async (ctx) => {
const adminService = ctx.get('adminService');
const {user_id: userId} = ctx.req.valid('param');
await adminService.userService.securityService.revokeRecoveryKit(
{user_id: userId},
ctx.get('adminUserId'),
ctx.get('auditLogReason'),
ctx.get('adminUserAcls'),
);
return ctx.body(null, 204);
},
);
app.put(
'/admin/users/:user_id/ban',
RateLimitMiddleware(RateLimitConfigs.ADMIN_USER_MODIFY),
+2 -2
View File
@@ -2,7 +2,7 @@
import type {User} from '@app/api/models/User';
import {getIpAddressReverse, lookupGeoip} from '@app/api/utils/IpUtils';
import {AdminACLs} from '@fluxer/constants/src/AdminACLs';
import {AdminACLs, filterKnownAdminACLs} from '@fluxer/constants/src/AdminACLs';
import type {UserAdminResponse} from '@fluxer/schema/src/domains/admin/AdminUserSchemas';
import type {ICacheService} from '@pkgs/cache/src/ICacheService';
import {formatGeoipLocation} from '@pkgs/geoip/src/GeoipLookup';
@@ -65,7 +65,7 @@ export async function mapUserToAdminResponse(
deletion_audit_log_reason: canViewAuditLog ? user.deletionAuditLogReason : null,
deletion_scheduled_by: user.deletionScheduledBy?.toString() ?? null,
deletion_scheduled_at: user.deletionScheduledAt?.toISOString() ?? null,
acls: user.acls ? Array.from(user.acls) : [],
acls: user.acls ? filterKnownAdminACLs(user.acls) : [],
traits: Array.from(user.traits).sort(),
has_totp: user.totpSecret !== null,
authenticator_types: user.authenticatorTypes ? Array.from(user.authenticatorTypes) : [],
@@ -24,6 +24,7 @@ import {phraseBlocklistCache} from '@app/api/middleware/PhraseBlocklistCache';
import {profileSubstringBlocklistCache} from '@app/api/middleware/ProfileSubstringBlocklistCache';
import {urlBlocklistCache} from '@app/api/middleware/UrlBlocklistCache';
import {canonicalizeStoredPhrase} from '@app/api/utils/PhraseBlocklistNormalization';
import {parseUrlDomainEntry} from '@app/api/utils/UrlHostRules';
import {canonicalizeUrl} from '@app/api/utils/UrlNormalizer';
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes';
@@ -108,6 +109,16 @@ function normalizeAvatarHashes(hashes: Array<string>): Array<string> {
return Array.from(new Set(hashes.map((hash) => stripAvatarAnimationPrefix(hash.toLowerCase()))));
}
function hostFromUrlOrHostname(value: string): string | null {
const trimmed = value.trim();
if (!/^https?:\/\//i.test(trimmed)) return trimmed;
try {
return new URL(trimmed).hostname;
} catch {
return null;
}
}
function withReasonMetadata(entries: Array<[string, string]>, reason: string | undefined): Map<string, string> {
if (!reason) {
return new Map(entries);
@@ -122,6 +133,7 @@ export class AdminBanManagementService {
async banIp(
data: {
ip: string;
duration_hours?: number;
},
adminUserId: UserID,
auditLogReason: string | null,
@@ -142,15 +154,24 @@ export class AdminBanManagementService {
message: 'This IP address is on the instance exemption list',
});
}
await adminRepository.banIp(data.ip);
ipBanCache.ban(data.ip);
const durationHours = data.duration_hours ?? 0;
const metadata = new Map([['ip', data.ip]]);
if (durationHours > 0) {
const ttlSeconds = durationHours * 3600;
await adminRepository.banIp(data.ip, ttlSeconds);
metadata.set('duration_hours', durationHours.toString());
metadata.set('expires_at', new Date(Date.now() + ttlSeconds * 1000).toISOString());
} else {
await adminRepository.banIp(data.ip);
}
await ipBanCache.refresh();
await cacheService.publish(IP_BAN_REFRESH_CHANNEL, 'refresh');
await this.createBlocklistAuditLog({
adminUserId,
targetType: 'ip',
action: 'ban_ip',
auditLogReason,
metadata: new Map([['ip', data.ip]]),
metadata,
});
}
@@ -177,9 +198,10 @@ export class AdminBanManagementService {
async checkIpBan(data: {ip: string}): Promise<{
banned: boolean;
expires_at: string | null;
}> {
const banned = ipBanCache.isBanned(data.ip);
return {banned};
const match = ipBanCache.getMatch(data.ip);
return {banned: match !== null, expires_at: toIsoString(match?.expiresAt)};
}
async banEmail(
@@ -355,7 +377,9 @@ export class AdminBanManagementService {
) {
const {adminRepository} = this.deps;
const {cache: cacheService} = this.deps.apiContext.services;
const d = data.domain.toLowerCase();
const entry = parseUrlDomainEntry(data.domain);
if (!entry.ok) throw InputValidationError.create('domain', entry.message);
const d = entry.value;
const matchSubs = data.match_subdomains ?? true;
await adminRepository.banUrlDomain({
domain: d,
@@ -367,7 +391,7 @@ export class AdminBanManagementService {
added_by: adminUserId,
notes: data.notes ?? null,
});
urlBlocklistCache.addDomain(d);
urlBlocklistCache.addDomain(d, matchSubs);
await cacheService.publish(BANNED_URL_DOMAINS_REFRESH_CHANNEL, 'refresh');
await this.createBlocklistAuditLog({
adminUserId,
@@ -377,6 +401,7 @@ export class AdminBanManagementService {
metadata: new Map([
['domain', d],
['match_subdomains', String(matchSubs)],
['pattern', String(entry.pattern)],
]),
});
}
@@ -390,7 +415,8 @@ export class AdminBanManagementService {
) {
const {adminRepository} = this.deps;
const {cache: cacheService} = this.deps.apiContext.services;
const d = data.domain.toLowerCase();
const entry = parseUrlDomainEntry(data.domain);
const d = entry.ok ? entry.value : data.domain.trim().toLowerCase();
await adminRepository.unbanUrlDomain(d);
urlBlocklistCache.removeDomain(d);
await cacheService.publish(BANNED_URL_DOMAINS_REFRESH_CHANNEL, 'refresh');
@@ -406,7 +432,8 @@ export class AdminBanManagementService {
async checkUrlDomainBan(data: {domain: string}): Promise<{
banned: boolean;
}> {
return {banned: urlBlocklistCache.isHostnameBanned(data.domain)};
const host = hostFromUrlOrHostname(data.domain);
return {banned: host != null && urlBlocklistCache.isHostnameBanned(host)};
}
async banFileSha(
@@ -261,7 +261,8 @@ export class AdminMessageService {
private async getMessageResponseAccessForAdmin(channelId: ChannelID): Promise<MessageResponseAccessContext> {
const channel = await this.deps.channelRepository.findUnique(channelId);
return channel ? messageResponseAccessForChannel(channel) : messageResponseAccessForGuild(null);
const access = channel ? messageResponseAccessForChannel(channel) : messageResponseAccessForGuild(null);
return {...access, includeHidden: true};
}
private async listMessageResponsesForAdmin(params: {
@@ -34,11 +34,13 @@ import type {User} from '@app/api/models/User';
import type {IARMessageContext, IARSubmission} from '@app/api/report/IReportRepository';
import type {ReportService} from '@app/api/report/ReportService';
import {getReportSearchService} from '@app/api/SearchFactory';
import {isHiddenPartial} from '@app/api/user/ProfileVisibility';
import type {UserChannelService} from '@app/api/user/services/UserChannelService';
import {formatUserTag} from '@app/api/user/UserTag';
import {assertSafeByteSize} from '@app/api/utils/ByteSizeUtils';
import {AdminACLs} from '@fluxer/constants/src/AdminACLs';
import {FeatureTemporarilyDisabledError} from '@fluxer/errors/src/domains/core/FeatureTemporarilyDisabledError';
import type {SearchReportsRequest} from '@fluxer/schema/src/domains/admin/AdminSchemas';
import type {SearchReportsRequest, UpdateReportRequest} from '@fluxer/schema/src/domains/admin/AdminSchemas';
import type {MessageResponse} from '@fluxer/schema/src/domains/message/MessageResponseSchemas';
import {getEmailTemplate} from '@pkgs/email/src/email_i18n/EmailI18n';
import {seconds} from 'itty-time';
@@ -56,6 +58,8 @@ interface AdminReportServiceDeps {
ncmecSubmissionService: NcmecSubmissionService;
}
type StaffReportResolution = NonNullable<UpdateReportRequest['resolution']>;
interface ReportNsfwLookupCache {
channelNsfwByChannelId: Map<string, boolean | null>;
guildNsfwLevelByGuildId: Map<string, number | null>;
@@ -105,10 +109,14 @@ export class AdminReportService {
publicComment: string | null,
auditLogReason: string | null,
notifyReporter: boolean,
resolution?: StaffReportResolution,
) {
const {reportService, auditService} = this.deps;
const {users: userRepository, email: emailService} = this.deps.apiContext.services;
const resolvedReport = await reportService.resolveReport(reportId, adminUserId, publicComment, auditLogReason);
const resolvedReport = await reportService.resolveReport(reportId, adminUserId, publicComment, auditLogReason, {
outcome: resolution,
resolvedBy: 'staff',
});
let reporterDmSent = false;
let reporterEmailSent = false;
const reporter =
@@ -147,6 +155,7 @@ export class AdminReportService {
['notify_reporter', notifyReporter ? 'true' : 'false'],
['reporter_dm_sent', reporterDmSent ? 'true' : 'false'],
['reporter_email_sent', reporterEmailSent ? 'true' : 'false'],
...(resolution ? [['resolution', resolution] as [string, string]] : []),
]),
});
return {
@@ -418,7 +427,8 @@ export class AdminReportService {
private async getMessageResponseAccessForAdmin(channelId: ChannelID): Promise<MessageResponseAccessContext> {
const channel = await this.deps.channelRepository.findUnique(channelId);
return channel ? messageResponseAccessForChannel(channel) : messageResponseAccessForGuild(null);
const access = channel ? messageResponseAccessForChannel(channel) : messageResponseAccessForGuild(null);
return {...access, includeHidden: true};
}
private async getMutualDmChannelId(report: IARSubmission): Promise<string | null> {
@@ -617,10 +627,23 @@ export class AdminReportService {
return null;
}
try {
const user = await this.deps.userCacheService.getUserPartialResponse(userId, requestCache);
const cached = await this.deps.userCacheService.getUserPartialResponse(userId, requestCache);
const stored = isHiddenPartial(cached) ? await this.deps.apiContext.services.users.findUnique(userId) : null;
const user = stored
? {
username: stored.username,
global_name: stored.globalName,
discriminator: stored.discriminator.toString(),
bot: stored.isBot,
}
: cached;
const discriminator = user.discriminator?.padStart(4, '0') ?? '0000';
return {
tag: `${user.username}#${discriminator}`,
tag: formatUserTag({
username: user.username,
discriminator: Number.parseInt(discriminator, 10),
isBot: user.bot ?? false,
}),
username: user.username,
global_name: user.global_name ?? null,
discriminator,
@@ -19,6 +19,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';
@@ -246,7 +247,7 @@ 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});
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,8 +382,13 @@ export class AdminUserDeletionService {
return knownIps;
}
private async resolvePendingReportsAgainstUser(params: {user: User; adminUserId: UserID}): Promise<void> {
const {user, adminUserId} = params;
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} = this.deps;
const reportSearchService = getReportSearchService();
if (!reportSearchService) {
@@ -423,7 +429,10 @@ export class AdminUserDeletionService {
for (const hitId of pendingReportIds) {
const reportId = createReportID(BigInt(hitId));
try {
await reportService.resolveReport(reportId, adminUserId, null, auditLogReason);
await reportService.resolveReport(reportId, adminUserId, null, auditLogReason, {
outcome,
resolvedBy: 'system',
});
resolvedCount++;
} catch (error) {
if (error instanceof ReportAlreadyResolvedError) continue;
@@ -4,7 +4,10 @@ import type {ApiContext} from '@app/api/ApiContext';
import {mapUserToAdminResponse} from '@app/api/admin/models/UserTypes';
import {createUserID} from '@app/api/BrandedTypes';
import {isSyntheticUserId} from '@app/api/constants/Core';
import {usesUniqueUsernames} from '@app/api/instance/AccountIdentityModeCache';
import {Logger} from '@app/api/Logger';
import type {User} from '@app/api/models/User';
import {findPersonByLoginHandle, parseLoginHandle} from '@app/api/user/UniqueUsernames';
import type {LookupUserRequest} from '@fluxer/schema/src/domains/admin/AdminUserSchemas';
interface AdminUserLookupServiceDeps {
@@ -41,10 +44,17 @@ export class AdminUserLookupService {
} else if (query.includes('@')) {
user = await userRepository.findByEmail(query);
} else {
user = await userRepository.findByStripeSubscriptionId(query);
user = (await this.findPersonByBareUsername(query)) ?? (await userRepository.findByStripeSubscriptionId(query));
}
return {
users: user ? [await mapUserToAdminResponse(user, cacheService, acls)] : [],
};
}
private async findPersonByBareUsername(query: string): Promise<User | null> {
if (!usesUniqueUsernames()) return null;
const handle = parseLoginHandle(query);
if (!handle || handle.discriminator !== null) return null;
return await findPersonByLoginHandle(this.deps.apiContext.services.users, handle);
}
}
@@ -10,10 +10,10 @@ import {GuildMemberSearchIndexService} from '@app/api/guild/services/member/Guil
import type {IDiscriminatorService} from '@app/api/infrastructure/DiscriminatorService';
import type {EntityAssetService, PreparedAssetUpload} from '@app/api/infrastructure/EntityAssetService';
import {Logger} from '@app/api/Logger';
import {getInstanceConfigRepository} from '@app/api/middleware/ServiceSingletons';
import type {User} from '@app/api/models/User';
import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes';
import {AccessDeniedError} from '@fluxer/errors/src/domains/core/AccessDeniedError';
import {InputValidationError} from '@fluxer/errors/src/domains/core/InputValidationError';
import {assertNoDiscriminatorChange, reserveUsername, type UsernameReservation} from '@app/api/user/UniqueUsernames';
import {USERNAME_MODE_DISCRIMINATOR} from '@app/api/user/UserTag';
import {TagAlreadyTakenError} from '@fluxer/errors/src/domains/user/TagAlreadyTakenError';
import {UnknownUserError} from '@fluxer/errors/src/domains/user/UnknownUserError';
import type {
@@ -21,8 +21,6 @@ import type {
ChangeEmailRequest,
ChangeUsernameRequest,
ClearUserFieldsRequest,
SetUserBotStatusRequest,
SetUserSystemStatusRequest,
VerifyUserEmailRequest,
} from '@fluxer/schema/src/domains/admin/AdminUserSchemas';
import {types} from 'cassandra-driver';
@@ -105,75 +103,6 @@ export class AdminUserProfileService {
};
}
async setUserBotStatus(
data: SetUserBotStatusRequest,
adminUserId: UserID,
auditLogReason: string | null,
acls: ReadonlySet<string>,
) {
const {users: userRepository, cache: cacheService} = this.deps.apiContext.services;
const {auditService, updatePropagator} = this.deps;
const userId = createUserID(data.user_id);
const user = await userRepository.findUnique(userId);
if (!user) {
throw new UnknownUserError();
}
if (data.bot && user.acls.size > 0) {
throw new AccessDeniedError();
}
const updates: Record<string, boolean> = {bot: data.bot};
if (!data.bot) {
updates['system'] = false;
}
const updatedUser = await userRepository.patchUpsert(userId, updates, user.toRow());
await updatePropagator.propagateUserUpdate({userId, oldUser: user, updatedUser: updatedUser});
await auditService.createAuditLog({
adminUserId,
targetType: 'user',
targetId: BigInt(userId),
action: 'set_bot_status',
auditLogReason,
metadata: new Map([['bot', data.bot.toString()]]),
});
return {
user: await mapUserToAdminResponse(updatedUser, cacheService, acls),
};
}
async setUserSystemStatus(
data: SetUserSystemStatusRequest,
adminUserId: UserID,
auditLogReason: string | null,
acls: ReadonlySet<string>,
) {
const {users: userRepository, cache: cacheService} = this.deps.apiContext.services;
const {auditService, updatePropagator} = this.deps;
const userId = createUserID(data.user_id);
const user = await userRepository.findUnique(userId);
if (!user) {
throw new UnknownUserError();
}
if (data.system && !user.isBot) {
throw InputValidationError.fromCode(
'system',
ValidationErrorCodes.USER_MUST_BE_A_BOT_TO_BE_MARKED_AS_A_SYSTEM_USER,
);
}
const updatedUser = await userRepository.patchUpsert(userId, {system: data.system}, user.toRow());
await updatePropagator.propagateUserUpdate({userId, oldUser: user, updatedUser: updatedUser});
await auditService.createAuditLog({
adminUserId,
targetType: 'user',
targetId: BigInt(userId),
action: 'set_system_status',
auditLogReason,
metadata: new Map([['system', data.system.toString()]]),
});
return {
user: await mapUserToAdminResponse(updatedUser, cacheService, acls),
};
}
async verifyUserEmail(
data: VerifyUserEmailRequest,
adminUserId: UserID,
@@ -223,22 +152,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,
@@ -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);
}
}
@@ -78,6 +78,7 @@ export class AdminGuildMembershipService {
reason: data.reason ?? undefined,
banDurationSeconds: data.ban_duration_seconds ?? undefined,
skipGuildAuditLog: true,
by: 'staff',
},
auditLogReason,
);
@@ -0,0 +1,170 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {createTestAccount, setUserACLs, type TestAccount} from '@app/api/auth/tests/AuthTestUtils';
import {createChannel, createGuild} from '@app/api/channel/tests/ChannelTestUtils';
import {ensureSessionStarted} from '@app/api/message/tests/MessageTestUtils';
import {getAdminRepository} from '@app/api/middleware/ServiceSingletons';
import {type ApiTestHarness, createApiTestHarness} from '@app/api/test/ApiTestHarness';
import {createBuilder} from '@app/api/test/TestRequestBuilder';
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
import {afterAll, beforeAll, beforeEach, describe, expect, it} from 'vitest';
interface ValidationErrorResponse {
code: string;
errors?: Array<{path: string; message: string}>;
}
interface EntryPage {
items: Array<{value: string; match_subdomains: boolean | null}>;
}
describe('Admin url-domain blocklist patterns', () => {
let harness: ApiTestHarness;
let admin: TestAccount;
beforeAll(async () => {
harness = await createApiTestHarness();
});
beforeEach(async () => {
await harness.reset();
admin = await setUserACLs(harness, await createTestAccount(harness), [
'admin:authenticate',
'ban:url_domain:add',
'ban:url_domain:check',
'ban:url_domain:remove',
]);
});
afterAll(async () => {
await harness?.shutdown();
});
async function add(domain: string, matchSubdomains?: boolean): Promise<void> {
await createBuilder(harness, admin.token)
.post('/admin/blocklists/url-domain/entries')
.body(matchSubdomains === undefined ? {domain} : {domain, match_subdomains: matchSubdomains})
.expect(204)
.execute();
}
async function check(value: string): Promise<boolean> {
const json = await createBuilder<{banned: boolean}>(harness, admin.token)
.get(`/admin/blocklists/url-domain/entries/${encodeURIComponent(value)}`)
.expect(200)
.execute();
return json.banned;
}
async function list(): Promise<EntryPage['items']> {
const json = await createBuilder<EntryPage>(harness, admin.token)
.get('/admin/blocklists/url-domain/entries?limit=200')
.expect(200)
.execute();
return json.items;
}
it('stores a canonical pattern and reports the hosts it covers', async () => {
await add('**Shop**.OnRender.com.');
expect(await list()).toMatchObject([{value: '*shop*.onrender.com', match_subdomains: true}]);
expect(await check('shop-2.onrender.com')).toBe(true);
expect(await check('https://www.myshop.onrender.com/checkout')).toBe(true);
expect(await check('onrender.com')).toBe(false);
expect(await check('docs.onrender.com')).toBe(false);
});
it('records whether the entry is a pattern in the audit log', async () => {
await add('*shop*.onrender.com', false);
await add('shop.example.com');
const logs = (await getAdminRepository().listAllAuditLogsPaginated(1000)).filter(
(log) => log.action === 'ban_url_domain',
);
const metadata = logs.map((log) => Object.fromEntries(log.metadata));
expect(metadata).toEqual(
expect.arrayContaining([
{domain: '*shop*.onrender.com', match_subdomains: 'false', pattern: 'true'},
{domain: 'shop.example.com', match_subdomains: 'true', pattern: 'false'},
]),
);
});
it('rejects patterns that are too broad or malformed', async () => {
for (const domain of ['*', '*.com', '*shop*.co.uk', '*.onrender.com', '*ab*.onrender.com', 'shop.*.example.com']) {
const json = await createBuilder<ValidationErrorResponse>(harness, admin.token)
.post('/admin/blocklists/url-domain/entries')
.body({domain})
.expect(400, 'INVALID_FORM_BODY')
.execute();
expect(json.errors?.[0]?.path, domain).toBe('domain');
}
expect(await list()).toEqual([]);
});
it('validates the value on update', async () => {
const json = await createBuilder<ValidationErrorResponse>(harness, admin.token)
.patch(`/admin/blocklists/url-domain/entries/${encodeURIComponent('*.com')}`)
.body({})
.expect(400, 'INVALID_FORM_BODY')
.execute();
expect(json.errors?.[0]?.path).toBe('domain');
});
it('stores internationalized domains in ASCII form', async () => {
await add('Bücher.Example.');
expect((await list()).map((entry) => entry.value)).toEqual(['xn--bcher-kva.example']);
expect(await check('www.bücher.example')).toBe(true);
});
it('accepts an add for a domain that is already blocked', async () => {
await add('shop.example.com');
await add('shop.example.com', false);
expect(await list()).toMatchObject([{value: 'shop.example.com', match_subdomains: false}]);
});
it('removes a pattern through any spelling that canonicalizes to it', async () => {
await add('*shop*.onrender.com');
await createBuilder(harness, admin.token)
.delete(`/admin/blocklists/url-domain/entries/${encodeURIComponent('*SHOP**.onrender.com')}`)
.expect(204)
.execute();
expect(await list()).toEqual([]);
expect(await check('shop.onrender.com')).toBe(false);
});
it('blocks messages whose masked links or autolinks point at a covered host', async () => {
await add('*shop*.onrender.com');
const member = await createTestAccount(harness);
const guild = await createGuild(harness, member.token, 'Links');
const channel = await createChannel(harness, member.token, guild.id, 'general');
await ensureSessionStarted(harness, member.token);
for (const content of [
'[open the store](https://shop-2.onrender.com)',
'<https://[email protected]:8443/x>',
'https://SHOP.onrender.com./',
]) {
await createBuilder(harness, member.token)
.post(`/channels/${channel.id}/messages`)
.body({content})
.expect(403, APIErrorCodes.CONTENT_BLOCKED)
.execute();
}
await createBuilder(harness, member.token)
.post(`/channels/${channel.id}/messages`)
.body({content: '[docs](https://docs.onrender.com) and https://onrender.com'})
.expect(200)
.execute();
});
it('blocks rich embeds that link to a covered host', async () => {
await add('*shop*.onrender.com');
const member = await createTestAccount(harness);
const guild = await createGuild(harness, member.token, 'Embeds');
const channel = await createChannel(harness, member.token, guild.id, 'general');
await ensureSessionStarted(harness, member.token);
await createBuilder(harness, member.token)
.post(`/channels/${channel.id}/messages`)
.body({embeds: [{title: 'Store', url: 'https://shop.onrender.com/'}]})
.expect(403, APIErrorCodes.CONTENT_BLOCKED)
.execute();
});
});
@@ -78,7 +78,6 @@ const adminEndpoints: Array<AdminEndpointCase> = [
{method: 'GET', path: '/admin/users/1/webauthn-credentials', requiredACL: 'user:update:mfa'},
{method: 'DELETE', path: '/admin/users/1/webauthn-credentials/credential', requiredACL: 'user:update:mfa'},
{method: 'DELETE', path: '/admin/users/1/profile-fields', requiredACL: 'user:update:profile'},
{method: 'PUT', path: '/admin/users/1/bot-status', requiredACL: 'user:update:bot_status'},
{method: 'PUT', path: '/admin/users/1/acls', requiredACL: 'acl:set:user'},
{method: 'PUT', path: '/admin/users/1/deletion', requiredACL: 'user:delete'},
{method: 'POST', path: '/admin/users/1/avatar-block', requiredACL: 'ban:avatar_hash:add'},
@@ -0,0 +1,142 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {AdminRepository} from '@app/api/admin/AdminRepository';
import type {AdminAuditLog} from '@app/api/admin/IAdminRepository';
import {createTestAccount, setUserACLs, type TestAccount} from '@app/api/auth/tests/AuthTestUtils';
import {ipBanCache} from '@app/api/middleware/IpBanMiddleware';
import {getAdminRepository} from '@app/api/middleware/ServiceSingletons';
import {type ApiTestHarness, createApiTestHarness} from '@app/api/test/ApiTestHarness';
import {HTTP_STATUS} from '@app/api/test/TestConstants';
import {createBuilder} from '@app/api/test/TestRequestBuilder';
import {AdminACLs} from '@fluxer/constants/src/AdminACLs';
import {afterAll, beforeAll, beforeEach, describe, expect, it} from 'vitest';
interface BlocklistEntryPage {
items: Array<{value: string; reason: string | null; expires_at: string | null; created_at: string | null}>;
}
interface BlocklistCheck {
banned: boolean;
expires_at: string | null;
}
const HOUR_MS = 3_600_000;
describe('Admin IP bans with an expiry', () => {
let harness: ApiTestHarness;
let admin: TestAccount;
beforeAll(async () => {
harness = await createApiTestHarness();
});
beforeEach(async () => {
await harness.reset();
admin = await setUserACLs(harness, await createTestAccount(harness), [
AdminACLs.AUTHENTICATE,
AdminACLs.BAN_IP_ADD,
AdminACLs.BAN_IP_CHECK,
AdminACLs.BAN_IP_REMOVE,
]);
});
afterAll(async () => {
await harness.shutdown();
});
async function addIpBan(body: Record<string, unknown>, status: number = HTTP_STATUS.NO_CONTENT): Promise<void> {
await createBuilder(harness, admin.token).post('/admin/blocklists/ip/entries').body(body).expect(status).execute();
}
async function listIpBans(): Promise<BlocklistEntryPage['items']> {
const page = await createBuilder<BlocklistEntryPage>(harness, admin.token)
.get('/admin/blocklists/ip/entries')
.expect(HTTP_STATUS.OK)
.execute();
return page.items;
}
async function checkIpBan(ip: string): Promise<BlocklistCheck> {
return createBuilder<BlocklistCheck>(harness, admin.token)
.get(`/admin/blocklists/ip/entries/${encodeURIComponent(ip)}`)
.expect(HTTP_STATUS.OK)
.execute();
}
async function banIpAuditLogs(): Promise<Array<AdminAuditLog>> {
const logs = await getAdminRepository().listAllAuditLogsPaginated(1000);
return logs.filter((log) => log.action === 'ban_ip');
}
it('stores an expiring ban that the listing and the check both report', async () => {
const before = Date.now();
await addIpBan({ip: '198.51.100.7', duration_hours: 24});
const [entry] = await listIpBans();
expect(entry?.value).toBe('198.51.100.7');
expect(entry?.reason).toBe('platform_admin_enforcement');
const expiresAt = Date.parse(entry?.expires_at ?? '');
expect(expiresAt).toBeGreaterThanOrEqual(before + 24 * HOUR_MS);
expect(expiresAt).toBeLessThanOrEqual(Date.now() + 24 * HOUR_MS);
const check = await checkIpBan('198.51.100.7');
expect(check.banned).toBe(true);
expect(Date.parse(check.expires_at ?? '')).toBe(expiresAt);
});
it('records the duration and expiry in the audit log', async () => {
await addIpBan({ip: '198.51.100.8', duration_hours: 168});
const [log] = await banIpAuditLogs();
const metadata = Object.fromEntries(log!.metadata);
expect(metadata['ip']).toBe('198.51.100.8');
expect(metadata['duration_hours']).toBe('168');
expect(Date.parse(metadata['expires_at'] ?? '')).toBeGreaterThan(Date.now() + 167 * HOUR_MS);
});
it('keeps a ban permanent when no duration is given', async () => {
await addIpBan({ip: '198.51.100.9'});
await addIpBan({ip: '198.51.100.10', duration_hours: 0});
const entries = await listIpBans();
expect(entries.map((entry) => entry.expires_at)).toEqual([null, null]);
expect(await checkIpBan('198.51.100.9')).toEqual({banned: true, expires_at: null});
const [log] = await banIpAuditLogs();
expect(log!.metadata.has('duration_hours')).toBe(false);
});
it('replaces a permanent ban with an expiring one when the address is banned again', async () => {
await addIpBan({ip: '198.51.100.11'});
await addIpBan({ip: '198.51.100.11', duration_hours: 24});
const [entry] = await listIpBans();
expect(entry?.expires_at).not.toBeNull();
const check = await checkIpBan('198.51.100.11');
expect(check.banned).toBe(true);
expect(check.expires_at).not.toBeNull();
});
it('rejects a duration beyond one year', async () => {
await addIpBan({ip: '198.51.100.12', duration_hours: 8761}, HTTP_STATUS.BAD_REQUEST);
await addIpBan({ip: '198.51.100.12', duration_hours: 1.5}, HTTP_STATUS.BAD_REQUEST);
expect(await listIpBans()).toEqual([]);
});
it('reports a check with no match as not banned with no expiry', async () => {
expect(await checkIpBan('198.51.100.13')).toEqual({banned: false, expires_at: null});
});
it('stops applying an expiring ban once its expiry has passed', async () => {
await new AdminRepository().banIp('198.51.100.14', 1);
await ipBanCache.refresh();
expect(ipBanCache.isBanned('198.51.100.14')).toBe(true);
await new Promise((resolve) => setTimeout(resolve, 1100));
expect(ipBanCache.isBanned('198.51.100.14')).toBe(false);
await ipBanCache.refresh();
expect(ipBanCache.isBanned('198.51.100.14')).toBe(false);
expect(await listIpBans()).toEqual([]);
});
});
@@ -21,6 +21,8 @@ interface AdminUserLookupResponse {
}>;
}
const RETIRED_ACL = 'retired:acl';
describe('Admin set user ACLs validation', () => {
let harness: ApiTestHarness;
beforeAll(async () => {
@@ -69,4 +71,43 @@ describe('Admin set user ACLs validation', () => {
.execute();
expect(lookup.users[0]!.acls).toEqual([AdminACLs.USER_LOOKUP]);
});
test('lists only registry values when a retired ACL is stored', async () => {
const admin = await setUserACLs(harness, await createTestAccount(harness), [
AdminACLs.AUTHENTICATE,
AdminACLs.USER_LOOKUP,
]);
const target = await setUserACLs(harness, await createTestAccount(harness), [AdminACLs.USER_LOOKUP, RETIRED_ACL]);
const lookup = await createBuilder<AdminUserLookupResponse>(harness, `${admin.token}`)
.get(`/admin/users/${target.userId}`)
.expect(HTTP_STATUS.OK)
.execute();
expect(lookup.users[0]!.acls).toEqual([AdminACLs.USER_LOOKUP]);
});
test('saves ACLs for an account that holds a retired ACL', async () => {
const admin = await setUserACLs(harness, await createTestAccount(harness), [
AdminACLs.AUTHENTICATE,
AdminACLs.ACL_SET_USER,
AdminACLs.USER_LOOKUP,
AdminACLs.USER_VIEW_EMAIL,
]);
const target = await setUserACLs(harness, await createTestAccount(harness), [AdminACLs.USER_LOOKUP, RETIRED_ACL]);
const result = await createBuilder<AdminUserMutationResponse>(harness, `${admin.token}`)
.put(`/admin/users/${target.userId}/acls`)
.body({acls: [AdminACLs.USER_LOOKUP, AdminACLs.USER_VIEW_EMAIL]})
.expect(HTTP_STATUS.OK)
.execute();
expect(result.user.acls.sort()).toEqual([AdminACLs.USER_LOOKUP, AdminACLs.USER_VIEW_EMAIL].sort());
});
test('returns the current admin when every registry ACL and a retired ACL are stored', async () => {
const admin = await setUserACLs(harness, await createTestAccount(harness), [
...Object.values(AdminACLs),
RETIRED_ACL,
]);
const me = await createBuilder<AdminUserMutationResponse>(harness, `${admin.token}`)
.get('/admin/users/@me')
.expect(HTTP_STATUS.OK)
.execute();
expect(me.user.acls).toHaveLength(Object.values(AdminACLs).length);
expect(me.user.acls).not.toContain(RETIRED_ACL);
});
});
@@ -20,8 +20,6 @@ const MUTATIONS: Array<{verb: 'put' | 'patch' | 'delete'; path: string; acl: str
{verb: 'put', path: 'ban', acl: AdminACLs.USER_TEMP_BAN, body: {duration_hours: 1, reason: 'test'}},
{verb: 'put', path: 'deletion', acl: AdminACLs.USER_DELETE, body: {delay_days: 1}},
{verb: 'delete', path: 'profile-fields', acl: AdminACLs.USER_UPDATE_PROFILE, body: {fields: ['bio']}},
{verb: 'put', path: 'bot-status', acl: AdminACLs.USER_UPDATE_BOT_STATUS, body: {bot: true}},
{verb: 'put', path: 'system-status', acl: AdminACLs.USER_UPDATE_BOT_STATUS, body: {system: true}},
];
const CASES = SYNTHETIC_USER_IDS.flatMap((userId) =>
@@ -159,7 +159,7 @@ describe('VoiceAdminController', () => {
expect(deletedRegion.success).toBe(true);
expect(await voiceRepository.getRegion(fixture.regionId)).toBeNull();
});
test('rejects voice server creation when no region carries the identifier', async () => {
test('rejects voice server creation when no region has the identifier', async () => {
const admin = await createAdminWithAcls(harness, [AdminACLs.VOICE_SERVER_CREATE]);
const regionId = 'voice-region-missing-for-server-create';
const serverId = 'voice-server-missing-region';
@@ -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)));
@@ -185,39 +191,6 @@ export const UserWriteAdminAuditCases: ReadonlyArray<AdminAuditCoverageCase> = [
};
},
},
{
method: 'PUT',
route: '/admin/users/:user_id/bot-status',
async prepare({harness}) {
const target = await createTestAccount(harness);
return {
request: {path: `/admin/users/${target.userId}/bot-status`, body: {bot: true}},
expected: {
action: 'set_bot_status',
targetType: 'user',
targetId: target.userId,
metadata: {bot: 'true'},
},
};
},
},
{
method: 'PUT',
route: '/admin/users/:user_id/system-status',
async prepare(context) {
const target = await createTestAccount(context.harness);
await adminBuilder(context).put(`/admin/users/${target.userId}/bot-status`).body({bot: true}).execute();
return {
request: {path: `/admin/users/${target.userId}/system-status`, body: {system: true}},
expected: {
action: 'set_system_status',
targetType: 'user',
targetId: target.userId,
metadata: {system: 'true'},
},
};
},
},
{
method: 'PATCH',
route: '/admin/users/:user_id/username',
@@ -304,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',
Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.4 KiB

+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),
+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.deletePasswordResetToken(data.token);
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;
}

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