Compare commits

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

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