Compare commits

...
Author SHA1 Message Date
HampusandGitHub 997d98c65c fix(app): fade messages behind the composer status row (#3020) 2026-09-28 18:47:42 +02:00
HampusandGitHub c9ae5b6ee8 fix(app): smooth the fluxer.com migration and expired re-login (#3019) 2026-09-28 18:11:18 +02:00
HampusandGitHub a728be4062 fix(app): respect time format setting in profile local time (#3018) 2026-09-28 17:48:55 +02:00
HampusandGitHub fce81367fb fix(app): stop message text showing through the slowmode hint (#3017) 2026-09-28 17:29:15 +02:00
HampusandGitHub 5a4edc0b59 fix(api): make read state clear endpoint a no-op (#3015) 2026-09-28 16:31:30 +02:00
HampusandGitHub 713ae5f7f5 feat(api): restrict dms to friends by default for new users (#3013) 2026-09-28 15:02:20 +02:00
HampusandGitHub eaee820216 feat(experiments): target rollouts by guild and premium status (#3012) 2026-09-28 14:34:19 +02:00
HampusandGitHub 564c5ae164 feat(profile): move profile timezone from staff to an experiment (#3011) 2026-09-28 12:57:26 +02:00
HampusandGitHub dd8ed6f205 fix(app): react at once when picking a +: autocomplete emoji (#3010) 2026-09-28 12:30:45 +02:00
HampusandGitHub ed8c412415 perf(gateway): make channel moves cheap in large guilds (#3008) 2026-09-28 02:07:47 +02:00
HampusandGitHub 12417a6942 fix(app): keep the caret after inserted emoji (#3007) 2026-09-28 01:56:08 +02:00
HampusandGitHub d05f6c9aaa fix(gateway): push held users whose sessions end during grace (#3006) 2026-09-28 01:47:55 +02:00
HampusandGitHub 0ca035c547 fix(messages): accept null version on legacy message rows (#3004) 2026-09-28 01:10:52 +02:00
HampusandGitHub dfd46ccc2c ci(gateway): drop cached gateway build output before compiling (#3003) 2026-09-28 01:08:52 +02:00
HampusandGitHub f6df3169ca fix(app): use +:shortcode: for reactions, no space before emoji (#3001) 2026-09-28 00:48:23 +02:00
HampusandGitHub 5b280898c5 refactor(push): retire the push service delivery experiment (#3000) 2026-09-28 00:45:22 +02:00
HampusandGitHub 2a9e25c788 fix(dev): drop the stray -- from the tunnel public URL hint (#2999) 2026-09-28 00:43:32 +02:00
HampusandGitHub 463c03fb6d feat(app): make +emoji react on send and target replies (#2998) 2026-09-28 00:08:40 +02:00
HampusandGitHub 153dad11e1 feat(installer): let upgrades copy the uploads uncompressed (#2995) 2026-09-27 23:51:24 +02:00
HampusandGitHub e2d05a44a8 fix(push): stop retrying relay rate limit refusals (#2993) 2026-09-27 23:29:27 +02:00
HampusandGitHub 30ba55bd4d fix(gateway): parse push relay hosts as binaries (#2989) 2026-09-27 21:22:45 +02:00
HampusandGitHub 9def9fbef6 feat(api): accept CIDR ranges in FLUXER_API_IP_BAN_EXEMPT_IPS (#2988) 2026-09-27 21:19:35 +02:00
HampusandGitHub fa3fd0027c fix(i18n): translate the push relay notice strings (#2987) 2026-09-27 21:15:33 +02:00
HampusandGitHub 7e1b934637 feat(captcha): add ALTCHA proof-of-work captcha experiment (#2986) 2026-09-27 21:02:55 +02:00
HampusandGitHub 33a118d12a docs(readme): list the Google Play beta first for Android (#2985) 2026-09-27 20:49:39 +02:00
HampusandGitHub 01f53a168d feat(push): gate relay delivery on operator consent (#2984) 2026-09-27 20:33:10 +02:00
HampusandGitHub 336b8b7dcd fix(forward): make an @silent comment silence the forward too (#2983) 2026-09-27 20:13:14 +02:00
HampusandGitHub 48d0034239 fix(app-proxy): trust the Play app signing certificate (#2982) 2026-09-27 19:37:40 +02:00
HampusandGitHub 677ef8491e fix(desktop): back off failed app loads and offer a retry (#2980) 2026-09-27 16:18:01 +02:00
HampusandGitHub 6a6119ed1e fix(push): preview forwarded message content (#2979) 2026-09-27 13:33:22 +02:00
HampusandGitHub 931327d1dc fix(push): stop sending notifications for system messages (#2978) 2026-09-27 13:33:18 +02:00
HampusandGitHub 858a2d9e2b fix(oauth): stop granting scopes the user turned off (#2968) 2026-09-26 13:48:23 +02:00
273 changed files with 21204 additions and 19834 deletions
+3
View File
@@ -336,6 +336,9 @@ jobs:
restore-keys: |
rebar3-${{ runner.os }}-otp28-rebar3.27.0-
- name: Drop restored gateway build output
run: rm -rf fluxer_gateway/_build/default/lib/fluxer_gateway fluxer_gateway/_build/test/lib/fluxer_gateway
- name: Check formatting
run: |
"$FLUXER_CI_BIN" ci --step gateway_fmt
+4 -3
View File
@@ -33,9 +33,9 @@ Fluxer is a free and open source instant messaging and VoIP chat app built for f
| Windows | macOS | Linux | Android | iOS |
| --- | --- | --- | --- | --- |
| [Installer (x64)][win-setup-x64] | [Disk image][mac-dmg] | [Flathub][flathub] | [APK][android-apk] | [TestFlight][ios-testflight] |
| [Installer (ARM64)][win-setup-arm64] | | [deb (x64)][linux-deb-x64] | [Obtainium][obtainium] | |
| [Portable (x64)][win-portable-x64] | | [deb (ARM64)][linux-deb-arm64] | | |
| [Installer (x64)][win-setup-x64] | [Disk image][mac-dmg] | [Flathub][flathub] | [Google Play (beta)][android-play] | [TestFlight][ios-testflight] |
| [Installer (ARM64)][win-setup-arm64] | | [deb (x64)][linux-deb-x64] | [APK (beta)][android-apk] | |
| [Portable (x64)][win-portable-x64] | | [deb (ARM64)][linux-deb-arm64] | [Obtainium (beta)][obtainium] | |
| [Portable (ARM64)][win-portable-arm64] | | [rpm (x64)][linux-rpm-x64] | | |
| | | [rpm (ARM64)][linux-rpm-arm64] | | |
| | | [AppImage (x64)][linux-appimage-x64] | | |
@@ -168,6 +168,7 @@ endorsement rights.
[flatpak-ref]: https://pkgs.fluxer.com/flatpak/fluxer.flatpakref
[flatpak-canary-ref]: https://pkgs.fluxer.com/flatpak/fluxer-canary.flatpakref
[flathub]: https://flathub.org/apps/app.fluxer.Fluxer
[android-play]: https://play.google.com/store/apps/details?id=com.fluxer
[android-apk]: https://github.com/fluxerapp/flutter_client/releases
[obtainium]: https://obtainium.imranr.dev/
[ios-testflight]: https://testflight.apple.com/join/PKZR6pK9
+202 -53
View File
@@ -10524,8 +10524,10 @@
},
"gateway_rollout": {"$ref": "#/components/schemas/GatewayRolloutConfigResponse"},
"voice_noise_suppression": {"$ref": "#/components/schemas/VoiceNoiseSuppressionConfigResponse"},
"push_service_delivery": {"$ref": "#/components/schemas/PushServiceDeliveryConfigResponse"},
"push_relay": {"$ref": "#/components/schemas/PushRelayConfigResponse"},
"domain_migration": {"$ref": "#/components/schemas/DomainMigrationConfigResponse"},
"altcha_captcha": {"$ref": "#/components/schemas/AltchaCaptchaConfigResponse"},
"profile_timezone": {"$ref": "#/components/schemas/ProfileTimezoneConfigResponse"},
"experiment_delivery": {"$ref": "#/components/schemas/ExperimentDeliveryConfigResponse"},
"registration": {
"type": "object",
@@ -10953,8 +10955,10 @@
"sso",
"gateway_rollout",
"voice_noise_suppression",
"push_service_delivery",
"push_relay",
"domain_migration",
"altcha_captcha",
"profile_timezone",
"experiment_delivery",
"registration",
"self_hosted",
@@ -11089,14 +11093,19 @@
"nullable": true,
"allOf": [{"$ref": "#/components/schemas/VoiceNoiseSuppressionConfigUpdateRequest"}]
},
"push_service_delivery": {
"nullable": true,
"allOf": [{"$ref": "#/components/schemas/PushServiceDeliveryConfigUpdateRequest"}]
},
"push_relay": {"nullable": true, "allOf": [{"$ref": "#/components/schemas/PushRelayConfigUpdateRequest"}]},
"domain_migration": {
"nullable": true,
"allOf": [{"$ref": "#/components/schemas/DomainMigrationConfigUpdateRequest"}]
},
"altcha_captcha": {
"nullable": true,
"allOf": [{"$ref": "#/components/schemas/AltchaCaptchaConfigUpdateRequest"}]
},
"profile_timezone": {
"nullable": true,
"allOf": [{"$ref": "#/components/schemas/ProfileTimezoneConfigUpdateRequest"}]
},
"experiment_delivery": {
"nullable": true,
"allOf": [{"$ref": "#/components/schemas/ExperimentDeliveryConfigUpdateRequest"}]
@@ -15190,6 +15199,57 @@
"poll_jitter_percent": {"type": "integer", "minimum": 0, "maximum": 50}
}
},
"ProfileTimezoneConfigUpdateRequest": {
"type": "object",
"properties": {
"enabled": {"type": "boolean"},
"rollout_basis_points": {"type": "integer", "minimum": 0, "maximum": 10000},
"rollout_salt": {"type": "string", "minLength": 1, "maxLength": 64, "pattern": "^[\\x20-\\x7e]+$"},
"included_user_ids": {
"maxItems": 1000,
"type": "array",
"items": {"type": "string", "pattern": "^\\d{1,20}$"}
},
"included_guild_ids": {
"maxItems": 1000,
"type": "array",
"items": {"type": "string", "pattern": "^\\d{1,20}$"}
},
"include_premium_users": {"type": "boolean"},
"excluded_user_ids": {
"maxItems": 1000,
"type": "array",
"items": {"type": "string", "pattern": "^\\d{1,20}$"}
}
}
},
"AltchaCaptchaConfigUpdateRequest": {
"type": "object",
"properties": {
"enabled": {"type": "boolean"},
"rollout_basis_points": {"type": "integer", "minimum": 0, "maximum": 10000},
"rollout_salt": {"type": "string", "minLength": 1, "maxLength": 64, "pattern": "^[\\x20-\\x7e]+$"},
"included_user_ids": {
"maxItems": 1000,
"type": "array",
"items": {"type": "string", "pattern": "^\\d{1,20}$"}
},
"included_guild_ids": {
"maxItems": 1000,
"type": "array",
"items": {"type": "string", "pattern": "^\\d{1,20}$"}
},
"include_premium_users": {"type": "boolean"},
"excluded_user_ids": {
"maxItems": 1000,
"type": "array",
"items": {"type": "string", "pattern": "^\\d{1,20}$"}
},
"anonymous_enabled": {"type": "boolean"},
"cost": {"type": "integer", "minimum": 1000, "maximum": 100000},
"max_counter": {"type": "integer", "minimum": 100, "maximum": 1000000}
}
},
"DomainMigrationConfigUpdateRequest": {
"type": "object",
"properties": {
@@ -15201,6 +15261,12 @@
"type": "array",
"items": {"type": "string", "pattern": "^\\d{1,20}$"}
},
"included_guild_ids": {
"maxItems": 1000,
"type": "array",
"items": {"type": "string", "pattern": "^\\d{1,20}$"}
},
"include_premium_users": {"type": "boolean"},
"excluded_user_ids": {
"maxItems": 1000,
"type": "array",
@@ -15210,24 +15276,7 @@
"standalone_forwarding": {"type": "boolean"}
}
},
"PushServiceDeliveryConfigUpdateRequest": {
"type": "object",
"properties": {
"enabled": {"type": "boolean"},
"rollout_basis_points": {"type": "integer", "minimum": 0, "maximum": 10000},
"rollout_salt": {"type": "string", "minLength": 1, "maxLength": 64, "pattern": "^[\\x20-\\x7e]+$"},
"included_user_ids": {
"maxItems": 1000,
"type": "array",
"items": {"type": "string", "pattern": "^\\d{1,20}$"}
},
"excluded_user_ids": {
"maxItems": 1000,
"type": "array",
"items": {"type": "string", "pattern": "^\\d{1,20}$"}
}
}
},
"PushRelayConfigUpdateRequest": {"type": "object", "properties": {"relay_consent_accepted": {"type": "boolean"}}},
"VoiceNoiseSuppressionConfigUpdateRequest": {
"type": "object",
"properties": {
@@ -15246,6 +15295,12 @@
"type": "array",
"items": {"type": "string", "pattern": "^\\d{1,20}$"}
},
"included_guild_ids": {
"maxItems": 1000,
"type": "array",
"items": {"type": "string", "pattern": "^\\d{1,20}$"}
},
"include_premium_users": {"type": "boolean"},
"excluded_user_ids": {
"maxItems": 1000,
"type": "array",
@@ -15293,6 +15348,102 @@
"required": ["poll_interval_seconds", "poll_jitter_percent"],
"additionalProperties": false
},
"ProfileTimezoneConfigResponse": {
"type": "object",
"properties": {
"enabled": {"default": false, "type": "boolean"},
"config_version": {"default": 0, "type": "integer", "minimum": 0, "maximum": 9007199254740991},
"rollout_basis_points": {"default": 0, "type": "integer", "minimum": 0, "maximum": 10000},
"rollout_salt": {
"default": "profile-timezone-v1",
"type": "string",
"minLength": 1,
"maxLength": 64,
"pattern": "^[\\x20-\\x7e]+$"
},
"included_user_ids": {
"default": [],
"maxItems": 1000,
"type": "array",
"items": {"type": "string", "pattern": "^\\d{1,20}$"}
},
"included_guild_ids": {
"default": [],
"maxItems": 1000,
"type": "array",
"items": {"type": "string", "pattern": "^\\d{1,20}$"}
},
"include_premium_users": {"default": false, "type": "boolean"},
"excluded_user_ids": {
"default": [],
"maxItems": 1000,
"type": "array",
"items": {"type": "string", "pattern": "^\\d{1,20}$"}
}
},
"required": [
"enabled",
"config_version",
"rollout_basis_points",
"rollout_salt",
"included_user_ids",
"included_guild_ids",
"include_premium_users",
"excluded_user_ids"
],
"additionalProperties": false
},
"AltchaCaptchaConfigResponse": {
"type": "object",
"properties": {
"enabled": {"default": false, "type": "boolean"},
"config_version": {"default": 0, "type": "integer", "minimum": 0, "maximum": 9007199254740991},
"rollout_basis_points": {"default": 0, "type": "integer", "minimum": 0, "maximum": 10000},
"rollout_salt": {
"default": "altcha-captcha-v1",
"type": "string",
"minLength": 1,
"maxLength": 64,
"pattern": "^[\\x20-\\x7e]+$"
},
"included_user_ids": {
"default": [],
"maxItems": 1000,
"type": "array",
"items": {"type": "string", "pattern": "^\\d{1,20}$"}
},
"included_guild_ids": {
"default": [],
"maxItems": 1000,
"type": "array",
"items": {"type": "string", "pattern": "^\\d{1,20}$"}
},
"include_premium_users": {"default": false, "type": "boolean"},
"excluded_user_ids": {
"default": [],
"maxItems": 1000,
"type": "array",
"items": {"type": "string", "pattern": "^\\d{1,20}$"}
},
"anonymous_enabled": {"default": false, "type": "boolean"},
"cost": {"default": 5000, "type": "integer", "minimum": 1000, "maximum": 100000},
"max_counter": {"default": 10000, "type": "integer", "minimum": 100, "maximum": 1000000}
},
"required": [
"enabled",
"config_version",
"rollout_basis_points",
"rollout_salt",
"included_user_ids",
"included_guild_ids",
"include_premium_users",
"excluded_user_ids",
"anonymous_enabled",
"cost",
"max_counter"
],
"additionalProperties": false
},
"DomainMigrationConfigResponse": {
"type": "object",
"properties": {
@@ -15312,6 +15463,13 @@
"type": "array",
"items": {"type": "string", "pattern": "^\\d{1,20}$"}
},
"included_guild_ids": {
"default": [],
"maxItems": 1000,
"type": "array",
"items": {"type": "string", "pattern": "^\\d{1,20}$"}
},
"include_premium_users": {"default": false, "type": "boolean"},
"excluded_user_ids": {
"default": [],
"maxItems": 1000,
@@ -15327,46 +15485,28 @@
"rollout_basis_points",
"rollout_salt",
"included_user_ids",
"included_guild_ids",
"include_premium_users",
"excluded_user_ids",
"anonymous_rollout_basis_points",
"standalone_forwarding"
],
"additionalProperties": false
},
"PushServiceDeliveryConfigResponse": {
"PushRelayConfigResponse": {
"type": "object",
"properties": {
"enabled": {"default": false, "type": "boolean"},
"config_version": {"default": 0, "type": "integer", "minimum": 0, "maximum": 9007199254740991},
"rollout_basis_points": {"default": 0, "type": "integer", "minimum": 0, "maximum": 10000},
"rollout_salt": {
"default": "push-service-delivery-v1",
"relay_consent_accepted": {"default": false, "type": "boolean"},
"relay_consent_accepted_at": {
"default": null,
"nullable": true,
"type": "string",
"minLength": 1,
"maxLength": 64,
"pattern": "^[\\x20-\\x7e]+$"
"format": "date-time",
"pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z))$"
},
"included_user_ids": {
"default": [],
"maxItems": 1000,
"type": "array",
"items": {"type": "string", "pattern": "^\\d{1,20}$"}
},
"excluded_user_ids": {
"default": [],
"maxItems": 1000,
"type": "array",
"items": {"type": "string", "pattern": "^\\d{1,20}$"}
}
"relay_consent_accepted_by": {"default": null, "nullable": true, "type": "string", "pattern": "^\\d{1,20}$"}
},
"required": [
"enabled",
"config_version",
"rollout_basis_points",
"rollout_salt",
"included_user_ids",
"excluded_user_ids"
],
"required": ["relay_consent_accepted", "relay_consent_accepted_at", "relay_consent_accepted_by"],
"additionalProperties": false
},
"VoiceNoiseSuppressionConfigResponse": {
@@ -15393,6 +15533,13 @@
"type": "array",
"items": {"type": "string", "pattern": "^\\d{1,20}$"}
},
"included_guild_ids": {
"default": [],
"maxItems": 1000,
"type": "array",
"items": {"type": "string", "pattern": "^\\d{1,20}$"}
},
"include_premium_users": {"default": false, "type": "boolean"},
"excluded_user_ids": {
"default": [],
"maxItems": 1000,
@@ -15424,6 +15571,8 @@
"rollout_basis_points",
"rollout_salt",
"included_user_ids",
"included_guild_ids",
"include_premium_users",
"excluded_user_ids",
"guild_overrides",
"suppression_strength"
+167 -34
View File
@@ -23,10 +23,14 @@ pub struct InstanceConfigResponse {
#[serde(default)]
pub voice_noise_suppression: VoiceNoiseSuppressionConfigResponse,
#[serde(default)]
pub push_service_delivery: PushServiceDeliveryConfigResponse,
pub push_relay: PushRelayConfigResponse,
#[serde(default)]
pub domain_migration: DomainMigrationConfigResponse,
#[serde(default)]
pub altcha_captcha: AltchaCaptchaConfigResponse,
#[serde(default)]
pub profile_timezone: ProfileTimezoneConfigResponse,
#[serde(default)]
pub experiment_delivery: ExperimentDeliveryConfigResponse,
}
@@ -451,8 +455,11 @@ impl VoiceE2eeScope {
}
pub const EXPERIMENT_MAX_TARGETED_USERS: usize = 1_000;
pub const PUSH_SERVICE_DELIVERY_DEFAULT_SALT: &str = "push-service-delivery-v1";
pub const DOMAIN_MIGRATION_DEFAULT_SALT: &str = "domain-migration-v1";
pub const ALTCHA_CAPTCHA_DEFAULT_SALT: &str = "altcha-captcha-v1";
pub const ALTCHA_CAPTCHA_COST_RANGE: std::ops::RangeInclusive<u32> = 1_000..=100_000;
pub const ALTCHA_CAPTCHA_MAX_COUNTER_RANGE: std::ops::RangeInclusive<u32> = 100..=1_000_000;
pub const PROFILE_TIMEZONE_DEFAULT_SALT: &str = "profile-timezone-v1";
pub const VOICE_NS_MAX_GUILD_OVERRIDES: usize = 200;
impl NoiseSuppressionBackend {
@@ -496,6 +503,8 @@ pub struct VoiceNoiseSuppressionConfigResponse {
pub rollout_basis_points: u32,
pub rollout_salt: String,
pub included_user_ids: Vec<String>,
pub included_guild_ids: Vec<String>,
pub include_premium_users: bool,
pub excluded_user_ids: Vec<String>,
pub guild_overrides: Vec<VoiceNoiseSuppressionGuildOverride>,
pub suppression_strength: u32,
@@ -512,6 +521,8 @@ impl Default for VoiceNoiseSuppressionConfigResponse {
rollout_basis_points: 0,
rollout_salt: "voice-ns-v1".to_owned(),
included_user_ids: Vec::new(),
included_guild_ids: Vec::new(),
include_premium_users: false,
excluded_user_ids: Vec::new(),
guild_overrides: Vec::new(),
suppression_strength: 80,
@@ -536,6 +547,10 @@ pub struct VoiceNoiseSuppressionConfigUpdateRequest {
#[serde(skip_serializing_if = "Option::is_none")]
pub included_user_ids: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub included_guild_ids: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub include_premium_users: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub excluded_user_ids: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub guild_overrides: Option<Vec<VoiceNoiseSuppressionGuildOverride>>,
@@ -543,42 +558,18 @@ pub struct VoiceNoiseSuppressionConfigUpdateRequest {
pub suppression_strength: Option<u32>,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
#[serde(default)]
pub struct PushServiceDeliveryConfigResponse {
pub enabled: bool,
pub config_version: u64,
pub rollout_basis_points: u32,
pub rollout_salt: String,
pub included_user_ids: Vec<String>,
pub excluded_user_ids: Vec<String>,
}
impl Default for PushServiceDeliveryConfigResponse {
fn default() -> Self {
Self {
enabled: false,
config_version: 0,
rollout_basis_points: 0,
rollout_salt: PUSH_SERVICE_DELIVERY_DEFAULT_SALT.to_owned(),
included_user_ids: Vec::new(),
excluded_user_ids: Vec::new(),
}
}
pub struct PushRelayConfigResponse {
pub relay_consent_accepted: bool,
pub relay_consent_accepted_at: Option<String>,
pub relay_consent_accepted_by: Option<String>,
}
#[derive(Clone, Debug, Default, Serialize)]
pub struct PushServiceDeliveryConfigUpdateRequest {
pub struct PushRelayConfigUpdateRequest {
#[serde(skip_serializing_if = "Option::is_none")]
pub enabled: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub rollout_basis_points: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub rollout_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>>,
pub relay_consent_accepted: Option<bool>,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
@@ -589,6 +580,8 @@ pub struct DomainMigrationConfigResponse {
pub rollout_basis_points: u32,
pub rollout_salt: String,
pub included_user_ids: Vec<String>,
pub included_guild_ids: Vec<String>,
pub include_premium_users: bool,
pub excluded_user_ids: Vec<String>,
pub anonymous_rollout_basis_points: u32,
pub standalone_forwarding: bool,
@@ -602,6 +595,8 @@ impl Default for DomainMigrationConfigResponse {
rollout_basis_points: 0,
rollout_salt: DOMAIN_MIGRATION_DEFAULT_SALT.to_owned(),
included_user_ids: Vec::new(),
included_guild_ids: Vec::new(),
include_premium_users: false,
excluded_user_ids: Vec::new(),
anonymous_rollout_basis_points: 0,
standalone_forwarding: false,
@@ -620,6 +615,10 @@ pub struct DomainMigrationConfigUpdateRequest {
#[serde(skip_serializing_if = "Option::is_none")]
pub included_user_ids: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub included_guild_ids: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub include_premium_users: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub excluded_user_ids: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub anonymous_rollout_basis_points: Option<u32>,
@@ -627,6 +626,110 @@ pub struct DomainMigrationConfigUpdateRequest {
pub standalone_forwarding: Option<bool>,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(default)]
pub struct AltchaCaptchaConfigResponse {
pub enabled: bool,
pub config_version: u64,
pub rollout_basis_points: u32,
pub rollout_salt: String,
pub included_user_ids: Vec<String>,
pub included_guild_ids: Vec<String>,
pub include_premium_users: bool,
pub excluded_user_ids: Vec<String>,
pub anonymous_enabled: bool,
pub cost: u32,
pub max_counter: u32,
}
impl Default for AltchaCaptchaConfigResponse {
fn default() -> Self {
Self {
enabled: false,
config_version: 0,
rollout_basis_points: 0,
rollout_salt: ALTCHA_CAPTCHA_DEFAULT_SALT.to_owned(),
included_user_ids: Vec::new(),
included_guild_ids: Vec::new(),
include_premium_users: false,
excluded_user_ids: Vec::new(),
anonymous_enabled: false,
cost: 5_000,
max_counter: 10_000,
}
}
}
#[derive(Clone, Debug, Default, Serialize)]
pub struct AltchaCaptchaConfigUpdateRequest {
#[serde(skip_serializing_if = "Option::is_none")]
pub enabled: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub rollout_basis_points: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub rollout_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 included_guild_ids: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub include_premium_users: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub excluded_user_ids: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub anonymous_enabled: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub cost: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub max_counter: Option<u32>,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(default)]
pub struct ProfileTimezoneConfigResponse {
pub enabled: bool,
pub config_version: u64,
pub rollout_basis_points: u32,
pub rollout_salt: String,
pub included_user_ids: Vec<String>,
pub included_guild_ids: Vec<String>,
pub include_premium_users: bool,
pub excluded_user_ids: Vec<String>,
}
impl Default for ProfileTimezoneConfigResponse {
fn default() -> Self {
Self {
enabled: false,
config_version: 0,
rollout_basis_points: 0,
rollout_salt: PROFILE_TIMEZONE_DEFAULT_SALT.to_owned(),
included_user_ids: Vec::new(),
included_guild_ids: Vec::new(),
include_premium_users: false,
excluded_user_ids: Vec::new(),
}
}
}
#[derive(Clone, Debug, Default, Serialize)]
pub struct ProfileTimezoneConfigUpdateRequest {
#[serde(skip_serializing_if = "Option::is_none")]
pub enabled: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub rollout_basis_points: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub rollout_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 included_guild_ids: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub include_premium_users: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub excluded_user_ids: Option<Vec<String>>,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(default)]
pub struct ExperimentDeliveryConfigResponse {
@@ -743,10 +846,14 @@ pub struct InstanceConfigUpdateRequest {
#[serde(skip_serializing_if = "Option::is_none")]
pub voice_noise_suppression: Option<VoiceNoiseSuppressionConfigUpdateRequest>,
#[serde(skip_serializing_if = "Option::is_none")]
pub push_service_delivery: Option<PushServiceDeliveryConfigUpdateRequest>,
pub push_relay: Option<PushRelayConfigUpdateRequest>,
#[serde(skip_serializing_if = "Option::is_none")]
pub domain_migration: Option<DomainMigrationConfigUpdateRequest>,
#[serde(skip_serializing_if = "Option::is_none")]
pub altcha_captcha: Option<AltchaCaptchaConfigUpdateRequest>,
#[serde(skip_serializing_if = "Option::is_none")]
pub profile_timezone: Option<ProfileTimezoneConfigUpdateRequest>,
#[serde(skip_serializing_if = "Option::is_none")]
pub experiment_delivery: Option<ExperimentDeliveryConfigUpdateRequest>,
}
@@ -1086,17 +1193,31 @@ mod tests {
.expect("default noise config");
let domain_migration = serde_json::from_value::<DomainMigrationConfigResponse>(json!({}))
.expect("default domain migration config");
let altcha_captcha = serde_json::from_value::<AltchaCaptchaConfigResponse>(json!({}))
.expect("default altcha captcha config");
let profile_timezone = serde_json::from_value::<ProfileTimezoneConfigResponse>(json!({}))
.expect("default profile timezone config");
let delivery = serde_json::from_value::<ExperimentDeliveryConfigResponse>(json!({}))
.expect("default delivery config");
let noise = serde_json::to_value(noise).expect("serializable noise config");
let domain_migration =
serde_json::to_value(domain_migration).expect("serializable domain migration config");
let altcha_captcha =
serde_json::to_value(altcha_captcha).expect("serializable altcha captcha config");
let profile_timezone =
serde_json::to_value(profile_timezone).expect("serializable profile timezone config");
let delivery = serde_json::to_value(delivery).expect("serializable delivery config");
let generated_noise: generated_types::VoiceNoiseSuppressionConfigResponse =
serde_json::from_value(noise.clone()).expect("generated noise config contract");
let generated_domain_migration: generated_types::DomainMigrationConfigResponse =
serde_json::from_value(domain_migration.clone())
.expect("generated domain migration config contract");
let generated_altcha_captcha: generated_types::AltchaCaptchaConfigResponse =
serde_json::from_value(altcha_captcha.clone())
.expect("generated altcha captcha config contract");
let generated_profile_timezone: generated_types::ProfileTimezoneConfigResponse =
serde_json::from_value(profile_timezone.clone())
.expect("generated profile timezone config contract");
let generated_delivery: generated_types::ExperimentDeliveryConfigResponse =
serde_json::from_value(delivery.clone()).expect("generated delivery config contract");
assert_eq!(
@@ -1108,6 +1229,16 @@ mod tests {
.expect("serializable generated domain migration config"),
domain_migration
);
assert_eq!(
serde_json::to_value(generated_altcha_captcha)
.expect("serializable generated altcha captcha config"),
altcha_captcha
);
assert_eq!(
serde_json::to_value(generated_profile_timezone)
.expect("serializable generated profile timezone config"),
profile_timezone
);
assert_eq!(
serde_json::to_value(generated_delivery)
.expect("serializable generated delivery config"),
@@ -1116,6 +1247,8 @@ mod tests {
for (name, value) in [
("VoiceNoiseSuppressionConfigResponse", noise),
("DomainMigrationConfigResponse", domain_migration),
("AltchaCaptchaConfigResponse", altcha_captcha),
("ProfileTimezoneConfigResponse", profile_timezone),
("ExperimentDeliveryConfigResponse", delivery),
] {
for (field, value) in value.as_object().expect("config object") {
+299 -48
View File
@@ -4,24 +4,25 @@ use crate::{
api::{
client::AdminApiClient,
types::{
AppBrandingConfigUpdateRequest, AppLegalConfigUpdateRequest,
AppPublicConfigUpdateRequest, AppRegistrationConfigUpdateRequest,
AppSetupConfigUpdateRequest, CreateRegistrationUrlRequest,
DeferredPhoneGateUpdateRequest, DomainMigrationConfigUpdateRequest,
EXPERIMENT_MAX_TARGETED_USERS, ExperimentDeliveryConfigUpdateRequest,
GatewayRolloutConfigUpdateRequest, GatewayRolloutMode,
InstanceAttachmentDecayUpdateRequest, InstanceBlueskyIntegrationUpdateRequest,
InstanceBlueskyKeyIntegrationUpdateRequest, InstanceCaptchaIntegrationUpdateRequest,
InstanceConfigUpdateRequest, InstanceEmailIntegrationUpdateRequest,
InstanceEmailSmtpIntegrationUpdateRequest, InstanceEmailSmtpTestRequest,
InstanceGifIntegrationUpdateRequest, InstanceIntegrationsUpdateRequest,
InstanceMediaUpdateRequest, InstancePolicyUpdateRequest,
InstanceRegistrationConfigUpdateRequest, InstanceServicesUpdateRequest,
InstanceYoutubeIntegrationUpdateRequest, LimitConfigUpdateRequest, LimitRule,
LimitRuleFilters, NoiseSuppressionBackend, PremiumMode,
PushServiceDeliveryConfigUpdateRequest, RegistrationMode, SsoConfigUpdateRequest,
VOICE_NS_MAX_GUILD_OVERRIDES, VoiceE2eeScope, VoiceNoiseSuppressionConfigUpdateRequest,
VoiceNoiseSuppressionGuildOverride,
ALTCHA_CAPTCHA_COST_RANGE, ALTCHA_CAPTCHA_MAX_COUNTER_RANGE,
AltchaCaptchaConfigUpdateRequest, AppBrandingConfigUpdateRequest,
AppLegalConfigUpdateRequest, AppPublicConfigUpdateRequest,
AppRegistrationConfigUpdateRequest, AppSetupConfigUpdateRequest,
CreateRegistrationUrlRequest, DeferredPhoneGateUpdateRequest,
DomainMigrationConfigUpdateRequest, EXPERIMENT_MAX_TARGETED_USERS,
ExperimentDeliveryConfigUpdateRequest, GatewayRolloutConfigUpdateRequest,
GatewayRolloutMode, InstanceAttachmentDecayUpdateRequest,
InstanceBlueskyIntegrationUpdateRequest, InstanceBlueskyKeyIntegrationUpdateRequest,
InstanceCaptchaIntegrationUpdateRequest, InstanceConfigUpdateRequest,
InstanceEmailIntegrationUpdateRequest, InstanceEmailSmtpIntegrationUpdateRequest,
InstanceEmailSmtpTestRequest, InstanceGifIntegrationUpdateRequest,
InstanceIntegrationsUpdateRequest, InstanceMediaUpdateRequest,
InstancePolicyUpdateRequest, InstanceRegistrationConfigUpdateRequest,
InstanceServicesUpdateRequest, InstanceYoutubeIntegrationUpdateRequest,
LimitConfigUpdateRequest, LimitRule, LimitRuleFilters, NoiseSuppressionBackend,
PremiumMode, ProfileTimezoneConfigUpdateRequest, PushRelayConfigUpdateRequest,
RegistrationMode, SsoConfigUpdateRequest, VOICE_NS_MAX_GUILD_OVERRIDES, VoiceE2eeScope,
VoiceNoiseSuppressionConfigUpdateRequest, VoiceNoiseSuppressionGuildOverride,
},
},
config::AdminConfig,
@@ -208,11 +209,19 @@ pub async fn instance_config_post(
Ok(update) => instance_config_result(client.update_instance_config(&update).await),
Err(message) => FlashData::error(message),
},
"update_push_service_delivery" => match build_push_service_delivery_update(&form) {
"update_push_relay" => {
let update = build_push_relay_update(&form);
instance_config_result(client.update_instance_config(&update).await)
}
"update_domain_migration" => match build_domain_migration_update(&form) {
Ok(update) => instance_config_result(client.update_instance_config(&update).await),
Err(message) => FlashData::error(message),
},
"update_domain_migration" => match build_domain_migration_update(&form) {
"update_altcha_captcha" => match build_altcha_captcha_update(&form) {
Ok(update) => instance_config_result(client.update_instance_config(&update).await),
Err(message) => FlashData::error(message),
},
"update_profile_timezone" => match build_profile_timezone_update(&form) {
Ok(update) => instance_config_result(client.update_instance_config(&update).await),
Err(message) => FlashData::error(message),
},
@@ -634,6 +643,12 @@ fn build_voice_noise_suppression_update(
form.first("voice_ns_included_user_ids").unwrap_or_default(),
"Included user IDs",
)?),
included_guild_ids: Some(parse_experiment_user_ids(
form.first("voice_ns_included_guild_ids")
.unwrap_or_default(),
"Included guild IDs",
)?),
include_premium_users: Some(form.bool_value("voice_ns_include_premium_users")),
excluded_user_ids: Some(parse_experiment_user_ids(
form.first("voice_ns_excluded_user_ids").unwrap_or_default(),
"Excluded user IDs",
@@ -653,36 +668,13 @@ fn build_voice_noise_suppression_update(
})
}
fn build_push_service_delivery_update(
form: &MultiValueForm,
) -> Result<InstanceConfigUpdateRequest, String> {
Ok(InstanceConfigUpdateRequest {
push_service_delivery: Some(PushServiceDeliveryConfigUpdateRequest {
enabled: Some(form.bool_value("push_service_delivery_enabled")),
rollout_basis_points: parse_form_number(
form,
"push_service_delivery_rollout_basis_points",
"Rollout basis points",
0,
EXPERIMENT_ROLLOUT_BASIS_POINTS_MAX,
)?,
rollout_salt: parse_ascii_experiment_rollout_salt(
form,
"push_service_delivery_rollout_salt",
)?,
included_user_ids: Some(parse_experiment_user_ids(
form.first("push_service_delivery_included_user_ids")
.unwrap_or_default(),
"Included user IDs",
)?),
excluded_user_ids: Some(parse_experiment_user_ids(
form.first("push_service_delivery_excluded_user_ids")
.unwrap_or_default(),
"Excluded user IDs",
)?),
fn build_push_relay_update(form: &MultiValueForm) -> InstanceConfigUpdateRequest {
InstanceConfigUpdateRequest {
push_relay: Some(PushRelayConfigUpdateRequest {
relay_consent_accepted: Some(form.bool_value("push_relay_consent_accepted")),
}),
..Default::default()
})
}
}
fn build_domain_migration_update(
@@ -707,6 +699,12 @@ fn build_domain_migration_update(
.unwrap_or_default(),
"Included user IDs",
)?),
included_guild_ids: Some(parse_experiment_user_ids(
form.first("domain_migration_included_guild_ids")
.unwrap_or_default(),
"Included guild IDs",
)?),
include_premium_users: Some(form.bool_value("domain_migration_include_premium_users")),
excluded_user_ids: Some(parse_experiment_user_ids(
form.first("domain_migration_excluded_user_ids")
.unwrap_or_default(),
@@ -725,6 +723,94 @@ fn build_domain_migration_update(
})
}
fn build_altcha_captcha_update(
form: &MultiValueForm,
) -> Result<InstanceConfigUpdateRequest, String> {
Ok(InstanceConfigUpdateRequest {
altcha_captcha: Some(AltchaCaptchaConfigUpdateRequest {
enabled: Some(form.bool_value("altcha_captcha_enabled")),
rollout_basis_points: parse_form_number(
form,
"altcha_captcha_rollout_basis_points",
"Rollout basis points",
0,
EXPERIMENT_ROLLOUT_BASIS_POINTS_MAX,
)?,
rollout_salt: parse_ascii_experiment_rollout_salt(form, "altcha_captcha_rollout_salt")?,
included_user_ids: Some(parse_experiment_user_ids(
form.first("altcha_captcha_included_user_ids")
.unwrap_or_default(),
"Included user IDs",
)?),
included_guild_ids: Some(parse_experiment_user_ids(
form.first("altcha_captcha_included_guild_ids")
.unwrap_or_default(),
"Included guild IDs",
)?),
include_premium_users: Some(form.bool_value("altcha_captcha_include_premium_users")),
excluded_user_ids: Some(parse_experiment_user_ids(
form.first("altcha_captcha_excluded_user_ids")
.unwrap_or_default(),
"Excluded user IDs",
)?),
anonymous_enabled: Some(form.bool_value("altcha_captcha_anonymous_enabled")),
cost: parse_form_number(
form,
"altcha_captcha_cost",
"Cost",
*ALTCHA_CAPTCHA_COST_RANGE.start(),
*ALTCHA_CAPTCHA_COST_RANGE.end(),
)?,
max_counter: parse_form_number(
form,
"altcha_captcha_max_counter",
"Maximum counter",
*ALTCHA_CAPTCHA_MAX_COUNTER_RANGE.start(),
*ALTCHA_CAPTCHA_MAX_COUNTER_RANGE.end(),
)?,
}),
..Default::default()
})
}
fn build_profile_timezone_update(
form: &MultiValueForm,
) -> Result<InstanceConfigUpdateRequest, String> {
Ok(InstanceConfigUpdateRequest {
profile_timezone: Some(ProfileTimezoneConfigUpdateRequest {
enabled: Some(form.bool_value("profile_timezone_enabled")),
rollout_basis_points: parse_form_number(
form,
"profile_timezone_rollout_basis_points",
"Rollout basis points",
0,
EXPERIMENT_ROLLOUT_BASIS_POINTS_MAX,
)?,
rollout_salt: parse_ascii_experiment_rollout_salt(
form,
"profile_timezone_rollout_salt",
)?,
included_user_ids: Some(parse_experiment_user_ids(
form.first("profile_timezone_included_user_ids")
.unwrap_or_default(),
"Included user IDs",
)?),
included_guild_ids: Some(parse_experiment_user_ids(
form.first("profile_timezone_included_guild_ids")
.unwrap_or_default(),
"Included guild IDs",
)?),
include_premium_users: Some(form.bool_value("profile_timezone_include_premium_users")),
excluded_user_ids: Some(parse_experiment_user_ids(
form.first("profile_timezone_excluded_user_ids")
.unwrap_or_default(),
"Excluded user IDs",
)?),
}),
..Default::default()
})
}
fn build_experiment_delivery_update(
form: &MultiValueForm,
) -> Result<InstanceConfigUpdateRequest, String> {
@@ -1369,6 +1455,8 @@ mod tests {
"allow_user_override": false,
"enabled_backends": [],
"included_user_ids": [],
"included_guild_ids": [],
"include_premium_users": false,
"excluded_user_ids": [],
"guild_overrides": [],
}})
@@ -1685,6 +1773,8 @@ mod tests {
serde_json::json!({"domain_migration": {
"enabled": false,
"included_user_ids": [],
"included_guild_ids": [],
"include_premium_users": false,
"excluded_user_ids": [],
"standalone_forwarding": false,
}})
@@ -1731,6 +1821,167 @@ mod tests {
}
}
#[test]
fn build_push_relay_update_reads_the_consent_checkbox() {
let unchecked = build_push_relay_update(&MultiValueForm::parse(b"_csrf=token"));
assert_eq!(
serde_json::to_value(&unchecked).expect("serialize update"),
serde_json::json!({"push_relay": {"relay_consent_accepted": false}})
);
let checked = build_push_relay_update(&MultiValueForm::parse(
b"_csrf=token&push_relay_consent_accepted=true",
));
assert_eq!(
serde_json::to_value(&checked).expect("serialize update"),
serde_json::json!({"push_relay": {"relay_consent_accepted": true}})
);
}
#[test]
fn build_altcha_captcha_update_reads_the_rollout_and_difficulty_fields() {
let form = MultiValueForm::parse(
b"altcha_captcha_enabled=true&altcha_captcha_rollout_basis_points=%20500%20&altcha_captcha_rollout_salt=%20altcha-captcha-v2%20&altcha_captcha_included_user_ids=1500000000000000001&altcha_captcha_excluded_user_ids=1500000000000000002&altcha_captcha_anonymous_enabled=true&altcha_captcha_cost=2000&altcha_captcha_max_counter=%20400%20",
);
let update = build_altcha_captcha_update(&form)
.expect("valid form")
.altcha_captcha
.expect("altcha captcha update");
assert_eq!(update.enabled, Some(true));
assert_eq!(update.rollout_basis_points, Some(500));
assert_eq!(update.rollout_salt, Some("altcha-captcha-v2".to_owned()));
assert_eq!(
update.included_user_ids,
Some(vec!["1500000000000000001".to_owned()])
);
assert_eq!(
update.excluded_user_ids,
Some(vec!["1500000000000000002".to_owned()])
);
assert_eq!(update.anonymous_enabled, Some(true));
assert_eq!(update.cost, Some(2000));
assert_eq!(update.max_counter, Some(400));
}
#[test]
fn build_altcha_captcha_update_leaves_the_feature_inert_when_nothing_is_submitted() {
let form = MultiValueForm::parse(b"_csrf=token");
let request = build_altcha_captcha_update(&form).expect("valid form");
assert_eq!(
serde_json::to_value(request).expect("serializable update"),
serde_json::json!({"altcha_captcha": {
"enabled": false,
"included_user_ids": [],
"included_guild_ids": [],
"include_premium_users": false,
"excluded_user_ids": [],
"anonymous_enabled": false,
}})
);
}
#[test]
fn build_altcha_captcha_update_rejects_difficulty_outside_the_supported_range() {
for (form, message) in [
(
"altcha_captcha_cost=999",
"Cost must be a whole number between 1000 and 100000",
),
(
"altcha_captcha_max_counter=1000001",
"Maximum counter must be a whole number between 100 and 1000000",
),
(
"altcha_captcha_rollout_basis_points=10001",
"Rollout basis points must be a whole number between 0 and 10000",
),
] {
let form = MultiValueForm::parse(form.as_bytes());
assert_eq!(
build_altcha_captcha_update(&form).expect_err("invalid field"),
message
);
}
}
#[test]
fn build_profile_timezone_update_reads_the_rollout_fields() {
let form = MultiValueForm::parse(
b"profile_timezone_enabled=true&profile_timezone_rollout_basis_points=%20500%20&profile_timezone_rollout_salt=%20profile-timezone-v2%20&profile_timezone_included_user_ids=1500000000000000001&profile_timezone_excluded_user_ids=1500000000000000002&profile_timezone_included_guild_ids=1500000000000000005%0A1500000000000000006%2C1500000000000000005&profile_timezone_include_premium_users=true",
);
let update = build_profile_timezone_update(&form)
.expect("valid form")
.profile_timezone
.expect("profile timezone update");
assert_eq!(update.enabled, Some(true));
assert_eq!(update.rollout_basis_points, Some(500));
assert_eq!(update.rollout_salt, Some("profile-timezone-v2".to_owned()));
assert_eq!(update.include_premium_users, Some(true));
assert_eq!(
update.included_guild_ids,
Some(vec![
"1500000000000000005".to_owned(),
"1500000000000000006".to_owned()
])
);
assert_eq!(
update.included_user_ids,
Some(vec!["1500000000000000001".to_owned()])
);
assert_eq!(
update.excluded_user_ids,
Some(vec!["1500000000000000002".to_owned()])
);
}
#[test]
fn build_profile_timezone_update_leaves_the_feature_inert_when_nothing_is_submitted() {
let form = MultiValueForm::parse(b"_csrf=token");
let request = build_profile_timezone_update(&form).expect("valid form");
assert_eq!(
serde_json::to_value(request).expect("serializable update"),
serde_json::json!({"profile_timezone": {
"enabled": false,
"included_user_ids": [],
"included_guild_ids": [],
"include_premium_users": false,
"excluded_user_ids": [],
}})
);
}
#[test]
fn every_experiment_update_rejects_an_invalid_included_guild_id() {
for (prefix, build) in [
(
"voice_ns",
build_voice_noise_suppression_update
as fn(&MultiValueForm) -> Result<InstanceConfigUpdateRequest, String>,
),
("domain_migration", build_domain_migration_update),
("altcha_captcha", build_altcha_captcha_update),
("profile_timezone", build_profile_timezone_update),
] {
let form = MultiValueForm::parse(
format!("{prefix}_included_guild_ids=1500000000000000005%0Anot-a-guild").as_bytes(),
);
assert_eq!(
build(&form).expect_err("invalid guild id"),
"Included guild IDs entry 2 must contain 1 to 20 decimal digits",
"{prefix}"
);
}
}
#[test]
fn build_profile_timezone_update_rejects_a_rollout_above_everybody() {
let form = MultiValueForm::parse(b"profile_timezone_rollout_basis_points=10001");
assert_eq!(
build_profile_timezone_update(&form).expect_err("invalid field"),
"Rollout basis points must be a whole number between 0 and 10000"
);
}
#[test]
fn build_experiment_delivery_update_leaves_both_fields_unchanged_when_absent() {
let form = MultiValueForm::parse(b"_csrf=token");
@@ -2,13 +2,15 @@
use crate::{
api::types::{
AppPublicConfigResponse, DOMAIN_MIGRATION_DEFAULT_SALT, DomainMigrationConfigResponse,
EXPERIMENT_MAX_TARGETED_USERS, ExperimentDeliveryConfigResponse,
GatewayRolloutConfigResponse, InstanceConfigResponse, InstanceIntegrationsResponse,
InstanceMediaResponse, InstancePolicyResponse, InstanceRegistrationResponse,
LimitConfigResponse, NoiseSuppressionBackend, PUSH_SERVICE_DELIVERY_DEFAULT_SALT,
PendingRegistrationResponse, PushServiceDeliveryConfigResponse, RegistrationUrlResponse,
SsoConfigResponse, VOICE_NS_MAX_GUILD_OVERRIDES, VoiceNoiseSuppressionConfigResponse,
ALTCHA_CAPTCHA_COST_RANGE, ALTCHA_CAPTCHA_DEFAULT_SALT, ALTCHA_CAPTCHA_MAX_COUNTER_RANGE,
AltchaCaptchaConfigResponse, AppPublicConfigResponse, DOMAIN_MIGRATION_DEFAULT_SALT,
DomainMigrationConfigResponse, EXPERIMENT_MAX_TARGETED_USERS,
ExperimentDeliveryConfigResponse, GatewayRolloutConfigResponse, InstanceConfigResponse,
InstanceIntegrationsResponse, InstanceMediaResponse, InstancePolicyResponse,
InstanceRegistrationResponse, LimitConfigResponse, NoiseSuppressionBackend,
PROFILE_TIMEZONE_DEFAULT_SALT, PendingRegistrationResponse, ProfileTimezoneConfigResponse,
PushRelayConfigResponse, RegistrationUrlResponse, SsoConfigResponse,
VOICE_NS_MAX_GUILD_OVERRIDES, VoiceNoiseSuppressionConfigResponse,
},
config::AdminConfig,
middleware::auth::AuthContext,
@@ -136,6 +138,13 @@ pub fn instance_config_page(
(integrations_config_section(base, csrf_token, &instance_config.integrations))
},
))
(config_group(
"Push notifications",
"Consent for the relay that delivers official mobile app notifications.",
html! {
(push_relay_section(base, csrf_token, &instance_config.push_relay))
},
))
(config_group(
"Media & retention",
"Attachment expiry rules that can be changed without editing environment variables.",
@@ -149,8 +158,9 @@ pub fn instance_config_page(
html! {
(gateway_rollout_section(base, csrf_token, &instance_config.gateway_rollout))
(voice_noise_suppression_section(base, csrf_token, &instance_config.voice_noise_suppression))
(push_service_delivery_section(base, csrf_token, &instance_config.push_service_delivery))
(domain_migration_section(base, csrf_token, &instance_config.domain_migration))
(altcha_captcha_section(base, csrf_token, &instance_config.altcha_captcha))
(profile_timezone_section(base, csrf_token, &instance_config.profile_timezone))
(experiment_delivery_section(base, csrf_token, &instance_config.experiment_delivery))
@if let Some(limit_config) = limit_config {
(limit_config_section(base, limit_config))
@@ -1117,6 +1127,38 @@ fn voice_noise_suppression_section(
IDs are ignored."
}
}
div class="flex flex-col gap-2" {
(checkbox(
"voice_ns_include_premium_users",
"true",
"Include premium users",
voice_noise_suppression.include_premium_users,
true,
))
p class="text-xs text-neutral-500" {
"Includes every account with active premium perks, regardless of the \
percentage above. The never-on list still wins."
}
}
div class="flex flex-col gap-2" {
(textarea_input(
"voice_ns_included_guild_ids",
"Always-on Guild IDs",
"1500000000000000005\n1500000000000000006",
&voice_noise_suppression.included_guild_ids.join("\n"),
4,
false,
))
(entry_count_hint(
voice_noise_suppression.included_guild_ids.len(),
EXPERIMENT_MAX_TARGETED_USERS,
))
p class="text-xs text-neutral-500" {
"Same format, with guild IDs. Every member of a listed guild is \
included regardless of the percentage above, unless the user is \
in the never-on list."
}
}
div class="flex flex-col gap-2" {
(textarea_input(
"voice_ns_excluded_user_ids",
@@ -1179,108 +1221,69 @@ fn voice_noise_suppression_section(
)
}
fn push_service_delivery_section(
fn push_relay_section(
base: &str,
csrf_token: &str,
push_service_delivery: &PushServiceDeliveryConfigResponse,
push_relay: &PushRelayConfigResponse,
) -> Markup {
let status = if push_service_delivery.enabled {
("Live", BadgeVariant::Success)
let status = if push_relay.relay_consent_accepted {
("Accepted", BadgeVariant::Success)
} else {
("Inert", BadgeVariant::Default)
("Not accepted", BadgeVariant::Default)
};
let included_user_ids = push_service_delivery.included_user_ids.join("\n");
let excluded_user_ids = push_service_delivery.excluded_user_ids.join("\n");
let accepted_at =
format_optional_admin_timestamp(push_relay.relay_consent_accepted_at.as_deref(), "Never");
let accepted_by = push_relay
.relay_consent_accepted_by
.as_deref()
.unwrap_or("Nobody");
section_card_with_description(
"Push Service Delivery",
"Routes push notification delivery for the selected accounts through the push service. \
Accounts the rollout does not select keep the current path.",
"Push Relay",
"Official mobile app notifications travel through Fluxer's relay to Apple and Google. \
The relay delivers them only after an operator accepts its privacy notice.",
html! {
form method="post" action={(base) "/instance-config?action=update_push_service_delivery"} {
form method="post" action={(base) "/instance-config?action=update_push_relay"} {
(csrf_input(csrf_token))
div class="space-y-6" {
div class="flex flex-wrap items-center gap-2" {
h3 class="text-sm font-semibold text-neutral-900" { "Master switch" }
h3 class="text-sm font-semibold text-neutral-900" { "Relay consent" }
(badge(status.0, status.1))
span class="text-xs text-neutral-500" {
"Config version " (push_service_delivery.config_version)
}
}
(checkbox(
"push_service_delivery_enabled",
"push_relay_consent_accepted",
"true",
"Hand push notifications to the push service",
push_service_delivery.enabled,
"Accept the push relay supplemental privacy notice",
push_relay.relay_consent_accepted,
true,
))
p class="text-xs text-neutral-500" {
"Off is the safe state. With this unchecked every notification keeps the \
current delivery path, so the rollout and targeting fields below have no \
effect at all."
}
h3 class="text-sm font-semibold text-neutral-900" { "Rollout" }
(number_field(
"push_service_delivery_rollout_basis_points",
"Rollout (basis points)",
&push_service_delivery.rollout_basis_points.to_string(),
Some(0), Some(10000), "1",
Some("Share of users bucketed into the canary, in basis points: 0 is nobody, 100 is 1%, 10000 is everybody."),
))
div class="flex flex-col gap-2" {
(text_input(
"push_service_delivery_rollout_salt",
"Rollout Salt",
&push_service_delivery.rollout_salt,
PUSH_SERVICE_DELIVERY_DEFAULT_SALT,
))
p class="text-xs text-neutral-500" {
"Seeds the bucketing hash. Changing it reshuffles which users fall \
inside the percentage above. Leave it alone to keep the current \
cohort stable."
"Until this is accepted official mobile app notifications are dropped. \
Self-hosted UnifiedPush and ntfy endpoints never reach the relay and are \
unaffected. "
a href="https://fluxer.com/push-relay" target="_blank" rel="noreferrer"
class="text-neutral-900 underline decoration-neutral-300 hover:text-neutral-600 hover:decoration-neutral-500" {
"Read the notice"
}
}
div class="flex flex-col gap-2" {
(textarea_input(
"push_service_delivery_included_user_ids",
"Always-on User IDs",
"1500000000000000001\n1500000000000000002",
&included_user_ids,
4,
false,
div class="grid grid-cols-1 gap-4 sm:grid-cols-2" {
(form_field_group("Accepted at", "push_relay_consent_accepted_at", false, None, None,
html! {
input type="text" id="push_relay_consent_accepted_at"
value=(accepted_at)
disabled class=(FORM_INPUT_CLASS);
},
))
(entry_count_hint(
push_service_delivery.included_user_ids.len(),
EXPERIMENT_MAX_TARGETED_USERS,
(form_field_group("Accepted by user ID", "push_relay_consent_accepted_by", false, None, None,
html! {
input type="text" id="push_relay_consent_accepted_by"
value=(accepted_by)
disabled class=(FORM_INPUT_CLASS);
},
))
p class="text-xs text-neutral-500" {
"One snowflake per line, or comma separated. These users 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(
"push_service_delivery_excluded_user_ids",
"Never-on User IDs",
"1500000000000000003\n1500000000000000004",
&excluded_user_ids,
4,
false,
))
(entry_count_hint(
push_service_delivery.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. This is the per-user kill switch."
}
}
(form_actions(html! {
(submit_button("Save Push Service Delivery Configuration"))
(submit_button("Save Push Relay Settings"))
}))
}
}
@@ -1392,6 +1395,38 @@ fn domain_migration_section(
IDs are ignored."
}
}
div class="flex flex-col gap-2" {
(checkbox(
"domain_migration_include_premium_users",
"true",
"Include premium users",
domain_migration.include_premium_users,
true,
))
p class="text-xs text-neutral-500" {
"Includes every account with active premium perks, regardless of the \
percentage above. The never-on list still wins."
}
}
div class="flex flex-col gap-2" {
(textarea_input(
"domain_migration_included_guild_ids",
"Always-on Guild IDs",
"1500000000000000005\n1500000000000000006",
&domain_migration.included_guild_ids.join("\n"),
4,
false,
))
(entry_count_hint(
domain_migration.included_guild_ids.len(),
EXPERIMENT_MAX_TARGETED_USERS,
))
p class="text-xs text-neutral-500" {
"Same format, with guild IDs. Every member of a listed guild is \
included regardless of the percentage above, unless the user is \
in the never-on list."
}
}
div class="flex flex-col gap-2" {
(textarea_input(
"domain_migration_excluded_user_ids",
@@ -1421,6 +1456,314 @@ fn domain_migration_section(
)
}
fn altcha_captcha_section(
base: &str,
csrf_token: &str,
altcha_captcha: &AltchaCaptchaConfigResponse,
) -> Markup {
let status = if altcha_captcha.enabled {
("Live", BadgeVariant::Success)
} else {
("Inert", BadgeVariant::Default)
};
let included_user_ids = altcha_captcha.included_user_ids.join("\n");
let excluded_user_ids = altcha_captcha.excluded_user_ids.join("\n");
section_card_with_description(
"ALTCHA Captcha",
"Replaces the configured captcha provider with an ALTCHA proof-of-work check for the \
selected requesters. The API issues and verifies every challenge itself, so no third \
party is involved. Requests only need a captcha where one is already required, so this \
does nothing while captcha is off for the instance.",
html! {
form method="post" action={(base) "/instance-config?action=update_altcha_captcha"} {
(csrf_input(csrf_token))
div class="space-y-6" {
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))
span class="text-xs text-neutral-500" {
"Config version " (altcha_captcha.config_version)
}
}
(checkbox(
"altcha_captcha_enabled",
"true",
"Serve ALTCHA to the selected requesters",
altcha_captcha.enabled,
true,
))
p class="text-xs text-neutral-500" {
"Off is the safe state and the kill switch. With this unchecked every \
requester gets the configured provider and ALTCHA answers are rejected."
}
h3 class="text-sm font-semibold text-neutral-900" { "Logged-out requests" }
(checkbox(
"altcha_captcha_anonymous_enabled",
"true",
"Serve ALTCHA to logged-out requests",
altcha_captcha.anonymous_enabled,
true,
))
p class="text-xs text-neutral-500" {
"Covers registration, login and password reset. These requests have no \
account to bucket, so this switch applies to all of them at once."
}
h3 class="text-sm font-semibold text-neutral-900" { "Rollout" }
(number_field(
"altcha_captcha_rollout_basis_points",
"Rollout (basis points)",
&altcha_captcha.rollout_basis_points.to_string(),
Some(0), Some(10000), "1",
Some("Share of logged-in users bucketed into ALTCHA, in basis points: 0 is nobody, 100 is 1%, 10000 is everybody."),
))
div class="flex flex-col gap-2" {
(text_input(
"altcha_captcha_rollout_salt",
"Rollout Salt",
&altcha_captcha.rollout_salt,
ALTCHA_CAPTCHA_DEFAULT_SALT,
))
p class="text-xs text-neutral-500" {
"Seeds the bucketing hash. Changing it reshuffles which users fall \
inside the percentage above."
}
}
div class="flex flex-col gap-2" {
(textarea_input(
"altcha_captcha_included_user_ids",
"Always-on User IDs",
"1500000000000000001\n1500000000000000002",
&included_user_ids,
4,
false,
))
(entry_count_hint(
altcha_captcha.included_user_ids.len(),
EXPERIMENT_MAX_TARGETED_USERS,
))
p class="text-xs text-neutral-500" {
"One snowflake per line, or comma separated. These users get ALTCHA \
regardless of the percentage above. Invalid entries prevent the save."
}
}
div class="flex flex-col gap-2" {
(checkbox(
"altcha_captcha_include_premium_users",
"true",
"Include premium users",
altcha_captcha.include_premium_users,
true,
))
p class="text-xs text-neutral-500" {
"Includes every account with active premium perks, regardless of the \
percentage above. The never-on list still wins."
}
}
div class="flex flex-col gap-2" {
(textarea_input(
"altcha_captcha_included_guild_ids",
"Always-on Guild IDs",
"1500000000000000005\n1500000000000000006",
&altcha_captcha.included_guild_ids.join("\n"),
4,
false,
))
(entry_count_hint(
altcha_captcha.included_guild_ids.len(),
EXPERIMENT_MAX_TARGETED_USERS,
))
p class="text-xs text-neutral-500" {
"Same format, with guild IDs. Every member of a listed guild is \
included regardless of the percentage above, unless the user is \
in the never-on list."
}
}
div class="flex flex-col gap-2" {
(textarea_input(
"altcha_captcha_excluded_user_ids",
"Never-on User IDs",
"1500000000000000003\n1500000000000000004",
&excluded_user_ids,
4,
false,
))
(entry_count_hint(
altcha_captcha.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."
}
}
h3 class="text-sm font-semibold text-neutral-900" { "Difficulty" }
(number_field(
"altcha_captcha_cost",
"Cost (PBKDF2 iterations per attempt)",
&altcha_captcha.cost.to_string(),
Some(*ALTCHA_CAPTCHA_COST_RANGE.start()),
Some(*ALTCHA_CAPTCHA_COST_RANGE.end()),
"1",
Some("The API spends one attempt at this cost to issue each challenge."),
))
(number_field(
"altcha_captcha_max_counter",
"Maximum counter",
&altcha_captcha.max_counter.to_string(),
Some(*ALTCHA_CAPTCHA_MAX_COUNTER_RANGE.start()),
Some(*ALTCHA_CAPTCHA_MAX_COUNTER_RANGE.end()),
"1",
Some("Each challenge hides its answer between half this value and this value. The client tries counters from 0 until it finds it, so solve time grows with cost times this value. At the defaults a recent laptop takes about 3 seconds."),
))
(form_actions(html! {
(submit_button("Save ALTCHA Configuration"))
}))
}
}
},
)
}
fn profile_timezone_section(
base: &str,
csrf_token: &str,
profile_timezone: &ProfileTimezoneConfigResponse,
) -> Markup {
let status = if profile_timezone.enabled {
("Live", BadgeVariant::Success)
} else {
("Inert", BadgeVariant::Default)
};
let included_user_ids = profile_timezone.included_user_ids.join("\n");
let excluded_user_ids = profile_timezone.excluded_user_ids.join("\n");
section_card_with_description(
"Profile Timezone",
"Lets the selected users save a time zone in profile settings and show their local time \
on their profile. Users outside the rollout cannot change it, and a saved time zone \
stays hidden from everyone while its owner is outside the rollout.",
html! {
form method="post" action={(base) "/instance-config?action=update_profile_timezone"} {
(csrf_input(csrf_token))
div class="space-y-6" {
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))
span class="text-xs text-neutral-500" {
"Config version " (profile_timezone.config_version)
}
}
(checkbox(
"profile_timezone_enabled",
"true",
"Serve profile timezone to the selected users",
profile_timezone.enabled,
true,
))
p class="text-xs text-neutral-500" {
"Off is the safe state and the kill switch. With this unchecked nobody \
sees the setting and every saved time zone is hidden."
}
h3 class="text-sm font-semibold text-neutral-900" { "Rollout" }
(number_field(
"profile_timezone_rollout_basis_points",
"Rollout (basis points)",
&profile_timezone.rollout_basis_points.to_string(),
Some(0), Some(10000), "1",
Some("Share of users bucketed into profile timezone, in basis points: 0 is nobody, 100 is 1%, 10000 is everybody."),
))
div class="flex flex-col gap-2" {
(text_input(
"profile_timezone_rollout_salt",
"Rollout Salt",
&profile_timezone.rollout_salt,
PROFILE_TIMEZONE_DEFAULT_SALT,
))
p class="text-xs text-neutral-500" {
"Seeds the bucketing hash. Changing it reshuffles which users fall \
inside the percentage above."
}
}
div class="flex flex-col gap-2" {
(textarea_input(
"profile_timezone_included_user_ids",
"Always-on User IDs",
"1500000000000000001\n1500000000000000002",
&included_user_ids,
4,
false,
))
(entry_count_hint(
profile_timezone.included_user_ids.len(),
EXPERIMENT_MAX_TARGETED_USERS,
))
p class="text-xs text-neutral-500" {
"One snowflake per line, or comma separated. These users get profile \
timezone regardless of the percentage above. Invalid entries prevent the save."
}
}
div class="flex flex-col gap-2" {
(checkbox(
"profile_timezone_include_premium_users",
"true",
"Include premium users",
profile_timezone.include_premium_users,
true,
))
p class="text-xs text-neutral-500" {
"Includes every account with active premium perks, regardless of the \
percentage above. The never-on list still wins."
}
}
div class="flex flex-col gap-2" {
(textarea_input(
"profile_timezone_included_guild_ids",
"Always-on Guild IDs",
"1500000000000000005\n1500000000000000006",
&profile_timezone.included_guild_ids.join("\n"),
4,
false,
))
(entry_count_hint(
profile_timezone.included_guild_ids.len(),
EXPERIMENT_MAX_TARGETED_USERS,
))
p class="text-xs text-neutral-500" {
"Same format, with guild IDs. Every member of a listed guild is \
included regardless of the percentage above, unless the user is \
in the never-on list."
}
}
div class="flex flex-col gap-2" {
(textarea_input(
"profile_timezone_excluded_user_ids",
"Never-on User IDs",
"1500000000000000003\n1500000000000000004",
&excluded_user_ids,
4,
false,
))
(entry_count_hint(
profile_timezone.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."
}
}
(form_actions(html! {
(submit_button("Save Profile Timezone Configuration"))
}))
}
}
},
)
}
fn experiment_delivery_section(
base: &str,
csrf_token: &str,
@@ -2086,6 +2429,31 @@ mod tests {
assert!(!markup.contains("at the cap"));
}
#[test]
fn push_relay_section_shows_the_consent_toggle() {
let accepted = PushRelayConfigResponse {
relay_consent_accepted: true,
relay_consent_accepted_at: Some("2026-09-27T10:11:12.000Z".to_owned()),
relay_consent_accepted_by: Some("1130650140672000000".to_owned()),
};
let markup = push_relay_section("/admin", "csrf", &accepted).into_string();
assert!(markup.contains("action=update_push_relay"));
assert!(markup.contains("name=\"push_relay_consent_accepted\""));
assert!(markup.contains("https://fluxer.com/push-relay"));
assert!(markup.contains("value=\"Sep 27, 2026, 10:11 AM UTC\""));
assert!(markup.contains("value=\"1130650140672000000\""));
assert!(!markup.contains("name=\"push_relay_consent_accepted_at\""));
assert!(!markup.contains("name=\"push_relay_consent_accepted_by\""));
assert!(!markup.to_lowercase().contains("rollout"));
let unaccepted =
push_relay_section("/admin", "csrf", &PushRelayConfigResponse::default()).into_string();
assert!(unaccepted.contains("name=\"push_relay_consent_accepted\""));
assert!(unaccepted.contains("Not accepted"));
assert!(unaccepted.contains("value=\"Never\""));
assert!(unaccepted.contains("value=\"Nobody\""));
}
#[test]
fn voice_noise_suppression_section_flags_a_list_at_its_cap() {
let voice_noise_suppression = VoiceNoiseSuppressionConfigResponse {
+90 -7
View File
@@ -403,19 +403,18 @@ fn deserialize_instance_config_response_with_unknown_keys() {
"included_user_ids": [],
"excluded_user_ids": [],
"guild_overrides": [],
"included_guild_ids": ["1500000000000000005"],
"include_premium_users": true,
"suppression_strength": 80,
"future_presentation_knob": "verbose",
"future_knob": 7,
"future_object_knob": {"nested": true},
"future_list_knob": ["a", "b"]
},
"push_service_delivery": {
"enabled": true,
"config_version": 3,
"rollout_basis_points": 5000,
"rollout_salt": "push-service-delivery-v1",
"included_user_ids": ["1500000000000000002"],
"excluded_user_ids": []
"push_relay": {
"relay_consent_accepted": true,
"relay_consent_accepted_at": "2026-09-27T10:11:12.000Z",
"relay_consent_accepted_by": "1130650140672000000"
},
"domain_migration": {
"enabled": true,
@@ -424,10 +423,37 @@ fn deserialize_instance_config_response_with_unknown_keys() {
"rollout_salt": "domain-migration-v1",
"included_user_ids": ["1500000000000000001"],
"excluded_user_ids": [],
"included_guild_ids": [],
"include_premium_users": false,
"future_migration_knob": 9,
"anonymous_rollout_basis_points": 100,
"standalone_forwarding": true
},
"altcha_captcha": {
"enabled": true,
"config_version": 3,
"rollout_basis_points": 500,
"rollout_salt": "altcha-captcha-v1",
"included_user_ids": [],
"excluded_user_ids": ["1500000000000000003"],
"anonymous_enabled": true,
"cost": 5000,
"max_counter": 10000,
"included_guild_ids": [],
"include_premium_users": false,
"future_altcha_knob": "argon2id"
},
"profile_timezone": {
"enabled": true,
"config_version": 2,
"rollout_basis_points": 0,
"rollout_salt": "profile-timezone-v1",
"included_user_ids": ["1500000000000000001"],
"excluded_user_ids": [],
"included_guild_ids": ["1500000000000000005"],
"include_premium_users": true,
"future_profile_timezone_knob": true
},
"experiment_delivery": {"poll_interval_seconds": 300, "poll_jitter_percent": 15},
"registration": {
"mode": "open",
@@ -564,6 +590,18 @@ fn deserialize_instance_config_response_with_unknown_keys() {
assert_eq!(resp.domain_migration.included_user_ids.len(), 1);
assert_eq!(resp.domain_migration.anonymous_rollout_basis_points, 100);
assert!(resp.domain_migration.standalone_forwarding);
assert!(resp.push_relay.relay_consent_accepted);
assert!(resp.altcha_captcha.enabled);
assert_eq!(resp.altcha_captcha.config_version, 3);
assert!(resp.altcha_captcha.anonymous_enabled);
assert_eq!(resp.altcha_captcha.excluded_user_ids.len(), 1);
assert_eq!(resp.altcha_captcha.max_counter, 10000);
assert!(resp.profile_timezone.enabled);
assert_eq!(resp.profile_timezone.config_version, 2);
assert_eq!(resp.profile_timezone.included_user_ids.len(), 1);
assert_eq!(resp.profile_timezone.included_guild_ids.len(), 1);
assert!(resp.profile_timezone.include_premium_users);
assert!(resp.voice_noise_suppression.include_premium_users);
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));
@@ -596,6 +634,51 @@ fn deserialize_instance_config_response_with_unknown_keys() {
);
}
#[test]
fn deserialize_push_relay_config() {
let accepted: types::PushRelayConfigResponse = serde_json::from_str(
r#"{
"relay_consent_accepted": true,
"relay_consent_accepted_at": "2026-09-27T10:11:12.000Z",
"relay_consent_accepted_by": "1130650140672000000"
}"#,
)
.expect("an accepted relay consent must deserialize");
assert!(accepted.relay_consent_accepted);
assert_eq!(
accepted.relay_consent_accepted_at.as_deref(),
Some("2026-09-27T10:11:12.000Z")
);
assert_eq!(
accepted.relay_consent_accepted_by.as_deref(),
Some("1130650140672000000")
);
let empty: types::PushRelayConfigResponse =
serde_json::from_str("{}").expect("an empty push relay config must deserialize");
assert!(!empty.relay_consent_accepted);
assert!(empty.relay_consent_accepted_at.is_none());
assert!(empty.relay_consent_accepted_by.is_none());
}
#[test]
fn serialize_push_relay_update_omits_an_unset_consent() {
assert_eq!(
serde_json::to_value(types::PushRelayConfigUpdateRequest::default()).unwrap(),
serde_json::json!({})
);
let with = types::PushRelayConfigUpdateRequest {
relay_consent_accepted: Some(true),
};
assert_eq!(
serde_json::to_value(&with).unwrap(),
serde_json::json!({"relay_consent_accepted": true})
);
}
#[test]
fn deserialize_search_reports_response() {
let json = r#"{
+1
View File
@@ -56,6 +56,7 @@
"@simplewebauthn/server": "catalog:",
"@types/node": "catalog:",
"@vvo/tzdb": "catalog:",
"altcha-lib": "catalog:",
"archiver": "catalog:",
"argon2": "catalog:",
"bowser": "catalog:",
+3 -1
View File
@@ -11,7 +11,9 @@
},
"dependencies": {
"@fluxer/logger": "workspace:*",
"itty-time": "catalog:"
"altcha-lib": "catalog:",
"itty-time": "catalog:",
"zod": "catalog:"
},
"devDependencies": {
"@types/node": "catalog:",
@@ -5,7 +5,7 @@ export interface VerifyCaptchaParams {
remoteIp?: string;
}
export type CaptchaProviderType = 'hcaptcha' | 'recaptcha' | 'turnstile' | 'test' | 'unavailable';
export type CaptchaProviderType = 'hcaptcha' | 'recaptcha' | 'turnstile' | 'altcha' | 'test' | 'unavailable';
export interface ICaptchaProvider {
readonly type: CaptchaProviderType;
@@ -0,0 +1,107 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {LoggerInterface} from '@fluxer/logger/src/LoggerInterface';
import type {CaptchaProviderType, ICaptchaProvider, VerifyCaptchaParams} from '@pkgs/captcha/src/ICaptchaProvider';
import {createChallenge, randomInt, verifySolution} from 'altcha-lib';
import {deriveKey} from 'altcha-lib/algorithms/pbkdf2';
import type {Challenge} from 'altcha-lib/types';
import {ms} from 'itty-time';
import {z} from 'zod';
export const ALTCHA_ALGORITHM = 'PBKDF2/SHA-256';
const ALTCHA_CHALLENGE_TTL_MS = ms('10 minutes');
const ALTCHA_MAX_TOKEN_LENGTH = 4096;
const HEX_PATTERN = /^[0-9a-f]+$/u;
const AltchaPayloadSchema = z.object({
challenge: z.object({
parameters: z.looseObject({
algorithm: z.literal(ALTCHA_ALGORITHM),
nonce: z.string().regex(HEX_PATTERN),
salt: z.string().regex(HEX_PATTERN),
cost: z.number().int().positive(),
keyLength: z.number().int().positive(),
keyPrefix: z.string().regex(HEX_PATTERN),
keySignature: z.string().regex(HEX_PATTERN),
expiresAt: z.number().int().positive(),
}),
signature: z.string().regex(HEX_PATTERN),
}),
solution: z.object({
counter: z.number().int().min(0),
derivedKey: z.string().regex(HEX_PATTERN),
time: z.number().optional(),
}),
});
type AltchaPayload = z.infer<typeof AltchaPayloadSchema>;
export interface AltchaProviderOptions {
hmacSignatureSecret: string;
hmacKeySignatureSecret: string;
cost: number;
maxCounter: number;
claimChallenge: (signature: string, ttlSeconds: number) => Promise<boolean>;
logger?: LoggerInterface;
now?: () => number;
}
function decodePayload(token: string): AltchaPayload | null {
if (token.length > ALTCHA_MAX_TOKEN_LENGTH) return null;
try {
const parsed = AltchaPayloadSchema.safeParse(JSON.parse(Buffer.from(token, 'base64').toString('utf8')));
return parsed.success ? parsed.data : null;
} catch {
return null;
}
}
export class AltchaProvider implements ICaptchaProvider {
readonly type: CaptchaProviderType = 'altcha';
private readonly options: AltchaProviderOptions;
private readonly now: () => number;
constructor(options: AltchaProviderOptions) {
this.options = options;
this.now = options.now ?? Date.now;
}
async createChallenge(): Promise<Challenge> {
const {cost, maxCounter, hmacSignatureSecret, hmacKeySignatureSecret} = this.options;
return await createChallenge({
algorithm: ALTCHA_ALGORITHM,
cost,
counter: randomInt(maxCounter, Math.ceil(maxCounter / 2)),
deriveKey,
expiresAt: new Date(this.now() + ALTCHA_CHALLENGE_TTL_MS),
hmacSignatureSecret,
hmacKeySignatureSecret,
});
}
async verify({token}: VerifyCaptchaParams): Promise<boolean> {
const payload = decodePayload(token);
if (!payload) return false;
try {
const result = await verifySolution({
challenge: payload.challenge,
solution: payload.solution,
deriveKey,
hmacSignatureSecret: this.options.hmacSignatureSecret,
hmacKeySignatureSecret: this.options.hmacKeySignatureSecret,
});
if (!result.verified) {
this.options.logger?.warn(
{expired: result.expired, invalidSignature: result.invalidSignature, invalidSolution: result.invalidSolution},
'ALTCHA verification failed',
);
return false;
}
} catch (error) {
this.options.logger?.error({error}, 'Error verifying ALTCHA payload');
return false;
}
const ttlSeconds = Math.max(1, payload.challenge.parameters.expiresAt - Math.floor(this.now() / 1000));
return await this.options.claimChallenge(payload.challenge.signature, ttlSeconds);
}
}
+9
View File
@@ -1,6 +1,7 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {APIConfig, BlueskyOAuthConfig} from '@app/api/config/APIConfig';
import {parseIpBanEntry} from '@app/api/utils/IpRangeUtils';
import type {WorkerTaskName} from '@app/api/worker/WorkerLaneConfig';
import type {MasterConfig} from '@fluxer/config/src/MasterConfig';
import {parseIpAddress} from '@fluxer/ip_utils/src/IpAddress';
@@ -82,6 +83,14 @@ function resolveTrustClientIpHeader(proxyConfig: object): boolean {
function normalizeIpBanExemptIps(values: Array<string>): Array<string> {
const normalized = new Set<string>();
for (const value of values) {
if (value.includes('/')) {
const range = parseIpBanEntry(value);
if (range?.type !== 'range') {
throw new Error(`FLUXER_API_IP_BAN_EXEMPT_IPS contains an invalid CIDR range: ${value}`);
}
normalized.add(range.canonical);
continue;
}
const parsed = parseIpAddress(value);
if (!parsed) {
throw new Error(`FLUXER_API_IP_BAN_EXEMPT_IPS contains an invalid IP address: ${value}`);
@@ -16,7 +16,7 @@ import {OpenAPI} from '@app/api/middleware/ResponseTypeMiddleware';
import {
getGatewayRolloutConfigPublisher,
getInstanceConfigRepository,
getPushServiceDeliveryConfigPublisher,
getPushRelayConfigPublisher,
} from '@app/api/middleware/ServiceSingletons';
import {RateLimitConfigs} from '@app/api/RateLimitConfig';
import type {HonoApp, HonoEnv} from '@app/api/types/HonoEnv';
@@ -34,9 +34,11 @@ import {
PendingRegistrationActionRequest,
RegistrationUrlIdParam,
} from '@fluxer/schema/src/domains/admin/AdminSchemas';
import {AltchaCaptchaConfigSchema} from '@fluxer/schema/src/domains/admin/AltchaCaptchaSchemas';
import {DomainMigrationConfigSchema} from '@fluxer/schema/src/domains/admin/DomainMigrationSchemas';
import {GatewayRolloutConfigSchema} from '@fluxer/schema/src/domains/admin/GatewayRolloutSchemas';
import {PushServiceDeliveryConfigSchema} from '@fluxer/schema/src/domains/admin/PushServiceDeliverySchemas';
import {ProfileTimezoneConfigSchema} from '@fluxer/schema/src/domains/admin/ProfileTimezoneSchemas';
import type {PushRelayConfig, PushRelayConfigUpdateRequest} from '@fluxer/schema/src/domains/admin/PushRelaySchemas';
import {VoiceNoiseSuppressionConfigSchema} from '@fluxer/schema/src/domains/admin/VoiceNoiseSuppressionSchemas';
import {UserIdParam} from '@fluxer/schema/src/domains/common/CommonParamSchemas';
import {ExperimentDeliveryConfigSchema} from '@fluxer/schema/src/domains/experiment/ExperimentSchemas';
@@ -65,8 +67,10 @@ async function buildInstanceConfigResponse(): Promise<InstanceConfigResponse> {
ssoConfig,
gatewayRollout,
voiceNoiseSuppression,
pushServiceDelivery,
pushRelay,
domainMigration,
altchaCaptcha,
profileTimezone,
experimentDelivery,
registrationConfig,
registrationUrls,
@@ -75,8 +79,10 @@ async function buildInstanceConfigResponse(): Promise<InstanceConfigResponse> {
instanceConfigRepository.getSsoConfig(),
instanceConfigRepository.getGatewayRolloutConfig(),
instanceConfigRepository.getVoiceNoiseSuppressionConfig(),
instanceConfigRepository.getPushServiceDeliveryConfig(),
instanceConfigRepository.getPushRelayConfig(),
instanceConfigRepository.getDomainMigrationConfig(),
instanceConfigRepository.getAltchaCaptchaConfig(),
instanceConfigRepository.getProfileTimezoneConfig(),
instanceConfigRepository.getExperimentDeliveryConfig(),
instanceConfigRepository.getRegistrationConfig(),
instanceConfigRepository.getRegistrationUrlsForAdmin(),
@@ -108,8 +114,10 @@ async function buildInstanceConfigResponse(): Promise<InstanceConfigResponse> {
},
gateway_rollout: gatewayRollout,
voice_noise_suppression: voiceNoiseSuppression,
push_service_delivery: pushServiceDelivery,
push_relay: pushRelay,
domain_migration: domainMigration,
altcha_captcha: altchaCaptcha,
profile_timezone: profileTimezone,
experiment_delivery: experimentDelivery,
registration: {
...registrationConfig,
@@ -194,6 +202,20 @@ async function grantSetupCompleterAdminACL(ctx: Context<HonoEnv>): Promise<boole
return true;
}
function relayConsentStamp(
current: PushRelayConfig,
patch: PushRelayConfigUpdateRequest,
adminUserId: string,
): Partial<PushRelayConfig> {
const accepted = patch.relay_consent_accepted;
if (accepted === undefined || accepted === current.relay_consent_accepted) {
return {};
}
return accepted
? {relay_consent_accepted_at: new Date().toISOString(), relay_consent_accepted_by: adminUserId}
: {relay_consent_accepted_at: null, relay_consent_accepted_by: null};
}
function listSuppliedSections(data: InstanceConfigUpdateRequest): string | undefined {
const sections = Object.entries(data)
.filter(([, value]) => value != null)
@@ -273,17 +295,16 @@ export function InstanceConfigAdminController(app: HonoApp) {
);
}
}
if (data.push_service_delivery) {
const patch = omitUndefinedFields(data.push_service_delivery);
if (data.push_relay) {
const patch = omitUndefinedFields(data.push_relay);
if (Object.keys(patch).length > 0) {
const landed = await instanceConfigRepository.updatePushServiceDeliveryConfig((current) =>
PushServiceDeliveryConfigSchema.parse({
...current,
...patch,
config_version: current.config_version + 1,
}),
);
await getPushServiceDeliveryConfigPublisher().publish(landed);
const adminUserId = ctx.get('adminUserId').toString();
const landed = await instanceConfigRepository.updatePushRelayConfig((current) => ({
...current,
...patch,
...relayConsentStamp(current, patch, adminUserId),
}));
await getPushRelayConfigPublisher().publish(landed);
}
}
if (data.domain_migration) {
@@ -298,6 +319,30 @@ export function InstanceConfigAdminController(app: HonoApp) {
);
}
}
if (data.altcha_captcha) {
const patch = omitUndefinedFields(data.altcha_captcha);
if (Object.keys(patch).length > 0) {
await instanceConfigRepository.updateAltchaCaptchaConfig((current) =>
AltchaCaptchaConfigSchema.parse({
...current,
...patch,
config_version: current.config_version + 1,
}),
);
}
}
if (data.profile_timezone) {
const patch = omitUndefinedFields(data.profile_timezone);
if (Object.keys(patch).length > 0) {
await instanceConfigRepository.updateProfileTimezoneConfig((current) =>
ProfileTimezoneConfigSchema.parse({
...current,
...patch,
config_version: current.config_version + 1,
}),
);
}
}
if (data.experiment_delivery) {
const patch = data.experiment_delivery;
await instanceConfigRepository.updateExperimentDeliveryConfig((current) =>
@@ -4,7 +4,7 @@ import type {AdminAuditLog} from '@app/api/admin/IAdminRepository';
import type {TestAccount} from '@app/api/auth/tests/AuthTestUtils';
import {createTestAccount, setUserACLs} from '@app/api/auth/tests/AuthTestUtils';
import {setCassandraQueryExecutorForTesting} from '@app/api/database/CassandraQueryExecution';
import {PushServiceDeliveryConfigPublisher} from '@app/api/instance/PushServiceDeliveryConfigPublisher';
import {PushRelayConfigPublisher} from '@app/api/instance/PushRelayConfigPublisher';
import {InstanceConfigWriteRaceExecutor} from '@app/api/instance/tests/InstanceConfigWriteRaceExecutor';
import {getAdminRepository} from '@app/api/middleware/ServiceSingletons';
import type {ApiTestHarness} from '@app/api/test/ApiTestHarness';
@@ -16,12 +16,12 @@ import {AdminACLs} from '@fluxer/constants/src/AdminACLs';
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
import type {InstanceConfigResponse} from '@fluxer/schema/src/domains/admin/AdminSchemas';
import {
DEFAULT_PUSH_SERVICE_DELIVERY_CONFIG,
type PushServiceDeliveryConfig,
} from '@fluxer/schema/src/domains/admin/PushServiceDeliverySchemas';
type LegacyPushServiceDeliveryWire,
toLegacyPushServiceDeliveryWire,
} from '@fluxer/schema/src/domains/admin/PushRelaySchemas';
import {afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi} from 'vitest';
const PUSH_SERVICE_DELIVERY_CONFIG_KEY = 'push_service_delivery_config';
const PUSH_RELAY_CONFIG_KEY = 'push_service_delivery_config';
describe('instance config admin PATCH under concurrent writes', () => {
let harness: ApiTestHarness;
@@ -55,13 +55,13 @@ describe('instance config admin PATCH under concurrent writes', () => {
const patchConfig = (admin: TestAccount, body: Record<string, unknown>) =>
createBuilder<InstanceConfigResponse>(harness, admin.token).patch('/admin/instance/config').body(body);
const spyOnPushDeliveryPublishes = () =>
vi.spyOn(PushServiceDeliveryConfigPublisher.prototype, 'publish').mockResolvedValue(undefined);
const spyOnPushRelayPublishes = () =>
vi.spyOn(PushRelayConfigPublisher.prototype, 'publish').mockResolvedValue(undefined);
async function readStoredPushServiceDelivery(): Promise<PushServiceDeliveryConfig> {
const raw = await executor.readDirectly(PUSH_SERVICE_DELIVERY_CONFIG_KEY);
if (raw === null) throw new Error('push service delivery config was never stored');
return JSON.parse(raw) as PushServiceDeliveryConfig;
async function readStoredPushRelay(): Promise<LegacyPushServiceDeliveryWire> {
const raw = await executor.readDirectly(PUSH_RELAY_CONFIG_KEY);
if (raw === null) throw new Error('push relay config was never stored');
return JSON.parse(raw) as LegacyPushServiceDeliveryWire;
}
async function listConfigUpdateAudits(): Promise<Array<AdminAuditLog>> {
@@ -84,37 +84,32 @@ describe('instance config admin PATCH under concurrent writes', () => {
});
it('answers with a conflict and neither writes, publishes nor audits once every attempt has lost the race', async () => {
const publish = spyOnPushDeliveryPublishes();
const publish = spyOnPushRelayPublishes();
const admin = await createAdmin();
await patchConfig(admin, {push_service_delivery: {enabled: true, rollout_basis_points: 1000}}).execute();
await patchConfig(admin, {push_relay: {relay_consent_accepted: false}}).execute();
publish.mockClear();
const auditsBefore = await listConfigUpdateAudits();
executor.watch(PUSH_SERVICE_DELIVERY_CONFIG_KEY);
executor.watch(PUSH_RELAY_CONFIG_KEY);
const unaccepted = {
relay_consent_accepted: false,
relay_consent_accepted_at: null,
relay_consent_accepted_by: null,
};
let competingWrites = 0;
executor.competeBeforeEachWrite(async () => {
competingWrites++;
await executor.writeDirectly(
PUSH_SERVICE_DELIVERY_CONFIG_KEY,
JSON.stringify({
...DEFAULT_PUSH_SERVICE_DELIVERY_CONFIG,
enabled: false,
rollout_basis_points: 1000,
config_version: 100 + competingWrites,
}),
PUSH_RELAY_CONFIG_KEY,
JSON.stringify(toLegacyPushServiceDeliveryWire(unaccepted, 100 + competingWrites)),
);
});
await patchConfig(admin, {push_service_delivery: {rollout_basis_points: 5000}})
await patchConfig(admin, {push_relay: {relay_consent_accepted: true}})
.expect(HTTP_STATUS.CONFLICT, APIErrorCodes.CONFLICT)
.execute();
expect(executor.events).not.toContain('write');
expect(await readStoredPushServiceDelivery()).toEqual({
...DEFAULT_PUSH_SERVICE_DELIVERY_CONFIG,
enabled: false,
rollout_basis_points: 1000,
config_version: 100 + competingWrites,
});
expect(await readStoredPushRelay()).toEqual(toLegacyPushServiceDeliveryWire(unaccepted, 100 + competingWrites));
expect(publish).not.toHaveBeenCalled();
expect(await listConfigUpdateAudits()).toHaveLength(auditsBefore.length);
});
@@ -0,0 +1,310 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {TestAccount} from '@app/api/auth/tests/AuthTestUtils';
import {createTestAccount, setUserACLs} from '@app/api/auth/tests/AuthTestUtils';
import {setCassandraQueryExecutorForTesting} from '@app/api/database/CassandraQueryExecution';
import {PushRelayConfigPublisher} from '@app/api/instance/PushRelayConfigPublisher';
import {InstanceConfigWriteRaceExecutor} from '@app/api/instance/tests/InstanceConfigWriteRaceExecutor';
import {getInstanceConfigRepository} from '@app/api/middleware/ServiceSingletons';
import type {ApiTestHarness} from '@app/api/test/ApiTestHarness';
import {createApiTestHarness} from '@app/api/test/ApiTestHarness';
import {InMemoryCassandraQueryExecutor} from '@app/api/test/InMemoryCassandraQueryExecutor';
import {HTTP_STATUS} from '@app/api/test/TestConstants';
import {createBuilder} from '@app/api/test/TestRequestBuilder';
import {AdminACLs} from '@fluxer/constants/src/AdminACLs';
import type {InstanceConfigResponse} from '@fluxer/schema/src/domains/admin/AdminSchemas';
import type {LegacyPushServiceDeliveryWire} from '@fluxer/schema/src/domains/admin/PushRelaySchemas';
import {afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi} from 'vitest';
const PUSH_RELAY_CONFIG_KEY = 'push_service_delivery_config';
const ACCEPTED_AT = '2026-09-20T08:00:00.000Z';
const ACCEPTED_BY = '1500000000000000007';
const PROD_ROW = {
enabled: true,
config_version: 41,
rollout_basis_points: 10000,
rollout_salt: 'push-service-delivery-v1',
included_user_ids: [],
excluded_user_ids: [],
relay_consent_accepted: true,
relay_consent_accepted_at: ACCEPTED_AT,
relay_consent_accepted_by: ACCEPTED_BY,
};
interface PushServiceDeliveryRpcResponse {
type: 'get_push_service_delivery_config';
data: {config: LegacyPushServiceDeliveryWire};
}
describe('push relay supplemental notice consent', () => {
let harness: ApiTestHarness;
let executor: InstanceConfigWriteRaceExecutor;
beforeAll(async () => {
harness = await createApiTestHarness();
executor = new InstanceConfigWriteRaceExecutor(new InMemoryCassandraQueryExecutor());
setCassandraQueryExecutorForTesting(executor);
});
beforeEach(async () => {
await harness.reset();
vi.spyOn(PushRelayConfigPublisher.prototype, 'publish').mockResolvedValue(undefined);
});
afterEach(() => {
vi.restoreAllMocks();
});
afterAll(async () => {
await harness.shutdown();
});
const createAdmin = async (): Promise<TestAccount> =>
await setUserACLs(harness, await createTestAccount(harness), [
AdminACLs.AUTHENTICATE,
AdminACLs.INSTANCE_CONFIG_VIEW,
AdminACLs.INSTANCE_CONFIG_UPDATE,
]);
const patchConfig = (admin: TestAccount, body: Record<string, unknown>) =>
createBuilder<InstanceConfigResponse>(harness, admin.token).patch('/admin/instance/config').body(body);
const readConfig = (admin: TestAccount) =>
createBuilder<InstanceConfigResponse>(harness, admin.token).get('/admin/instance/config');
const readRpcConfig = async (): Promise<LegacyPushServiceDeliveryWire> => {
const response = await createBuilder<PushServiceDeliveryRpcResponse>(harness, '')
.post('/test/rpc-session-init')
.body({type: 'get_push_service_delivery_config'})
.expect(HTTP_STATUS.OK)
.execute();
expect(response.type).toBe('get_push_service_delivery_config');
return response.data.config;
};
async function storeRow(row: Record<string, unknown>): Promise<void> {
await executor.writeDirectly(PUSH_RELAY_CONFIG_KEY, JSON.stringify(row));
getInstanceConfigRepository().clearCacheForTesting();
}
async function readStoredRow(): Promise<unknown> {
const raw = await executor.readDirectly(PUSH_RELAY_CONFIG_KEY);
if (raw === null) throw new Error('push relay config was never stored');
return JSON.parse(raw);
}
it('reads back as unaccepted before an operator agrees', async () => {
const admin = await createAdmin();
const config = await readConfig(admin).execute();
expect(config.push_relay).toEqual({
relay_consent_accepted: false,
relay_consent_accepted_at: null,
relay_consent_accepted_by: null,
});
});
it('keeps the consent of a stored push service delivery row', async () => {
const admin = await createAdmin();
await storeRow(PROD_ROW);
const config = await readConfig(admin).execute();
expect(config.push_relay).toEqual({
relay_consent_accepted: true,
relay_consent_accepted_at: ACCEPTED_AT,
relay_consent_accepted_by: ACCEPTED_BY,
});
});
it('reads a stored row without consent fields as unaccepted', async () => {
const admin = await createAdmin();
await storeRow({enabled: true, config_version: 3, rollout_basis_points: 10000});
const config = await readConfig(admin).execute();
expect(config.push_relay.relay_consent_accepted).toBe(false);
expect(await readRpcConfig()).toMatchObject({config_version: 3, relay_consent_accepted: false});
});
it('stamps the acting admin and the acceptance time when consent is given', async () => {
const admin = await createAdmin();
const updated = await patchConfig(admin, {push_relay: {relay_consent_accepted: true}}).execute();
expect(updated.push_relay.relay_consent_accepted).toBe(true);
expect(updated.push_relay.relay_consent_accepted_by).toBe(admin.userId);
expect(Date.parse(updated.push_relay.relay_consent_accepted_at ?? '')).not.toBeNaN();
});
it('keeps the stamp untouched when consent is re-sent unchanged', async () => {
const admin = await createAdmin();
const accepted = await patchConfig(admin, {push_relay: {relay_consent_accepted: true}}).execute();
const resent = await patchConfig(admin, {push_relay: {relay_consent_accepted: true}}).execute();
expect(resent.push_relay).toEqual(accepted.push_relay);
});
it('clears the stamp when an operator withdraws consent', async () => {
const admin = await createAdmin();
await patchConfig(admin, {push_relay: {relay_consent_accepted: true}}).execute();
const withdrawn = await patchConfig(admin, {push_relay: {relay_consent_accepted: false}}).execute();
expect(withdrawn.push_relay).toEqual({
relay_consent_accepted: false,
relay_consent_accepted_at: null,
relay_consent_accepted_by: null,
});
});
it('ignores an acceptance stamp supplied by the caller', async () => {
const admin = await createAdmin();
const updated = await patchConfig(admin, {
push_relay: {
relay_consent_accepted: true,
relay_consent_accepted_at: '2020-01-01T00:00:00.000Z',
relay_consent_accepted_by: '1500000000000000009',
},
}).execute();
expect(updated.push_relay.relay_consent_accepted_at).not.toBe('2020-01-01T00:00:00.000Z');
expect(updated.push_relay.relay_consent_accepted_by).toBe(admin.userId);
});
it('writes the full legacy document and bumps the stored config version', async () => {
const admin = await createAdmin();
await storeRow({
...PROD_ROW,
relay_consent_accepted: false,
relay_consent_accepted_at: null,
relay_consent_accepted_by: null,
});
const updated = await patchConfig(admin, {push_relay: {relay_consent_accepted: true}}).execute();
expect(await readStoredRow()).toEqual({
enabled: true,
config_version: 42,
rollout_basis_points: 10000,
rollout_salt: 'push-service-delivery-v1',
included_user_ids: [],
excluded_user_ids: [],
relay_consent_accepted: true,
relay_consent_accepted_at: updated.push_relay.relay_consent_accepted_at,
relay_consent_accepted_by: admin.userId,
});
await patchConfig(admin, {push_relay: {relay_consent_accepted: false}}).execute();
expect(await readStoredRow()).toMatchObject({
enabled: true,
config_version: 43,
rollout_basis_points: 10000,
relay_consent_accepted: false,
relay_consent_accepted_at: null,
relay_consent_accepted_by: null,
});
});
it('rewrites a partially enrolled stored row as full enrolment', async () => {
const admin = await createAdmin();
await storeRow({
...PROD_ROW,
enabled: false,
rollout_basis_points: 250,
rollout_salt: 'custom-salt',
included_user_ids: ['1500000000000000003'],
excluded_user_ids: ['1500000000000000004'],
});
await patchConfig(admin, {push_relay: {relay_consent_accepted: false}}).execute();
expect(await readStoredRow()).toMatchObject({
enabled: true,
config_version: 42,
rollout_basis_points: 10000,
rollout_salt: 'push-service-delivery-v1',
included_user_ids: [],
excluded_user_ids: [],
});
});
it('publishes the legacy delivery document with the consent', async () => {
const admin = await createAdmin();
const publish = vi.mocked(PushRelayConfigPublisher.prototype.publish);
const updated = await patchConfig(admin, {push_relay: {relay_consent_accepted: true}}).execute();
expect(publish).toHaveBeenCalledTimes(1);
expect(publish).toHaveBeenCalledWith({
enabled: true,
config_version: 1,
rollout_basis_points: 10000,
rollout_salt: 'push-service-delivery-v1',
included_user_ids: [],
excluded_user_ids: [],
relay_consent_accepted: true,
relay_consent_accepted_at: updated.push_relay.relay_consent_accepted_at,
relay_consent_accepted_by: admin.userId,
});
});
it('does not write or publish for an empty push relay patch', async () => {
const admin = await createAdmin();
const publish = vi.mocked(PushRelayConfigPublisher.prototype.publish);
await patchConfig(admin, {push_relay: {}}).execute();
expect(publish).not.toHaveBeenCalled();
expect(await executor.readDirectly(PUSH_RELAY_CONFIG_KEY)).toBeNull();
});
it('ignores the retired push_service_delivery section', async () => {
const admin = await createAdmin();
const publish = vi.mocked(PushRelayConfigPublisher.prototype.publish);
const updated = await patchConfig(admin, {push_service_delivery: {relay_consent_accepted: true}}).execute();
expect(updated.push_relay.relay_consent_accepted).toBe(false);
expect(publish).not.toHaveBeenCalled();
});
it('answers the legacy delivery RPC with full enrolment and the stored consent', async () => {
await storeRow(PROD_ROW);
expect(await readRpcConfig()).toEqual(PROD_ROW);
});
it('answers the legacy delivery RPC with defaults before anything is stored', async () => {
expect(await readRpcConfig()).toEqual({
enabled: true,
config_version: 0,
rollout_basis_points: 10000,
rollout_salt: 'push-service-delivery-v1',
included_user_ids: [],
excluded_user_ids: [],
relay_consent_accepted: false,
relay_consent_accepted_at: null,
relay_consent_accepted_by: null,
});
});
it('answers the legacy delivery RPC with consent given through the admin API', async () => {
const admin = await createAdmin();
const updated = await patchConfig(admin, {push_relay: {relay_consent_accepted: true}}).execute();
expect(await readRpcConfig()).toMatchObject({
enabled: true,
config_version: 1,
rollout_basis_points: 10000,
relay_consent_accepted: true,
relay_consent_accepted_at: updated.push_relay.relay_consent_accepted_at,
relay_consent_accepted_by: admin.userId,
});
});
});
@@ -43,7 +43,6 @@ async function revokeSessionTargets(
scope === 'all'
? users.deleteAllPushSubscriptions(userId)
: users.deletePushSubscriptionsForAuthSessions(userId, sessionIdHashes, {deleteUnboundSubscriptions: true}),
() => gateway.invalidatePushSubscriptions({userId}),
];
if (scope === 'selected' || targets.length > 0) {
steps.push(
@@ -0,0 +1,178 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {createTestAccount} from '@app/api/auth/tests/AuthTestUtils';
import {Config} from '@app/api/Config';
import {getInstanceConfigRepository} from '@app/api/middleware/ServiceSingletons';
import {type ApiTestHarness, createApiTestHarness} from '@app/api/test/ApiTestHarness';
import {HTTP_STATUS} from '@app/api/test/TestConstants';
import {createBuilder, createBuilderWithoutAuth, type TestRequestBuilder} from '@app/api/test/TestRequestBuilder';
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
import {
type AltchaCaptchaConfig,
DEFAULT_ALTCHA_CAPTCHA_CONFIG,
} from '@fluxer/schema/src/domains/admin/AltchaCaptchaSchemas';
import {solveChallenge} from 'altcha-lib';
import {deriveKey} from 'altcha-lib/algorithms/pbkdf2';
import type {Challenge} from 'altcha-lib/types';
import {afterAll, afterEach, beforeAll, beforeEach, describe, expect, it} from 'vitest';
interface CaptchaErrorBody {
code: string;
captcha_provider?: string;
altcha_challenge?: Challenge;
}
const FORGOT_PATH = '/auth/forgot';
const FORGOT_BODY = {email: '[email protected]'};
async function setAltchaConfig(overrides: Partial<AltchaCaptchaConfig>): Promise<void> {
await getInstanceConfigRepository().setAltchaCaptchaConfig({
...DEFAULT_ALTCHA_CAPTCHA_CONFIG,
enabled: true,
cost: 1000,
max_counter: 100,
...overrides,
});
}
async function solve(challenge: Challenge): Promise<string> {
const solution = await solveChallenge({challenge, deriveKey, timeout: 0});
if (!solution) throw new Error('ALTCHA challenge was not solved');
return Buffer.from(JSON.stringify({challenge, solution}), 'utf8').toString('base64');
}
async function rejectWith(builder: TestRequestBuilder<CaptchaErrorBody>, code: string): Promise<CaptchaErrorBody> {
const {json} = await builder.expect(HTTP_STATUS.BAD_REQUEST, code).executeWithResponse();
expect(json.code).toBe(code);
return json;
}
function forgot(harness: ApiTestHarness): TestRequestBuilder<CaptchaErrorBody> {
return createBuilderWithoutAuth<CaptchaErrorBody>(harness).post(FORGOT_PATH).body(FORGOT_BODY);
}
describe('ALTCHA captcha experiment', () => {
let harness: ApiTestHarness;
let previousCaptchaEnabled: boolean;
let previousTestModeEnabled: boolean;
beforeAll(async () => {
harness = await createApiTestHarness();
});
beforeEach(async () => {
await harness.reset();
previousCaptchaEnabled = Config.captcha.enabled;
previousTestModeEnabled = Config.dev.testModeEnabled;
Config.captcha.enabled = true;
Config.dev.testModeEnabled = true;
});
afterEach(() => {
Config.captcha.enabled = previousCaptchaEnabled;
Config.dev.testModeEnabled = previousTestModeEnabled;
});
afterAll(async () => {
await harness.shutdown();
});
it('keeps the configured provider while the experiment is off', async () => {
const body = await rejectWith(forgot(harness), APIErrorCodes.CAPTCHA_REQUIRED);
expect(body).not.toHaveProperty('captcha_provider');
expect(body).not.toHaveProperty('altcha_challenge');
});
it('leaves anonymous requests on the configured provider unless anonymous_enabled is set', async () => {
await setAltchaConfig({rollout_basis_points: 10000});
const body = await rejectWith(forgot(harness), APIErrorCodes.CAPTCHA_REQUIRED);
expect(body).not.toHaveProperty('altcha_challenge');
});
it('serves anonymous requests a challenge and accepts the solved payload once', async () => {
await setAltchaConfig({anonymous_enabled: true});
const required = await rejectWith(forgot(harness), APIErrorCodes.CAPTCHA_REQUIRED);
expect(required.captcha_provider).toBe('altcha');
expect(required.altcha_challenge?.parameters).toMatchObject({algorithm: 'PBKDF2/SHA-256', cost: 1000});
const token = await solve(required.altcha_challenge as Challenge);
await forgot(harness)
.header('X-Captcha-Token', token)
.header('X-Captcha-Type', 'altcha')
.expect(HTTP_STATUS.NO_CONTENT)
.execute();
const replayed = await rejectWith(
forgot(harness).header('X-Captcha-Token', token).header('X-Captcha-Type', 'altcha'),
APIErrorCodes.INVALID_CAPTCHA,
);
expect(replayed.captcha_provider).toBe('altcha');
expect(replayed.altcha_challenge?.signature).not.toBe(required.altcha_challenge?.signature);
});
it('rejects a payload whose derived key does not match the challenge', async () => {
await setAltchaConfig({anonymous_enabled: true});
const required = await rejectWith(forgot(harness), APIErrorCodes.CAPTCHA_REQUIRED);
const challenge = required.altcha_challenge as Challenge;
const forged = Buffer.from(
JSON.stringify({challenge, solution: {counter: 1, derivedKey: '00'.repeat(32)}}),
'utf8',
).toString('base64');
await rejectWith(
forgot(harness).header('X-Captcha-Token', forged).header('X-Captcha-Type', 'altcha'),
APIErrorCodes.INVALID_CAPTCHA,
);
});
it('rejects an ALTCHA payload from a requester outside the experiment', async () => {
await setAltchaConfig({anonymous_enabled: true});
const required = await rejectWith(forgot(harness), APIErrorCodes.CAPTCHA_REQUIRED);
const token = await solve(required.altcha_challenge as Challenge);
await setAltchaConfig({anonymous_enabled: false});
const rejected = await rejectWith(
forgot(harness).header('X-Captcha-Token', token).header('X-Captcha-Type', 'altcha'),
APIErrorCodes.INVALID_CAPTCHA,
);
expect(rejected).not.toHaveProperty('altcha_challenge');
});
it('buckets signed-in users by their own rollout and still accepts the configured provider', async () => {
Config.captcha.enabled = false;
const included = await createTestAccount(harness);
const excluded = await createTestAccount(harness);
Config.captcha.enabled = true;
await setAltchaConfig({
anonymous_enabled: true,
included_user_ids: [included.userId],
excluded_user_ids: [excluded.userId],
});
const redeemPath = '/gifts/altcha-gift-code/redeem';
const excludedBody = await rejectWith(
createBuilder<CaptchaErrorBody>(harness, excluded.token).post(redeemPath),
APIErrorCodes.CAPTCHA_REQUIRED,
);
expect(excludedBody).not.toHaveProperty('altcha_challenge');
const includedBody = await rejectWith(
createBuilder<CaptchaErrorBody>(harness, included.token).post(redeemPath),
APIErrorCodes.CAPTCHA_REQUIRED,
);
const token = await solve(includedBody.altcha_challenge as Challenge);
const solved = await createBuilder<CaptchaErrorBody>(harness, included.token)
.post(redeemPath)
.header('X-Captcha-Token', token)
.header('X-Captcha-Type', 'altcha')
.executeRaw();
expect([APIErrorCodes.CAPTCHA_REQUIRED, APIErrorCodes.INVALID_CAPTCHA]).not.toContain(solved.json?.code);
const classic = await createBuilder<CaptchaErrorBody>(harness, included.token)
.post(redeemPath)
.header('X-Captcha-Token', 'hcaptcha-token')
.header('X-Captcha-Type', 'hcaptcha')
.executeRaw();
expect([APIErrorCodes.CAPTCHA_REQUIRED, APIErrorCodes.INVALID_CAPTCHA]).not.toContain(classic.json?.code);
});
});
@@ -333,13 +333,10 @@ export function MessageController(app: HonoApp) {
statusCode: 204,
security: ['botToken', 'bearerToken', 'sessionToken'],
tags: ['Channels', 'Messages'],
description:
'Clears all read state and acknowledgement records for a channel, marking all messages as unread. Returns 204 No Content on success.',
deprecated: true,
description: 'Deprecated. Has no effect on the read state. Returns 204 No Content.',
}),
async (ctx) => {
const userId = ctx.get('user').id;
const channelId = createChannelID(ctx.req.valid('param').channel_id);
await ctx.get('readStateService').deleteReadState({userId, channelId});
return ctx.body(null, 204);
},
);
@@ -62,6 +62,8 @@ describe('DM Privacy Bidirectional Enforcement', () => {
const target = await createTestAccount(harness);
await ensureSessionStarted(harness, sender.token);
await ensureSessionStarted(harness, target.token);
await updateUserSettings(harness, sender.token, {default_guilds_restricted: false});
await updateUserSettings(harness, target.token, {default_guilds_restricted: false});
const guild = await createGuild(harness, sender.token, 'Mutual Community');
const systemChannel = await getChannel(harness, sender.token, guild.system_channel_id!);
const invite = await createChannelInvite(harness, sender.token, systemChannel.id);
@@ -80,6 +82,8 @@ describe('DM Privacy Bidirectional Enforcement', () => {
await ensureSessionStarted(harness, sender.token);
await ensureSessionStarted(harness, target.token);
await createFriendship(harness, sender, target);
await updateUserSettings(harness, sender.token, {default_guilds_restricted: false});
await updateUserSettings(harness, target.token, {default_guilds_restricted: false});
const guild = await createGuild(harness, sender.token, 'Verified Community');
await createBuilder(harness, '')
.post(`/test/guilds/${guild.id}/features`)
@@ -97,6 +101,25 @@ describe('DM Privacy Bidirectional Enforcement', () => {
.expect(HTTP_STATUS.OK)
.execute();
});
it('blocks message from a non-friend guild member to a new account by default', async () => {
const sender = await createTestAccount(harness);
const target = await createTestAccount(harness);
await ensureSessionStarted(harness, sender.token);
await ensureSessionStarted(harness, target.token);
await updateUserSettings(harness, sender.token, {default_guilds_restricted: false});
await createFriendship(harness, sender, target);
const guild = await createGuild(harness, sender.token, 'Default Community');
const systemChannel = await getChannel(harness, sender.token, guild.system_channel_id!);
const invite = await createChannelInvite(harness, sender.token, systemChannel.id);
await acceptInvite(harness, target.token, invite.code);
const channel = await createDmChannel(harness, sender.token, target.userId);
await removeRelationship(harness, sender.token, target.userId);
await createBuilder(harness, sender.token)
.post(`/channels/${channel.id}/messages`)
.body({content: 'default restricted target'})
.expect(HTTP_STATUS.BAD_REQUEST, 'CANNOT_SEND_MESSAGES_TO_USER')
.execute();
});
it('blocks message when sender restricts the only mutual guild', async () => {
const sender = await createTestAccount(harness);
const target = await createTestAccount(harness);
@@ -175,6 +198,8 @@ describe('DM Privacy Bidirectional Enforcement', () => {
const user2 = await createTestAccount(harness);
await ensureSessionStarted(harness, user1.token);
await ensureSessionStarted(harness, user2.token);
await updateUserSettings(harness, user1.token, {default_guilds_restricted: false});
await updateUserSettings(harness, user2.token, {default_guilds_restricted: false});
const guild = await createGuild(harness, user1.token, 'Shared Community');
const systemChannel = await getChannel(harness, user1.token, guild.system_channel_id!);
const invite = await createChannelInvite(harness, user1.token, systemChannel.id);
@@ -1,6 +1,7 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {createHash} from 'node:crypto';
import {resolveExperimentTargeting} from '@app/api/experiment/ExperimentTargeting';
import {LoginRequired} from '@app/api/middleware/AuthMiddleware';
import {RateLimitMiddleware} from '@app/api/middleware/RateLimitMiddleware';
import {OpenAPI} from '@app/api/middleware/ResponseTypeMiddleware';
@@ -8,7 +9,9 @@ import {RateLimitConfigs} from '@app/api/RateLimitConfig';
import type {HonoApp} from '@app/api/types/HonoEnv';
import {entityTagMatches} from '@app/api/utils/EntityTag';
import {Headers as HttpHeaders} from '@fluxer/constants/src/Headers';
import {resolveAltchaCaptchaAssignment} from '@fluxer/schema/src/domains/admin/AltchaCaptchaSchemas';
import {resolveDomainMigrationAssignment} from '@fluxer/schema/src/domains/admin/DomainMigrationSchemas';
import {resolveProfileTimezoneAssignment} from '@fluxer/schema/src/domains/admin/ProfileTimezoneSchemas';
import {resolveVoiceNoiseSuppressionAssignment} from '@fluxer/schema/src/domains/admin/VoiceNoiseSuppressionSchemas';
import {ExperimentAssignmentsResponse} from '@fluxer/schema/src/domains/experiment/ExperimentSchemas';
@@ -29,18 +32,30 @@ export function ExperimentController(app: HonoApp) {
}),
async (ctx) => {
const instanceConfigRepository = ctx.get('instanceConfigRepository');
const [delivery, voiceConfig, domainMigrationConfig] = await Promise.all([
instanceConfigRepository.getExperimentDeliveryConfig(),
instanceConfigRepository.getVoiceNoiseSuppressionConfig(),
instanceConfigRepository.getDomainMigrationConfig(),
const [delivery, voiceConfig, domainMigrationConfig, altchaCaptchaConfig, profileTimezoneConfig] =
await Promise.all([
instanceConfigRepository.getExperimentDeliveryConfig(),
instanceConfigRepository.getVoiceNoiseSuppressionConfig(),
instanceConfigRepository.getDomainMigrationConfig(),
instanceConfigRepository.getAltchaCaptchaConfig(),
instanceConfigRepository.getProfileTimezoneConfig(),
]);
const user = ctx.get('user');
const userId = user.id.toString();
const targeting = await resolveExperimentTargeting(user, [
voiceConfig,
domainMigrationConfig,
altchaCaptchaConfig,
profileTimezoneConfig,
]);
const userId = ctx.get('user').id.toString();
const body: ExperimentAssignmentsResponse = {
poll_interval_seconds: delivery.poll_interval_seconds,
poll_jitter_percent: delivery.poll_jitter_percent,
assignments: {
voice_noise_suppression: resolveVoiceNoiseSuppressionAssignment(voiceConfig, userId),
domain_migration: resolveDomainMigrationAssignment(domainMigrationConfig, userId),
voice_noise_suppression: resolveVoiceNoiseSuppressionAssignment(voiceConfig, userId, targeting),
domain_migration: resolveDomainMigrationAssignment(domainMigrationConfig, userId, targeting),
altcha_captcha: resolveAltchaCaptchaAssignment(altchaCaptchaConfig, userId, targeting),
profile_timezone: resolveProfileTimezoneAssignment(profileTimezoneConfig, userId, targeting),
},
};
const etag = `"${createHash('sha256').update(JSON.stringify(body)).digest('hex')}"`;
@@ -0,0 +1,28 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {getUserRepository} from '@app/api/middleware/ServiceSingletons';
import type {User} from '@app/api/models/User';
import type {ExperimentTargeting} from '@fluxer/schema/src/domains/experiment/ExperimentBucket';
interface TargetableExperimentConfig {
readonly enabled: boolean;
readonly included_guild_ids: ReadonlyArray<string>;
}
const NO_GUILDS: ReadonlySet<string> = new Set();
export const ANONYMOUS_EXPERIMENT_TARGETING: ExperimentTargeting = {
memberGuildIds: NO_GUILDS,
premium: false,
};
export async function resolveExperimentTargeting(
user: User,
configs: ReadonlyArray<TargetableExperimentConfig>,
): Promise<ExperimentTargeting> {
const needsGuilds = configs.some((config) => config.enabled && config.included_guild_ids.length > 0);
const memberGuildIds = needsGuilds
? new Set((await getUserRepository().getUserGuildIds(user.id)).map((guildId) => guildId.toString()))
: NO_GUILDS;
return {memberGuildIds, premium: !user.isBot && user.isPremium()};
}
@@ -1,15 +1,26 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {createTestAccount, setUserACLs} from '@app/api/auth/tests/AuthTestUtils';
import {acceptInvite, createChannelInvite, createGuild, getChannel} from '@app/api/guild/tests/GuildTestUtils';
import {getInstanceConfigRepository} from '@app/api/middleware/ServiceSingletons';
import {type ApiTestHarness, createApiTestHarness} from '@app/api/test/ApiTestHarness';
import {HTTP_STATUS} from '@app/api/test/TestConstants';
import {createBuilder, createBuilderWithoutAuth} from '@app/api/test/TestRequestBuilder';
import {grantPremium} from '@app/api/user/tests/UserTestUtils';
import {AdminACLs} from '@fluxer/constants/src/AdminACLs';
import {UserPremiumTypes} from '@fluxer/constants/src/UserConstants';
import {
DEFAULT_ALTCHA_CAPTCHA_CONFIG,
INERT_ALTCHA_CAPTCHA_ASSIGNMENT,
} from '@fluxer/schema/src/domains/admin/AltchaCaptchaSchemas';
import {
DEFAULT_DOMAIN_MIGRATION_CONFIG,
INERT_DOMAIN_MIGRATION_ASSIGNMENT,
} from '@fluxer/schema/src/domains/admin/DomainMigrationSchemas';
import {
DEFAULT_PROFILE_TIMEZONE_CONFIG,
INERT_PROFILE_TIMEZONE_ASSIGNMENT,
} from '@fluxer/schema/src/domains/admin/ProfileTimezoneSchemas';
import {
DEFAULT_VOICE_NOISE_SUPPRESSION_CONFIG,
INERT_VOICE_NOISE_SUPPRESSION_ASSIGNMENT,
@@ -57,6 +68,8 @@ describe('GET /experiments', () => {
assignments: {
voice_noise_suppression: INERT_VOICE_NOISE_SUPPRESSION_ASSIGNMENT,
domain_migration: INERT_DOMAIN_MIGRATION_ASSIGNMENT,
altcha_captcha: INERT_ALTCHA_CAPTCHA_ASSIGNMENT,
profile_timezone: INERT_PROFILE_TIMEZONE_ASSIGNMENT,
},
});
});
@@ -134,6 +147,215 @@ describe('GET /experiments', () => {
expect(body.assignments.domain_migration).toEqual({enabled: false});
});
it('resolves the altcha captcha caller through the allowlist and the exclusion list', async () => {
const targeted = await createTestAccount(harness);
const excluded = await createTestAccount(harness);
await getInstanceConfigRepository().setAltchaCaptchaConfig({
...DEFAULT_ALTCHA_CAPTCHA_CONFIG,
enabled: true,
rollout_basis_points: 10000,
anonymous_enabled: true,
included_user_ids: [targeted.userId],
excluded_user_ids: [excluded.userId],
});
const targetedBody = await createBuilder<ExperimentAssignmentsResponse>(harness, targeted.token)
.get(ENDPOINT)
.execute();
expect(targetedBody.assignments.altcha_captcha).toEqual({enabled: true});
const excludedBody = await createBuilder<ExperimentAssignmentsResponse>(harness, excluded.token)
.get(ENDPOINT)
.execute();
expect(excludedBody.assignments.altcha_captcha).toEqual({enabled: false});
});
it('bumps the altcha captcha config version on every admin update without the client sending one', async () => {
const admin = await setUserACLs(harness, await createTestAccount(harness), [
AdminACLs.AUTHENTICATE,
AdminACLs.INSTANCE_CONFIG_VIEW,
AdminACLs.INSTANCE_CONFIG_UPDATE,
]);
const afterFirst = await createBuilder<{altcha_captcha: {config_version: number; enabled: boolean}}>(
harness,
admin.token,
)
.patch('/admin/instance/config')
.body({altcha_captcha: {enabled: true, included_user_ids: [admin.userId]}})
.execute();
expect(afterFirst.altcha_captcha).toMatchObject({config_version: 1, enabled: true});
const afterSecond = await createBuilder<{
altcha_captcha: {config_version: number; anonymous_enabled: boolean; cost: number; max_counter: number};
}>(harness, admin.token)
.patch('/admin/instance/config')
.body({altcha_captcha: {anonymous_enabled: true, cost: 2000, max_counter: 400}})
.execute();
expect(afterSecond.altcha_captcha).toMatchObject({
config_version: 2,
anonymous_enabled: true,
cost: 2000,
max_counter: 400,
});
const body = await createBuilder<ExperimentAssignmentsResponse>(harness, admin.token).get(ENDPOINT).execute();
expect(body.assignments.altcha_captcha).toEqual({enabled: true});
});
it('resolves the profile timezone caller through the allowlist and the exclusion list', async () => {
const targeted = await createTestAccount(harness);
const excluded = await createTestAccount(harness);
await getInstanceConfigRepository().setProfileTimezoneConfig({
...DEFAULT_PROFILE_TIMEZONE_CONFIG,
enabled: true,
rollout_basis_points: 10000,
included_user_ids: [targeted.userId],
excluded_user_ids: [excluded.userId],
});
const targetedBody = await createBuilder<ExperimentAssignmentsResponse>(harness, targeted.token)
.get(ENDPOINT)
.execute();
expect(targetedBody.assignments.profile_timezone).toEqual({enabled: true});
const excludedBody = await createBuilder<ExperimentAssignmentsResponse>(harness, excluded.token)
.get(ENDPOINT)
.execute();
expect(excludedBody.assignments.profile_timezone).toEqual({enabled: false});
});
it('bumps the profile timezone config version on every admin update without the client sending one', async () => {
const admin = await setUserACLs(harness, await createTestAccount(harness), [
AdminACLs.AUTHENTICATE,
AdminACLs.INSTANCE_CONFIG_VIEW,
AdminACLs.INSTANCE_CONFIG_UPDATE,
]);
const afterFirst = await createBuilder<{profile_timezone: {config_version: number; enabled: boolean}}>(
harness,
admin.token,
)
.patch('/admin/instance/config')
.body({profile_timezone: {enabled: true, included_user_ids: [admin.userId]}})
.execute();
expect(afterFirst.profile_timezone).toMatchObject({config_version: 1, enabled: true});
const afterSecond = await createBuilder<{
profile_timezone: {config_version: number; rollout_basis_points: number};
}>(harness, admin.token)
.patch('/admin/instance/config')
.body({profile_timezone: {rollout_basis_points: 2500}})
.execute();
expect(afterSecond.profile_timezone).toMatchObject({config_version: 2, rollout_basis_points: 2500});
const body = await createBuilder<ExperimentAssignmentsResponse>(harness, admin.token).get(ENDPOINT).execute();
expect(body.assignments.profile_timezone).toEqual({enabled: true});
});
it('enrols members of an included guild in every experiment and leaves everyone else out', async () => {
const owner = await createTestAccount(harness);
const member = await createTestAccount(harness);
const outsider = await createTestAccount(harness);
const guild = await createGuild(harness, owner.token, 'Experiment Guild');
const systemChannel = await getChannel(harness, owner.token, guild.system_channel_id!);
const invite = await createChannelInvite(harness, owner.token, systemChannel.id);
await acceptInvite(harness, member.token, invite.code);
const repository = getInstanceConfigRepository();
await repository.setVoiceNoiseSuppressionConfig({
...DEFAULT_VOICE_NOISE_SUPPRESSION_CONFIG,
enabled: true,
included_guild_ids: [guild.id],
});
await repository.setDomainMigrationConfig({
...DEFAULT_DOMAIN_MIGRATION_CONFIG,
enabled: true,
included_guild_ids: [guild.id],
});
await repository.setAltchaCaptchaConfig({
...DEFAULT_ALTCHA_CAPTCHA_CONFIG,
enabled: true,
included_guild_ids: [guild.id],
});
await repository.setProfileTimezoneConfig({
...DEFAULT_PROFILE_TIMEZONE_CONFIG,
enabled: true,
included_guild_ids: [guild.id],
});
const memberBody = await createBuilder<ExperimentAssignmentsResponse>(harness, member.token)
.get(ENDPOINT)
.execute();
expect(memberBody.assignments.voice_noise_suppression).toMatchObject({user_targeted: true, source: 'user_rule'});
expect(memberBody.assignments.domain_migration).toEqual({enabled: true});
expect(memberBody.assignments.altcha_captcha).toEqual({enabled: true});
expect(memberBody.assignments.profile_timezone).toEqual({enabled: true});
const outsiderBody = await createBuilder<ExperimentAssignmentsResponse>(harness, outsider.token)
.get(ENDPOINT)
.execute();
expect(outsiderBody.assignments.voice_noise_suppression).toMatchObject({user_targeted: false, source: null});
expect(outsiderBody.assignments.domain_migration).toEqual({enabled: false});
expect(outsiderBody.assignments.altcha_captcha).toEqual({enabled: false});
expect(outsiderBody.assignments.profile_timezone).toEqual({enabled: false});
});
it('enrols premium users, subscription and lifetime alike, when the switch is on', async () => {
const subscriber = await createTestAccount(harness);
const visionary = await createTestAccount(harness);
const free = await createTestAccount(harness);
await grantPremium(harness, subscriber.userId, UserPremiumTypes.SUBSCRIPTION);
await grantPremium(harness, visionary.userId, UserPremiumTypes.LIFETIME);
await getInstanceConfigRepository().setProfileTimezoneConfig({
...DEFAULT_PROFILE_TIMEZONE_CONFIG,
enabled: true,
include_premium_users: true,
});
for (const [account, expected] of [
[subscriber, true],
[visionary, true],
[free, false],
] as const) {
const body = await createBuilder<ExperimentAssignmentsResponse>(harness, account.token).get(ENDPOINT).execute();
expect(body.assignments.profile_timezone).toEqual({enabled: expected});
}
});
it('stores the guild ids and premium switch an admin sets for each experiment', async () => {
const admin = await setUserACLs(harness, await createTestAccount(harness), [
AdminACLs.AUTHENTICATE,
AdminACLs.INSTANCE_CONFIG_VIEW,
AdminACLs.INSTANCE_CONFIG_UPDATE,
]);
const guildIds = ['1500000000000000001', '1500000000000000002'];
const body = await createBuilder<
Record<
'voice_noise_suppression' | 'domain_migration' | 'altcha_captcha' | 'profile_timezone',
{included_guild_ids: Array<string>; include_premium_users: boolean}
>
>(harness, admin.token)
.patch('/admin/instance/config')
.body({
voice_noise_suppression: {included_guild_ids: guildIds, include_premium_users: true},
domain_migration: {included_guild_ids: guildIds, include_premium_users: true},
altcha_captcha: {included_guild_ids: guildIds, include_premium_users: true},
profile_timezone: {included_guild_ids: guildIds, include_premium_users: true},
})
.execute();
expect(body.voice_noise_suppression.included_guild_ids).toEqual(guildIds);
expect(body.domain_migration.included_guild_ids).toEqual(guildIds);
expect(body.altcha_captcha.included_guild_ids).toEqual(guildIds);
expect(body.profile_timezone.included_guild_ids).toEqual(guildIds);
for (const section of [
body.voice_noise_suppression,
body.domain_migration,
body.altcha_captcha,
body.profile_timezone,
]) {
expect(section.include_premium_users).toBe(true);
}
});
it('serves the delivery cadence from the delivery config and not from the voice config', async () => {
const account = await createTestAccount(harness);
await getInstanceConfigRepository().setExperimentDeliveryConfig({
@@ -37,7 +37,6 @@ import type {GuildMemberResponse} from '@fluxer/schema/src/domains/guild/GuildMe
import type {GuildResponse} from '@fluxer/schema/src/domains/guild/GuildResponseSchemas';
import {ms} from 'itty-time';
const PUSH_BADGE_COUNT_BATCH_SIZE = 100;
const USER_PERMISSIONS_BATCH_SIZE = 100;
const GATEWAY_ERROR_TO_DOMAIN_ERROR: Record<string, () => Error> = {
@@ -67,18 +66,6 @@ interface DispatchPresenceParams {
data: unknown;
}
interface InvalidatePushBadgeCountParams {
userId: UserID;
}
interface InvalidatePushBadgeCountsParams {
userIds: Array<UserID>;
}
interface InvalidatePushSubscriptionsParams {
userId: UserID;
}
interface ClearPushChannelNotificationsParams {
userId: UserID;
channelId: ChannelID;
@@ -287,8 +274,6 @@ export class GatewayService {
private readonly MAX_BATCH_CONCURRENCY = 50;
private readonly PENDING_REQUEST_TIMEOUT_MS = ms('30 seconds');
private readonly AUTH_CONTEXT_FALLBACK_MS = ms('5 minutes');
private readonly BADGE_COUNTS_FALLBACK_MS = ms('5 minutes');
private badgeCountsUnsupportedUntil = 0;
constructor() {
this.rpcClient = GatewayRpcClient.getInstance();
@@ -704,48 +689,6 @@ export class GatewayService {
});
}
async invalidatePushBadgeCount({userId}: InvalidatePushBadgeCountParams): Promise<void> {
await this.call('push.invalidate_badge_count', {
user_id: userId.toString(),
});
}
async invalidatePushBadgeCounts({userIds}: InvalidatePushBadgeCountsParams): Promise<void> {
if (Date.now() < this.badgeCountsUnsupportedUntil) {
await this.invalidatePushBadgeCountsIndividually(userIds);
return;
}
const batches: Array<Array<UserID>> = [];
for (let index = 0; index < userIds.length; index += PUSH_BADGE_COUNT_BATCH_SIZE) {
batches.push(userIds.slice(index, index + PUSH_BADGE_COUNT_BATCH_SIZE));
}
try {
await Promise.all(
batches.map((batch) =>
this.call('push.invalidate_badge_counts', {user_ids: batch.map((userId) => userId.toString())}),
),
);
} catch (error) {
const transformedError = this.transformGatewayError(error);
if (!this.isAuthContextUnsupportedError(transformedError)) {
throw transformedError;
}
this.badgeCountsUnsupportedUntil = Date.now() + this.BADGE_COUNTS_FALLBACK_MS;
Logger.warn({error}, '[gateway-rpc] push.invalidate_badge_counts unavailable, falling back to per-user calls');
await this.invalidatePushBadgeCountsIndividually(userIds);
}
}
private async invalidatePushBadgeCountsIndividually(userIds: ReadonlyArray<UserID>): Promise<void> {
await Promise.all(userIds.map((userId) => this.invalidatePushBadgeCount({userId})));
}
async invalidatePushSubscriptions({userId}: InvalidatePushSubscriptionsParams): Promise<void> {
await this.call('push.invalidate_subscriptions', {
user_id: userId.toString(),
});
}
async clearPushChannelNotifications({
userId,
channelId,
@@ -292,12 +292,6 @@ export abstract class IGatewayService {
abstract dispatchPresence(params: {userId: UserID; event: GatewayDispatchEvent; data: unknown}): Promise<void>;
abstract invalidatePushBadgeCount(params: {userId: UserID}): Promise<void>;
abstract invalidatePushBadgeCounts(params: {userIds: Array<UserID>}): Promise<void>;
abstract invalidatePushSubscriptions(params: {userId: UserID}): Promise<void>;
abstract clearPushChannelNotifications(params: {
userId: UserID;
channelId: ChannelID;
@@ -28,6 +28,10 @@ import {
type PendingRegistrationResponse,
type RegistrationUrlResponse,
} from '@fluxer/schema/src/domains/admin/AdminSchemas';
import {
type AltchaCaptchaConfig,
AltchaCaptchaConfigSchema,
} from '@fluxer/schema/src/domains/admin/AltchaCaptchaSchemas';
import {
type DomainMigrationConfig,
DomainMigrationConfigSchema,
@@ -37,9 +41,15 @@ import {
GatewayRolloutConfigSchema,
} from '@fluxer/schema/src/domains/admin/GatewayRolloutSchemas';
import {
type PushServiceDeliveryConfig,
PushServiceDeliveryConfigSchema,
} from '@fluxer/schema/src/domains/admin/PushServiceDeliverySchemas';
type ProfileTimezoneConfig,
ProfileTimezoneConfigSchema,
} from '@fluxer/schema/src/domains/admin/ProfileTimezoneSchemas';
import {
type LegacyPushServiceDeliveryWire,
type PushRelayConfig,
PushRelayConfigSchema,
toLegacyPushServiceDeliveryWire,
} from '@fluxer/schema/src/domains/admin/PushRelaySchemas';
import {
type VoiceNoiseSuppressionConfig,
VoiceNoiseSuppressionConfigSchema,
@@ -66,8 +76,10 @@ import {z} from 'zod';
const GATEWAY_ROLLOUT_CONFIG_KEY = 'gateway_rollout_config';
const VOICE_NOISE_SUPPRESSION_CONFIG_KEY = 'voice_noise_suppression_config';
const PUSH_SERVICE_DELIVERY_CONFIG_KEY = 'push_service_delivery_config';
const PUSH_RELAY_CONFIG_KEY = 'push_service_delivery_config';
const DOMAIN_MIGRATION_CONFIG_KEY = 'domain_migration_config';
const ALTCHA_CAPTCHA_CONFIG_KEY = 'altcha_captcha_config';
const PROFILE_TIMEZONE_CONFIG_KEY = 'profile_timezone_config';
const EXPERIMENT_DELIVERY_CONFIG_KEY = 'experiment_delivery_config';
const REGISTRATION_CONFIG_KEY = 'registration_config';
const REGISTRATION_URLS_KEY = 'registration_urls';
@@ -374,8 +386,10 @@ type StoredConfigSection =
| 'app public'
| 'gateway rollout'
| 'voice noise suppression'
| 'push service delivery'
| 'push relay'
| 'domain migration'
| 'altcha captcha'
| 'profile timezone'
| 'experiment delivery'
| 'instance policy'
| 'integrations'
@@ -514,14 +528,39 @@ function parseStoredVoiceNoiseSuppressionConfig(raw: string | null): VoiceNoiseS
return parseStoredConfigOrDefault(VoiceNoiseSuppressionConfigSchema, raw, 'voice noise suppression');
}
function parseStoredPushServiceDeliveryConfig(raw: string | null): PushServiceDeliveryConfig {
return parseStoredConfigOrDefault(PushServiceDeliveryConfigSchema, raw, 'push service delivery');
const StoredPushRelayConfigSchema = PushRelayConfigSchema.extend({
config_version: z.number().int().min(0).default(0),
});
function parseStoredPushRelayConfig(raw: string | null): LegacyPushServiceDeliveryWire {
const {config_version, ...config} = salvageStoredConfig(
StoredPushRelayConfigSchema,
readStoredConfigValue(raw, 'push relay'),
'push relay',
);
return toLegacyPushServiceDeliveryWire(config, config_version);
}
function toPushRelayConfig(wire: LegacyPushServiceDeliveryWire): PushRelayConfig {
return {
relay_consent_accepted: wire.relay_consent_accepted,
relay_consent_accepted_at: wire.relay_consent_accepted_at,
relay_consent_accepted_by: wire.relay_consent_accepted_by,
};
}
function parseStoredDomainMigrationConfig(raw: string | null): DomainMigrationConfig {
return parseStoredConfigOrDefault(DomainMigrationConfigSchema, raw, 'domain migration');
}
function parseStoredAltchaCaptchaConfig(raw: string | null): AltchaCaptchaConfig {
return parseStoredConfigOrDefault(AltchaCaptchaConfigSchema, raw, 'altcha captcha');
}
function parseStoredProfileTimezoneConfig(raw: string | null): ProfileTimezoneConfig {
return parseStoredConfigOrDefault(ProfileTimezoneConfigSchema, raw, 'profile timezone');
}
function parseStoredExperimentDeliveryConfig(raw: string | null): ExperimentDeliveryConfig {
return parseStoredConfigOrDefault(ExperimentDeliveryConfigSchema, raw, 'experiment delivery');
}
@@ -1169,8 +1208,10 @@ export class InstanceConfigRepository {
parseStoredGatewayRolloutConfig(snapshot.get(GATEWAY_ROLLOUT_CONFIG_KEY) ?? null),
);
parseStoredVoiceNoiseSuppressionConfig(snapshot.get(VOICE_NOISE_SUPPRESSION_CONFIG_KEY) ?? null);
parseStoredPushServiceDeliveryConfig(snapshot.get(PUSH_SERVICE_DELIVERY_CONFIG_KEY) ?? null);
parseStoredPushRelayConfig(snapshot.get(PUSH_RELAY_CONFIG_KEY) ?? null);
parseStoredDomainMigrationConfig(snapshot.get(DOMAIN_MIGRATION_CONFIG_KEY) ?? null);
parseStoredAltchaCaptchaConfig(snapshot.get(ALTCHA_CAPTCHA_CONFIG_KEY) ?? null);
parseStoredProfileTimezoneConfig(snapshot.get(PROFILE_TIMEZONE_CONFIG_KEY) ?? null);
parseStoredExperimentDeliveryConfig(snapshot.get(EXPERIMENT_DELIVERY_CONFIG_KEY) ?? null);
const policy = parseStoredInstancePolicyConfig(snapshot.get(INSTANCE_POLICY_CONFIG_KEY) ?? null);
checkStoredConfig('registration', () =>
@@ -1267,21 +1308,21 @@ export class InstanceConfigRepository {
);
}
async getPushServiceDeliveryConfig(): Promise<PushServiceDeliveryConfig> {
const raw = await this.getConfig(PUSH_SERVICE_DELIVERY_CONFIG_KEY);
return parseStoredPushServiceDeliveryConfig(raw);
async getLegacyPushServiceDeliveryWire(): Promise<LegacyPushServiceDeliveryWire> {
const raw = await this.getConfig(PUSH_RELAY_CONFIG_KEY);
return parseStoredPushRelayConfig(raw);
}
updatePushServiceDeliveryConfig(
update: (current: PushServiceDeliveryConfig) => PushServiceDeliveryConfig,
): Promise<PushServiceDeliveryConfig> {
return this.updateStoredConfig(PUSH_SERVICE_DELIVERY_CONFIG_KEY, (raw) =>
validateStoredConfig(
PushServiceDeliveryConfigSchema,
update(parseStoredPushServiceDeliveryConfig(raw)),
'push service delivery',
),
);
async getPushRelayConfig(): Promise<PushRelayConfig> {
return toPushRelayConfig(await this.getLegacyPushServiceDeliveryWire());
}
updatePushRelayConfig(update: (current: PushRelayConfig) => PushRelayConfig): Promise<LegacyPushServiceDeliveryWire> {
return this.updateStoredConfig(PUSH_RELAY_CONFIG_KEY, (raw) => {
const current = parseStoredPushRelayConfig(raw);
const next = validateStoredConfig(PushRelayConfigSchema, update(toPushRelayConfig(current)), 'push relay');
return toLegacyPushServiceDeliveryWire(next, current.config_version + 1);
});
}
async getDomainMigrationConfig(): Promise<DomainMigrationConfig> {
@@ -1305,6 +1346,44 @@ export class InstanceConfigRepository {
);
}
async getAltchaCaptchaConfig(): Promise<AltchaCaptchaConfig> {
const raw = await this.getConfig(ALTCHA_CAPTCHA_CONFIG_KEY);
return parseStoredAltchaCaptchaConfig(raw);
}
async setAltchaCaptchaConfig(config: AltchaCaptchaConfig): Promise<void> {
await this.updateAltchaCaptchaConfig(() => config);
}
updateAltchaCaptchaConfig(
update: (current: AltchaCaptchaConfig) => AltchaCaptchaConfig,
): Promise<AltchaCaptchaConfig> {
return this.updateStoredConfig(ALTCHA_CAPTCHA_CONFIG_KEY, (raw) =>
validateStoredConfig(AltchaCaptchaConfigSchema, update(parseStoredAltchaCaptchaConfig(raw)), 'altcha captcha'),
);
}
async getProfileTimezoneConfig(): Promise<ProfileTimezoneConfig> {
const raw = await this.getConfig(PROFILE_TIMEZONE_CONFIG_KEY);
return parseStoredProfileTimezoneConfig(raw);
}
async setProfileTimezoneConfig(config: ProfileTimezoneConfig): Promise<void> {
await this.updateProfileTimezoneConfig(() => config);
}
updateProfileTimezoneConfig(
update: (current: ProfileTimezoneConfig) => ProfileTimezoneConfig,
): Promise<ProfileTimezoneConfig> {
return this.updateStoredConfig(PROFILE_TIMEZONE_CONFIG_KEY, (raw) =>
validateStoredConfig(
ProfileTimezoneConfigSchema,
update(parseStoredProfileTimezoneConfig(raw)),
'profile timezone',
),
);
}
async getExperimentDeliveryConfig(): Promise<ExperimentDeliveryConfig> {
const raw = await this.getConfig(EXPERIMENT_DELIVERY_CONFIG_KEY);
return parseStoredExperimentDeliveryConfig(raw);
@@ -1,21 +1,21 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {PushServiceDeliveryConfig} from '@fluxer/schema/src/domains/admin/PushServiceDeliverySchemas';
import type {LegacyPushServiceDeliveryWire} from '@fluxer/schema/src/domains/admin/PushRelaySchemas';
import type {INatsConnectionManager} from '@pkgs/nats/src/INatsConnectionManager';
const textEncoder = new TextEncoder();
export const PUSH_SERVICE_DELIVERY_CONFIG_NATS_SUBJECT = 'config.push.delivery';
const PUSH_SERVICE_DELIVERY_CONFIG_NATS_SUBJECT = 'config.push.delivery';
interface PushServiceDeliveryConfigNatsMessage {
type: 'push_service_delivery_config';
config: PushServiceDeliveryConfig;
config: LegacyPushServiceDeliveryWire;
}
export class PushServiceDeliveryConfigPublisher {
export class PushRelayConfigPublisher {
constructor(private readonly connectionManager: INatsConnectionManager) {}
async publish(config: PushServiceDeliveryConfig): Promise<void> {
async publish(config: LegacyPushServiceDeliveryWire): Promise<void> {
if (this.connectionManager.isClosed()) {
await this.connectionManager.connect();
}
@@ -1,7 +1,11 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {createHmac} from 'node:crypto';
import {Config} from '@app/api/Config';
import {ANONYMOUS_EXPERIMENT_TARGETING, resolveExperimentTargeting} from '@app/api/experiment/ExperimentTargeting';
import type {InstanceCaptchaEffectiveConfig} from '@app/api/instance/InstanceConfigRepository';
import {Logger} from '@app/api/Logger';
import {getKVClient} from '@app/api/middleware/ServiceRegistry';
import type {User} from '@app/api/models/User';
import {accountPolicyContactHasCapability} from '@app/api/risk/AccountPolicyService';
import type {HonoEnv} from '@app/api/types/HonoEnv';
@@ -9,12 +13,44 @@ import {Headers} from '@fluxer/constants/src/Headers';
import {UserFlags} from '@fluxer/constants/src/UserConstants';
import {CaptchaRequiredError, InvalidCaptchaError} from '@fluxer/errors/src/CaptchaErrors';
import {extractClientIp} from '@fluxer/ip_utils/src/ClientIp';
import {type AltchaCaptchaConfig, altchaCaptchaAppliesTo} from '@fluxer/schema/src/domains/admin/AltchaCaptchaSchemas';
import type {InstanceCaptchaProvider} from '@fluxer/schema/src/domains/instance/InstanceSchemas';
import {createCaptchaProvider} from '@pkgs/captcha/src/CaptchaProviderFactory';
import type {ICaptchaProvider} from '@pkgs/captcha/src/ICaptchaProvider';
import {AltchaProvider} from '@pkgs/captcha/src/providers/AltchaProvider';
import type {Context} from 'hono';
import {createMiddleware} from 'hono/factory';
const ALTCHA_SPENT_CHALLENGE_KEY_PREFIX = 'captcha:altcha:spent:';
function deriveAltchaSecret(label: string): string {
return createHmac('sha256', Config.auth.sudoModeSecret).update(label).digest('hex');
}
function createAltchaProvider(config: AltchaCaptchaConfig): AltchaProvider {
return new AltchaProvider({
hmacSignatureSecret: deriveAltchaSecret('fluxer-altcha-challenge-signature-v1'),
hmacKeySignatureSecret: deriveAltchaSecret('fluxer-altcha-key-signature-v1'),
cost: config.cost,
maxCounter: config.max_counter,
claimChallenge: (signature, ttlSeconds) =>
getKVClient().setnx(`${ALTCHA_SPENT_CHALLENGE_KEY_PREFIX}${signature}`, '1', ttlSeconds),
logger: Logger,
});
}
async function altchaChallengeData(altcha: AltchaProvider | null): Promise<Record<string, unknown> | undefined> {
if (!altcha) return undefined;
return {captcha_provider: 'altcha', altcha_challenge: await altcha.createChallenge()};
}
async function resolveAltchaProvider(ctx: Context<HonoEnv>, user: User | undefined): Promise<AltchaProvider | null> {
const config = await ctx.get('instanceConfigRepository').getAltchaCaptchaConfig();
const targeting = user ? await resolveExperimentTargeting(user, [config]) : ANONYMOUS_EXPERIMENT_TARGETING;
if (!altchaCaptchaAppliesTo(config, user ? user.id.toString() : null, targeting)) return null;
return createAltchaProvider(config);
}
function resolveProviderSecret(
config: InstanceCaptchaEffectiveConfig,
provider: InstanceCaptchaProvider,
@@ -58,11 +94,19 @@ export async function verifyCaptchaToken(ctx: Context<HonoEnv>): Promise<void> {
if (accountPolicyContactHasCapability(user?.email, 'captcha_exempt')) return;
if (userHasCaptchaExemptFlag(user)) return;
if (await requestUserHasCaptchaExemptFlag(ctx)) return;
const altcha = await resolveAltchaProvider(ctx, user);
const token = ctx.req.header(Headers.X_CAPTCHA_TOKEN);
if (!token) {
throw new CaptchaRequiredError();
throw new CaptchaRequiredError(await altchaChallengeData(altcha));
}
const provider = resolveCaptchaProvider(captchaConfig, ctx.req.header(Headers.X_CAPTCHA_TYPE));
const requestedType = ctx.req.header(Headers.X_CAPTCHA_TYPE);
if (requestedType === 'altcha') {
if (!altcha || !(await altcha.verify({token}))) {
throw new InvalidCaptchaError(await altchaChallengeData(altcha));
}
return;
}
const provider = resolveCaptchaProvider(captchaConfig, requestedType);
const isValid = await provider.verify({
token,
remoteIp:
@@ -72,7 +116,7 @@ export async function verifyCaptchaToken(ctx: Context<HonoEnv>): Promise<void> {
}) ?? undefined,
});
if (!isValid) {
throw new InvalidCaptchaError();
throw new InvalidCaptchaError(await altchaChallengeData(altcha));
}
}
@@ -51,7 +51,7 @@ import {createUsersServiceClient} from '@app/api/infrastructure/UsersServiceClie
import {VirusScanService} from '@app/api/infrastructure/VirusScanService';
import {GatewayRolloutConfigPublisher} from '@app/api/instance/GatewayRolloutConfigPublisher';
import {InstanceConfigRepository} from '@app/api/instance/InstanceConfigRepository';
import {PushServiceDeliveryConfigPublisher} from '@app/api/instance/PushServiceDeliveryConfigPublisher';
import {PushRelayConfigPublisher} from '@app/api/instance/PushRelayConfigPublisher';
import {InviteRepository} from '@app/api/invite/InviteRepository';
import {Logger} from '@app/api/Logger';
import {LimitConfigService} from '@app/api/limits/LimitConfigService';
@@ -157,13 +157,13 @@ export const getGatewayRolloutConfigPublisher = singleton(
),
);
export const getPushServiceDeliveryConfigPublisher = singleton(
export const getPushRelayConfigPublisher = singleton(
() =>
new PushServiceDeliveryConfigPublisher(
new PushRelayConfigPublisher(
new NatsConnectionManager({
url: Config.nats.coreUrl,
token: Config.nats.authToken || undefined,
name: 'fluxer-api-push-service-delivery-config',
name: 'fluxer-api-push-relay-config',
}),
),
);
@@ -8,6 +8,7 @@ import type {
import {CaptchaMiddleware} from '@app/api/middleware/CaptchaMiddleware';
import type {HonoEnv} from '@app/api/types/HonoEnv';
import {AppErrorHandler} from '@fluxer/errors/src/domains/core/ErrorHandlers';
import {DEFAULT_ALTCHA_CAPTCHA_CONFIG} from '@fluxer/schema/src/domains/admin/AltchaCaptchaSchemas';
import {Hono} from 'hono';
import {afterEach, beforeEach, describe, expect, it} from 'vitest';
@@ -25,6 +26,7 @@ function createHarness(
): (headers: Record<string, string>) => Promise<Response> {
const repository = {
getEffectiveCaptchaConfig: async () => captcha,
getAltchaCaptchaConfig: async () => DEFAULT_ALTCHA_CAPTCHA_CONFIG,
} as unknown as InstanceConfigRepository;
const app = new Hono<HonoEnv>();
app.use(async (ctx, next) => {
+1 -1
View File
@@ -216,7 +216,7 @@ export class UserSettings {
friend_source_flags: friendSourceFlags,
incoming_call_flags: IncomingCallFlags.FRIENDS_ONLY,
group_dm_add_permission_flags: GroupDmAddPermissionFlags.FRIENDS_ONLY,
default_guilds_restricted: false,
default_guilds_restricted: true,
bot_default_guilds_restricted: false,
restricted_guilds: new Set(),
bot_restricted_guilds: new Set(),
+21 -12
View File
@@ -3030,7 +3030,8 @@
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
}
},
"description": "Clears all read state and acknowledgement records for a channel, marking all messages as unread. Returns 204 No Content on success.",
"description": "Deprecated. Has no effect on the read state. Returns 204 No Content.",
"deprecated": true,
"security": [{"botToken": []}, {"sessionToken": []}],
"parameters": [
{
@@ -22404,14 +22405,8 @@
"anyOf": [{"$ref": "#/components/schemas/Int32Type"}, {"type": "null"}],
"description": "The user-selected accent color as an integer"
},
"timezone": {
"description": "The IANA timezone identifier saved by the user. Omitted unless the user has staff access.",
"type": ["string", "null"]
},
"timezone_privacy_flags": {
"description": "Bitfield controlling who can see the profile timezone. Omitted unless the user has staff access.",
"$ref": "#/components/schemas/ProfileFieldPrivacyFlags"
},
"timezone": {"description": "The IANA timezone identifier saved by the user", "type": ["string", "null"]},
"timezone_privacy_flags": {"$ref": "#/components/schemas/ProfileFieldPrivacyFlags"},
"banner": {"description": "The hash of the user profile banner image", "type": ["string", "null"]},
"banner_color": {
"anyOf": [{"$ref": "#/components/schemas/Int32Type"}, {"type": "null"}],
@@ -25121,11 +25116,11 @@
"anyOf": [{"$ref": "#/components/schemas/ColorType"}, {"type": "null"}]
},
"timezone": {
"description": "Staff-only IANA timezone identifier saved for profile local time. Ignored for non-staff users.",
"description": "IANA timezone identifier saved for profile local time. Ignored unless the profile_timezone experiment serves the user.",
"type": ["string", "null"]
},
"timezone_privacy_flags": {
"description": "Staff-only bitfield controlling who can see the profile timezone. Ignored for non-staff users.",
"description": "Bitfield controlling who can see the profile timezone. Ignored unless the profile_timezone experiment serves the user.",
"$ref": "#/components/schemas/ProfileFieldPrivacyFlags"
},
"premium_badge_hidden": {"type": "boolean", "description": "Whether to hide the premium badge"},
@@ -27953,7 +27948,9 @@
"type": "object",
"properties": {
"voice_noise_suppression": {"$ref": "#/components/schemas/VoiceNoiseSuppressionAssignmentResponse"},
"domain_migration": {"$ref": "#/components/schemas/DomainMigrationAssignmentResponse"}
"domain_migration": {"$ref": "#/components/schemas/DomainMigrationAssignmentResponse"},
"altcha_captcha": {"$ref": "#/components/schemas/AltchaCaptchaAssignmentResponse"},
"profile_timezone": {"$ref": "#/components/schemas/ProfileTimezoneAssignmentResponse"}
},
"additionalProperties": false
}
@@ -31629,6 +31626,18 @@
"additionalProperties": false
},
"DonationCurrency": {"type": "string", "enum": ["usd", "eur", "brl", "inr", "pln", "try", "sek", "dkk", "nok"]},
"ProfileTimezoneAssignmentResponse": {
"type": "object",
"properties": {"enabled": {"type": "boolean"}},
"required": ["enabled"],
"additionalProperties": false
},
"AltchaCaptchaAssignmentResponse": {
"type": "object",
"properties": {"enabled": {"type": "boolean"}},
"required": ["enabled"],
"additionalProperties": false
},
"DomainMigrationAssignmentResponse": {
"type": "object",
"properties": {"enabled": {"type": "boolean"}},
@@ -0,0 +1,48 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {createTestAccount} from '@app/api/auth/tests/AuthTestUtils';
import {createChannel, createGuild} from '@app/api/guild/tests/GuildTestUtils';
import {sendMessage} from '@app/api/message/tests/MessageTestUtils';
import {type ApiTestHarness, createApiTestHarness} from '@app/api/test/ApiTestHarness';
import {HTTP_STATUS} from '@app/api/test/TestConstants';
import {createBuilder} from '@app/api/test/TestRequestBuilder';
import {afterEach, beforeEach, describe, expect, test} from 'vitest';
interface AckResponse {
read_states: Array<{
id: string;
last_message_id: string | null;
}>;
}
describe('DELETE /channels/:channel_id/messages/ack', () => {
let harness: ApiTestHarness;
beforeEach(async () => {
harness = await createApiTestHarness();
});
afterEach(async () => {
await harness?.shutdown();
});
test('leaves the read state untouched', async () => {
const account = await createTestAccount(harness);
const guild = await createGuild(harness, account.token, 'Read State Guild');
const channel = await createChannel(harness, account.token, guild.id, 'read-state-channel');
const older = await sendMessage(harness, account.token, channel.id, 'older');
const newer = await sendMessage(harness, account.token, channel.id, 'newer');
await createBuilder<AckResponse>(harness, account.token)
.post('/read-states/ack')
.body({read_states: [{channel_id: channel.id, message_id: newer.id}]})
.expect(HTTP_STATUS.OK)
.execute();
await createBuilder(harness, account.token)
.delete(`/channels/${channel.id}/messages/ack`)
.expect(HTTP_STATUS.NO_CONTENT)
.execute();
const response = await createBuilder<AckResponse>(harness, account.token)
.post('/read-states/ack')
.body({read_states: [{channel_id: channel.id, message_id: older.id}]})
.expect(HTTP_STATUS.OK)
.execute();
expect(response.read_states[0]?.last_message_id).toBe(newer.id);
});
});
@@ -35,8 +35,6 @@ export abstract class IReadStateRepository {
}>
>;
abstract deleteReadState(userId: UserID, channelId: ChannelID): Promise<void>;
abstract bulkAckMessages(
userId: UserID,
readStates: Array<{
@@ -4,7 +4,6 @@ import type {ChannelID, MessageID, UserID} from '@app/api/BrandedTypes';
import {channelIdToMessageId} from '@app/api/BrandedTypes';
import {
BatchBuilder,
deleteOneOrMany,
fetchMany,
fetchManyInChunks,
fetchOne,
@@ -199,15 +198,6 @@ export class ReadStateRepository implements IReadStateRepository {
return appliedUpdates;
}
async deleteReadState(userId: UserID, channelId: ChannelID): Promise<void> {
await deleteOneOrMany(
ReadStates.deleteByPk({
user_id: userId,
channel_id: channelId,
}),
);
}
async bulkAckMessages(
userId: UserID,
readStates: Array<{
@@ -15,53 +15,6 @@ import {ReadStateService} from '@app/api/read_state/ReadStateService';
import {BadGatewayError} from '@fluxer/errors/src/domains/core/BadGatewayError';
import {describe, expect, it, vi} from 'vitest';
describe('ReadStateService.bulkIncrementMentionCounts', () => {
it('invalidates badge counts for touched users in a single bulk call', async () => {
const channelId = createChannelID(2n);
const messageId = createMessageID(3n);
const touched: Array<{userId: UserID; channelId: ChannelID}> = [
{userId: createUserID(10n), channelId},
{userId: createUserID(11n), channelId},
{userId: createUserID(10n), channelId: createChannelID(4n)},
];
const repository = {
bulkIncrementMentionCounts: vi.fn().mockResolvedValue(touched),
} as unknown as IReadStateRepository;
const invalidatePushBadgeCounts = vi.fn().mockResolvedValue(undefined);
const invalidatePushBadgeCount = vi.fn().mockResolvedValue(undefined);
const gatewayService = {
invalidatePushBadgeCounts,
invalidatePushBadgeCount,
} as unknown as IGatewayService;
const service = new ReadStateService(repository, gatewayService);
await service.bulkIncrementMentionCounts([
{userId: createUserID(10n), channelId, messageId},
{userId: createUserID(11n), channelId, messageId},
{userId: createUserID(12n), channelId, messageId},
]);
expect(invalidatePushBadgeCount).not.toHaveBeenCalled();
expect(invalidatePushBadgeCounts).toHaveBeenCalledTimes(1);
expect(invalidatePushBadgeCounts).toHaveBeenCalledWith({userIds: [createUserID(10n), createUserID(11n)]});
});
it('skips the bulk call when no read state was touched', async () => {
const repository = {
bulkIncrementMentionCounts: vi.fn().mockResolvedValue([]),
} as unknown as IReadStateRepository;
const invalidatePushBadgeCounts = vi.fn().mockResolvedValue(undefined);
const gatewayService = {invalidatePushBadgeCounts} as unknown as IGatewayService;
const service = new ReadStateService(repository, gatewayService);
await service.bulkIncrementMentionCounts([
{userId: createUserID(10n), channelId: createChannelID(2n), messageId: createMessageID(3n)},
]);
expect(invalidatePushBadgeCounts).not.toHaveBeenCalled();
});
});
const USER_ID = createUserID(20n);
const CHANNEL_ID = createChannelID(21n);
const MESSAGE_ID = createMessageID(22n);
@@ -78,7 +31,7 @@ function makeReadState(channelId: ChannelID, messageId: MessageID, mentionCount
}
describe('ReadStateService gateway side effects after the write', () => {
it('returns the committed read state when the badge invalidation fails', async () => {
it('returns the committed read state when clearing push notifications fails', async () => {
const stored: Array<{channelId: ChannelID; messageId: MessageID}> = [];
const repository = {
upsertReadState: vi.fn(async (_userId: UserID, channelId: ChannelID, messageId: MessageID) => {
@@ -87,8 +40,7 @@ describe('ReadStateService gateway side effects after the write', () => {
}),
} as unknown as IReadStateRepository;
const gatewayService = {
invalidatePushBadgeCount: vi.fn().mockRejectedValue(new BadGatewayError()),
clearPushChannelNotifications: vi.fn().mockResolvedValue(undefined),
clearPushChannelNotifications: vi.fn().mockRejectedValue(new BadGatewayError()),
dispatchPresence: vi.fn().mockResolvedValue(undefined),
} as unknown as IGatewayService;
const service = new ReadStateService(repository, gatewayService);
@@ -113,7 +65,6 @@ describe('ReadStateService gateway side effects after the write', () => {
),
} as unknown as IReadStateRepository;
const gatewayService = {
invalidatePushBadgeCount: vi.fn().mockResolvedValue(undefined),
clearPushChannelNotifications: vi.fn().mockResolvedValue(undefined),
dispatchPresence: vi.fn().mockRejectedValue(new BadGatewayError()),
} as unknown as IGatewayService;
@@ -138,7 +89,6 @@ describe('ReadStateService gateway side effects after the write', () => {
}),
} as unknown as IReadStateRepository;
const gatewayService = {
invalidatePushBadgeCount: vi.fn().mockResolvedValue(undefined),
clearPushChannelNotifications: vi.fn().mockResolvedValue(undefined),
dispatchPresence: vi.fn().mockRejectedValue(new BadGatewayError()),
} as unknown as IGatewayService;
@@ -156,42 +106,13 @@ describe('ReadStateService gateway side effects after the write', () => {
expect(stored).toEqual(['21', '23']);
});
it('deletes the read state when the badge invalidation fails', async () => {
const deleteReadState = vi.fn().mockResolvedValue(undefined);
const repository = {deleteReadState} as unknown as IReadStateRepository;
const gatewayService = {
invalidatePushBadgeCount: vi.fn().mockRejectedValue(new BadGatewayError()),
} as unknown as IGatewayService;
const service = new ReadStateService(repository, gatewayService);
await expect(service.deleteReadState({userId: USER_ID, channelId: CHANNEL_ID})).resolves.toBeUndefined();
expect(deleteReadState).toHaveBeenCalledWith(USER_ID, CHANNEL_ID);
});
it('increments the mention count when the badge invalidation fails', async () => {
const incrementReadStateMentions = vi.fn().mockResolvedValue(makeReadState(CHANNEL_ID, MESSAGE_ID, 1));
const repository = {incrementReadStateMentions} as unknown as IReadStateRepository;
const gatewayService = {
invalidatePushBadgeCount: vi.fn().mockRejectedValue(new BadGatewayError()),
} as unknown as IGatewayService;
const service = new ReadStateService(repository, gatewayService);
await expect(
service.incrementMentionCount({userId: USER_ID, channelId: CHANNEL_ID, messageId: MESSAGE_ID}),
).resolves.toBeUndefined();
expect(incrementReadStateMentions).toHaveBeenCalledTimes(1);
});
it('returns the bulk acknowledged states when the badge invalidation fails', async () => {
it('returns the bulk acknowledged states when clearing push notifications fails', async () => {
const updated = [makeReadState(CHANNEL_ID, MESSAGE_ID)];
const repository = {
bulkAckMessages: vi.fn().mockResolvedValue(updated),
} as unknown as IReadStateRepository;
const gatewayService = {
invalidatePushBadgeCount: vi.fn().mockRejectedValue(new BadGatewayError()),
clearPushChannelNotifications: vi.fn().mockResolvedValue(undefined),
clearPushChannelNotifications: vi.fn().mockRejectedValue(new BadGatewayError()),
dispatchPresence: vi.fn().mockResolvedValue(undefined),
} as unknown as IGatewayService;
const service = new ReadStateService(repository, gatewayService);
@@ -34,7 +34,6 @@ export class ReadStateService {
undefined,
manual ?? false,
);
await this.invalidatePushBadgeCount(userId);
if (!silent) {
await this.clearPushChannelNotifications({userId, channelId, messageId});
}
@@ -115,7 +114,6 @@ export class ReadStateService {
try {
const updatedReadStates = await this.repository.bulkAckMessages(userId, readStates);
const readStatesByChannel = new Map(updatedReadStates.map((readState) => [readState.channelId, readState]));
await this.invalidatePushBadgeCount(userId);
await Promise.all(
readStates.map(({channelId, messageId}) =>
Promise.all([
@@ -143,11 +141,6 @@ export class ReadStateService {
}
}
async deleteReadState({userId, channelId}: {userId: UserID; channelId: ChannelID}): Promise<void> {
await this.repository.deleteReadState(userId, channelId);
await this.invalidatePushBadgeCount(userId);
}
async incrementMentionCount({
userId,
channelId,
@@ -157,11 +150,7 @@ export class ReadStateService {
channelId: ChannelID;
messageId: MessageID;
}): Promise<void> {
const readState = await this.repository.incrementReadStateMentions(userId, channelId, messageId, 1);
if (readState == null) {
return;
}
await this.invalidatePushBadgeCount(userId);
await this.repository.incrementReadStateMentions(userId, channelId, messageId, 1);
}
async bulkIncrementMentionCounts(
@@ -175,15 +164,7 @@ export class ReadStateService {
return;
}
try {
const appliedUpdates = await this.repository.bulkIncrementMentionCounts(updates);
const uniqueUserIds = Array.from(new Set(appliedUpdates.map((update) => update.userId)));
if (uniqueUserIds.length === 0) {
return;
}
await this.gatewayService.invalidatePushBadgeCounts({userIds: uniqueUserIds}).catch((error) => {
Logger.error({userCount: uniqueUserIds.length, error}, 'Failed to invalidate push badge counts');
return null;
});
await this.repository.bulkIncrementMentionCounts(updates);
} catch (error) {
Logger.error({error}, 'Bulk increment mention counts failed');
throw error;
@@ -196,13 +177,6 @@ export class ReadStateService {
await this.dispatchPinsAck({userId, channelId, timestamp});
}
private async invalidatePushBadgeCount(userId: UserID): Promise<void> {
await this.gatewayService.invalidatePushBadgeCount({userId}).catch((error) => {
Logger.error({userId: userId.toString(), error}, 'Failed to invalidate push badge count');
return null;
});
}
private async dispatchMessageAck(params: {
userId: UserID;
channelId: ChannelID;
+49 -14
View File
@@ -1,34 +1,69 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {Config} from '@app/api/Config';
import {parseIpBanEntry, tryParseSingleIp} from '@app/api/utils/IpRangeUtils';
import type {IpAddressFamily} from '@fluxer/ip_utils/src/IpAddress';
import {getSameIpDecisionKey} from '@fluxer/ip_utils/src/IpAddress';
let exemptDecisionKeys: ReadonlySet<string> | null = null;
interface ExemptRange {
family: IpAddressFamily;
start: bigint;
end: bigint;
}
function getExemptDecisionKeys(): ReadonlySet<string> {
if (exemptDecisionKeys) {
return exemptDecisionKeys;
interface IpBanExemptions {
decisionKeys: ReadonlySet<string>;
ranges: ReadonlyArray<ExemptRange>;
}
let exemptions: IpBanExemptions | null = null;
function getExemptions(): IpBanExemptions {
if (exemptions) {
return exemptions;
}
const keys = new Set<string>();
for (const ip of Config.ipBanExemptIps) {
const key = getSameIpDecisionKey(ip);
if (!key) {
throw new Error(`Invalid IP ban exemption in API config: ${ip}`);
const decisionKeys = new Set<string>();
const ranges: Array<ExemptRange> = [];
for (const entry of Config.ipBanExemptIps) {
if (entry.includes('/')) {
const range = parseIpBanEntry(entry);
if (range?.type !== 'range') {
throw new Error(`Invalid IP ban exemption in API config: ${entry}`);
}
ranges.push({family: range.family, start: range.start, end: range.end});
continue;
}
keys.add(key);
const key = getSameIpDecisionKey(entry);
if (!key) {
throw new Error(`Invalid IP ban exemption in API config: ${entry}`);
}
decisionKeys.add(key);
}
exemptDecisionKeys = keys;
return keys;
exemptions = {decisionKeys, ranges};
return exemptions;
}
export function isIpBanExempt(ip: string | null | undefined): boolean {
if (!ip) {
return false;
}
const {decisionKeys, ranges} = getExemptions();
const key = getSameIpDecisionKey(ip);
return key !== null && getExemptDecisionKeys().has(key);
if (key !== null && decisionKeys.has(key)) {
return true;
}
if (ranges.length === 0) {
return false;
}
const parsed = tryParseSingleIp(ip);
if (!parsed) {
return false;
}
return ranges.some(
(range) => range.family === parsed.family && parsed.value >= range.start && parsed.value <= range.end,
);
}
export function resetIpBanExemptionsForTesting(): void {
exemptDecisionKeys = null;
exemptions = null;
}
@@ -0,0 +1,47 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {getConfig} from '@app/api/Config';
import {isIpBanExempt, resetIpBanExemptionsForTesting} from '@app/api/risk/IpBanExemptions';
import {afterEach, beforeEach, describe, expect, it} from 'vitest';
describe('isIpBanExempt', () => {
let originalExemptIps: Array<string>;
beforeEach(() => {
const config = getConfig();
originalExemptIps = config.ipBanExemptIps;
config.ipBanExemptIps = ['198.51.100.7', '2001:db8:6::', '2001:db8:1200:1000::/56', '203.0.113.0/24'];
resetIpBanExemptionsForTesting();
});
afterEach(() => {
getConfig().ipBanExemptIps = originalExemptIps;
resetIpBanExemptionsForTesting();
});
it('matches a bare IPv4 address exactly', () => {
expect(isIpBanExempt('198.51.100.7')).toBe(true);
expect(isIpBanExempt('198.51.100.8')).toBe(false);
});
it('matches a bare IPv6 address on its /64', () => {
expect(isIpBanExempt('2001:db8:6::abcd')).toBe(true);
expect(isIpBanExempt('2001:db8:7::1')).toBe(false);
});
it('matches every address inside a CIDR range', () => {
expect(isIpBanExempt('2001:db8:1200:1000::1')).toBe(true);
expect(isIpBanExempt('2001:db8:1200:10ff:ffff:ffff:ffff:ffff')).toBe(true);
expect(isIpBanExempt('2001:db8:1200:1100::1')).toBe(false);
expect(isIpBanExempt('2001:db8:1200:fff::1')).toBe(false);
expect(isIpBanExempt('203.0.113.200')).toBe(true);
expect(isIpBanExempt('::ffff:203.0.113.200')).toBe(true);
expect(isIpBanExempt('203.0.114.1')).toBe(false);
});
it('does not match empty or unparsable input', () => {
expect(isIpBanExempt(null)).toBe(false);
expect(isIpBanExempt('')).toBe(false);
expect(isIpBanExempt('not-an-ip')).toBe(false);
});
});
+1 -9
View File
@@ -99,7 +99,6 @@ import {RateLimitError} from '@fluxer/errors/src/domains/core/RateLimitError';
import {UnauthorizedError} from '@fluxer/errors/src/domains/core/UnauthorizedError';
import {UnknownGuildError} from '@fluxer/errors/src/domains/guild/UnknownGuildError';
import {UnknownUserError} from '@fluxer/errors/src/domains/user/UnknownUserError';
import {pushServiceDeliveryEnrols} from '@fluxer/schema/src/domains/admin/PushServiceDeliverySchemas';
import type {ChannelResponse} from '@fluxer/schema/src/domains/channel/ChannelSchemas';
import type {VoiceStateResponse} from '@fluxer/schema/src/domains/gateway/GatewaySchemas';
import type {GuildMemberResponse} from '@fluxer/schema/src/domains/guild/GuildMemberSchemas';
@@ -433,13 +432,6 @@ export class RpcService {
}),
};
case 'send_apns_push': {
const deliveryConfig = await this.instanceConfigRepository.getPushServiceDeliveryConfig();
if (pushServiceDeliveryEnrols(deliveryConfig, request.user_id.toString())) {
Logger.warn(
{userId: request.user_id.toString(), configVersion: deliveryConfig.config_version},
'push service delivery path mismatch',
);
}
const result = await sendApnsPush({
userId: request.user_id.toString(),
subscriptionId: request.subscription_id,
@@ -646,7 +638,7 @@ export class RpcService {
};
}
case 'get_push_service_delivery_config': {
const config = await this.instanceConfigRepository.getPushServiceDeliveryConfig();
const config = await this.instanceConfigRepository.getLegacyPushServiceDeliveryWire();
return {
type: 'get_push_service_delivery_config',
data: {config},
@@ -795,12 +795,6 @@ export class NoopGatewayService extends IGatewayService {
async dispatchPresence(_params: {userId: UserID; event: GatewayDispatchEvent; data: unknown}): Promise<void> {}
async invalidatePushBadgeCount(_params: {userId: UserID}): Promise<void> {}
async invalidatePushBadgeCounts(_params: {userIds: Array<UserID>}): Promise<void> {}
async invalidatePushSubscriptions(_params: {userId: UserID}): Promise<void> {}
async clearPushChannelNotifications(_params: {
userId: UserID;
channelId: ChannelID;
+7 -2
View File
@@ -2,7 +2,9 @@
import {Config} from '@app/api/Config';
import type {UserRow} from '@app/api/database/types/UserTypes';
import {resolveExperimentTargeting} from '@app/api/experiment/ExperimentTargeting';
import {getCachedInstancePremiumMode} from '@app/api/limits/InstancePremiumModeCache';
import {getInstanceConfigRepository} from '@app/api/middleware/ServiceSingletons';
import type {User} from '@app/api/models/User';
import {accountPolicyContactHasCapability} from '@app/api/risk/AccountPolicyService';
import {getCachedDeferredPhoneGateEnabled} from '@app/api/risk/DeferredPhoneGateCache';
@@ -13,6 +15,7 @@ import {
SuspiciousActivityFlags,
UserFlags,
} from '@fluxer/constants/src/UserConstants';
import {resolveProfileTimezoneAssignment} from '@fluxer/schema/src/domains/admin/ProfileTimezoneSchemas';
import type {RequiredAction} from '@fluxer/schema/src/domains/user/UserResponseSchemas';
import {ms} from 'itty-time';
@@ -289,6 +292,8 @@ export function isBugHunterBotUser(user: Pick<User, 'flags' | 'isBot'>): boolean
return user.isBot && (user.flags & UserFlags.BUG_HUNTER) !== 0n;
}
export function canUseProfileTimezone(user: Pick<PremiumCheckable, 'flags'>): boolean {
return (user.flags & UserFlags.STAFF) !== 0n;
export async function canUseProfileTimezone(user: User): Promise<boolean> {
const config = await getInstanceConfigRepository().getProfileTimezoneConfig();
const targeting = await resolveExperimentTargeting(user, [config]);
return resolveProfileTimezoneAssignment(config, user.id.toString(), targeting).enabled;
}
+3 -8
View File
@@ -10,7 +10,7 @@ import type {User} from '@app/api/models/User';
import type {UserGuildSettings} from '@app/api/models/UserGuildSettings';
import type {UserSettings} from '@app/api/models/UserSettings';
import type {WebAuthnCredential} from '@app/api/models/WebAuthnCredential';
import {canUseProfileTimezone, getRequiredActions} from '@app/api/user/UserHelpers';
import {getRequiredActions} from '@app/api/user/UserHelpers';
import {canUserAccessNsfwContent} from '@app/api/utils/AgeUtils';
import type {ChannelMessageNotifications} from '@fluxer/constants/src/NotificationConstants';
import {
@@ -121,7 +121,6 @@ export function mapUserToPrivateResponse(user: User): UserPrivateResponse {
const isStaff = (user.flags & UserFlags.STAFF) !== 0n;
const partialResponse = mapUserToPartialResponse(user);
const isActuallyPremium = user.isPremium();
const includeProfileTimezone = canUseProfileTimezone(user);
const traitSet = new Set<string>();
for (const trait of user.traits ?? []) {
if (trait && trait !== 'premium') {
@@ -147,12 +146,8 @@ export function mapUserToPrivateResponse(user: User): UserPrivateResponse {
bio: user.bio,
pronouns: user.pronouns,
accent_color: user.accentColor,
...(includeProfileTimezone
? {
timezone: user.timezone,
timezone_privacy_flags: user.timezonePrivacyFlags,
}
: {}),
timezone: user.timezone,
timezone_privacy_flags: user.timezonePrivacyFlags,
banner: stripBannerForUser(user),
banner_color: user.bannerColor,
mfa_enabled: authenticatorTypes.length > 0,
@@ -127,10 +127,10 @@ export class UserAccountLookupService {
: await this.getProfileFieldPrivacyContext(userId, targetId);
const timezoneVisible =
!restrictProfile &&
canUseProfileTimezone(user) &&
user.timezone != null &&
profileFieldPrivacyContext != null &&
this.canViewProfileField(user.timezonePrivacyFlags, profileFieldPrivacyContext);
this.canViewProfileField(user.timezonePrivacyFlags, profileFieldPrivacyContext) &&
(await canUseProfileTimezone(user));
const [mutualFriends, mutualGuilds, connections] = await Promise.all([
withMutualFriends && userId !== targetId ? this.getMutualFriends(userId, targetId) : undefined,
withMutualGuilds && userId !== targetId ? this.getMutualGuilds(userId, targetId) : undefined,
@@ -83,7 +83,8 @@ export class UserAccountProfileService {
if (data.accent_color !== undefined) {
await this.processAccentColorUpdate({user, accentColor: data.accent_color, updates});
}
const canUpdateProfileTimezone = canUseProfileTimezone(user);
const canUpdateProfileTimezone =
(data.timezone !== undefined || data.timezone_privacy_flags !== undefined) && (await canUseProfileTimezone(user));
if (canUpdateProfileTimezone && data.timezone !== undefined) {
const nextTimezone = this.processTimezoneUpdate({user, timezone: data.timezone, updates});
if (nextTimezone !== null && user.timezone === null && data.timezone_privacy_flags === undefined) {
@@ -85,11 +85,14 @@ function hasProfileCustomizationUpdate(data: UserUpdatePayload): boolean {
return EMAIL_VERIFICATION_REQUIRED_PROFILE_UPDATE_FIELDS.some((field) => data[field] !== undefined);
}
function stripUnauthorizedProfileTimezoneUpdate(
async function stripUnauthorizedProfileTimezoneUpdate(
user: User,
body: UserUpdateWithVerificationRequest,
): UserUpdateWithVerificationRequest {
if (canUseProfileTimezone(user)) {
): Promise<UserUpdateWithVerificationRequest> {
if (body.timezone === undefined && body.timezone_privacy_flags === undefined) {
return body;
}
if (await canUseProfileTimezone(user)) {
return body;
}
const {timezone: _timezone, timezone_privacy_flags: _timezonePrivacyFlags, ...rest} = body;
@@ -187,7 +190,7 @@ export class UserAccountRequestService {
const {ctx, body, authSession} = params;
let {user} = params;
const oldEmail = user.email;
const sanitizedBody = stripUnauthorizedProfileTimezoneUpdate(user, body);
const sanitizedBody = await stripUnauthorizedProfileTimezoneUpdate(user, body);
const {
mfa_method: _mfaMethod,
mfa_code: _mfaCode,
@@ -392,7 +392,6 @@ export class UserContentService {
provider_environment: null,
};
const subscription = await this.storeWebPushSubscription(data, originKind ?? null, installedApp === true);
await this.gatewayService.invalidatePushSubscriptions({userId});
return subscription;
}
@@ -468,7 +467,6 @@ export class UserContentService {
async deletePushSubscription(userId: UserID, subscriptionId: string): Promise<void> {
await this.userRepository.deletePushSubscription(userId, subscriptionId);
await this.gatewayService.invalidatePushSubscriptions({userId});
}
async rotatePushSubscription(params: {
@@ -504,7 +502,6 @@ export class UserContentService {
provider_environment: null,
};
const subscription = await this.storeWebPushSubscription(data, originKind ?? null, installedApp === true);
await this.gatewayService.invalidatePushSubscriptions({userId});
return subscription;
}
@@ -530,7 +527,6 @@ export class UserContentService {
provider_environment: providerEnvironment,
};
const subscription = await this.userRepository.createPushSubscription(data);
await this.gatewayService.invalidatePushSubscriptions({userId});
return subscription;
}
@@ -3,16 +3,16 @@
import {createTestAccount} from '@app/api/auth/tests/AuthTestUtils';
import {createFriendship} from '@app/api/channel/tests/ChannelTestUtils';
import {acceptInvite, createChannelInvite, createGuild, getChannel} from '@app/api/guild/tests/GuildTestUtils';
import {getInstanceConfigRepository} from '@app/api/middleware/ServiceSingletons';
import {type ApiTestHarness, createApiTestHarness} from '@app/api/test/ApiTestHarness';
import {HTTP_STATUS} from '@app/api/test/TestConstants';
import {createBuilder} from '@app/api/test/TestRequestBuilder';
import {
ProfileFieldPrivacyFlags,
type ProfilePrivacyLevel,
ProfilePrivacyLevels,
UserFlags,
} from '@fluxer/constants/src/UserConstants';
import {getCurrentTimeZoneOffsetMinutes} from '@fluxer/date_utils/src/TimeZoneUtils';
import {DEFAULT_PROFILE_TIMEZONE_CONFIG} from '@fluxer/schema/src/domains/admin/ProfileTimezoneSchemas';
import type {UserPrivateResponse, UserProfileFullResponse} from '@fluxer/schema/src/domains/user/UserResponseSchemas';
import {afterAll, beforeAll, beforeEach, describe, expect, it} from 'vitest';
@@ -31,12 +31,12 @@ async function updateProfileTimezone(
return createBuilder<UserPrivateResponse>(harness, token).patch('/users/@me').body(data).execute();
}
async function setUserFlags(harness: ApiTestHarness, userId: string, flags: bigint): Promise<void> {
await createBuilder(harness, '')
.patch(`/test/users/${userId}/flags`)
.body({flags: flags.toString()})
.expect(HTTP_STATUS.OK)
.execute();
async function setProfileTimezoneUsers(userIds: Array<string>): Promise<void> {
await getInstanceConfigRepository().setProfileTimezoneConfig({
...DEFAULT_PROFILE_TIMEZONE_CONFIG,
enabled: true,
included_user_ids: userIds,
});
}
async function updateProfilePrivacy(
@@ -76,7 +76,7 @@ describe('User Profile Timezone Visibility', () => {
it('defaults timezone visibility to everyone when a timezone is set', async () => {
const targetAccount = await createTestAccount(harness);
const viewerAccount = await createTestAccount(harness);
await setUserFlags(harness, targetAccount.userId, UserFlags.STAFF);
await setProfileTimezoneUsers([targetAccount.userId]);
const updated = await updateProfileTimezone(harness, targetAccount.token, {timezone: TEST_TIMEZONE});
expect(updated.timezone).toBe(TEST_TIMEZONE);
expect(updated.timezone_privacy_flags).toBe(ProfileFieldPrivacyFlags.EVERYONE);
@@ -86,7 +86,7 @@ describe('User Profile Timezone Visibility', () => {
});
it('restores default timezone visibility when a timezone is set again without explicit flags', async () => {
const targetAccount = await createTestAccount(harness);
await setUserFlags(harness, targetAccount.userId, UserFlags.STAFF);
await setProfileTimezoneUsers([targetAccount.userId]);
await updateProfileTimezone(harness, targetAccount.token, {
timezone: TEST_TIMEZONE,
timezone_privacy_flags: 0,
@@ -97,7 +97,7 @@ describe('User Profile Timezone Visibility', () => {
});
it('hides timezone from the public profile when privacy flags are unset', async () => {
const targetAccount = await createTestAccount(harness);
await setUserFlags(harness, targetAccount.userId, UserFlags.STAFF);
await setProfileTimezoneUsers([targetAccount.userId]);
await updateProfileTimezone(harness, targetAccount.token, {
timezone: TEST_TIMEZONE,
timezone_privacy_flags: 0,
@@ -109,7 +109,7 @@ describe('User Profile Timezone Visibility', () => {
const targetAccount = await createTestAccount(harness);
const friendAccount = await createTestAccount(harness);
const guildMemberAccount = await createTestAccount(harness);
await setUserFlags(harness, targetAccount.userId, UserFlags.STAFF);
await setProfileTimezoneUsers([targetAccount.userId]);
await updateProfileTimezone(harness, targetAccount.token, {
timezone: TEST_TIMEZONE,
timezone_privacy_flags: ProfileFieldPrivacyFlags.FRIENDS,
@@ -125,7 +125,7 @@ describe('User Profile Timezone Visibility', () => {
const targetAccount = await createTestAccount(harness);
const friendAccount = await createTestAccount(harness);
const guildMemberAccount = await createTestAccount(harness);
await setUserFlags(harness, targetAccount.userId, UserFlags.STAFF);
await setProfileTimezoneUsers([targetAccount.userId]);
await updateProfileTimezone(harness, targetAccount.token, {
timezone: TEST_TIMEZONE,
timezone_privacy_flags: ProfileFieldPrivacyFlags.MUTUAL_GUILDS,
@@ -140,7 +140,7 @@ describe('User Profile Timezone Visibility', () => {
it('hides timezone when full profile privacy restricts the viewer', async () => {
const targetAccount = await createTestAccount(harness);
const guildMemberAccount = await createTestAccount(harness);
await setUserFlags(harness, targetAccount.userId, UserFlags.STAFF);
await setProfileTimezoneUsers([targetAccount.userId]);
await updateProfileTimezone(harness, targetAccount.token, {timezone: TEST_TIMEZONE});
await updateProfilePrivacy(harness, targetAccount.token, ProfilePrivacyLevels.FRIENDS_ONLY);
await createSharedGuild(harness, targetAccount.token, guildMemberAccount.token);
@@ -148,23 +148,47 @@ describe('User Profile Timezone Visibility', () => {
expect(profile.profile_limited).toBe(true);
expect(profile.timezone_offset).toBeNull();
});
it('ignores profile timezone updates from non-staff users', async () => {
it('ignores profile timezone updates from users outside the experiment', async () => {
const targetAccount = await createTestAccount(harness, {skipEmailVerification: true});
const updated = await updateProfileTimezone(harness, targetAccount.token, {timezone: TEST_TIMEZONE});
expect(updated).not.toHaveProperty('timezone');
expect(updated).not.toHaveProperty('timezone_privacy_flags');
const updated = await updateProfileTimezone(harness, targetAccount.token, {
timezone: TEST_TIMEZONE,
timezone_privacy_flags: ProfileFieldPrivacyFlags.FRIENDS,
});
expect(updated.timezone).toBeNull();
expect(updated.timezone_privacy_flags).toBe(ProfileFieldPrivacyFlags.EVERYONE);
});
it('hides stored profile timezone after the user no longer has the staff flag', async () => {
it('ignores profile timezone updates from users excluded from a full rollout', async () => {
const targetAccount = await createTestAccount(harness, {skipEmailVerification: true});
await getInstanceConfigRepository().setProfileTimezoneConfig({
...DEFAULT_PROFILE_TIMEZONE_CONFIG,
enabled: true,
rollout_basis_points: 10000,
excluded_user_ids: [targetAccount.userId],
});
const updated = await updateProfileTimezone(harness, targetAccount.token, {timezone: TEST_TIMEZONE});
expect(updated.timezone).toBeNull();
});
it('lets members of an included guild set and show a timezone', async () => {
const targetAccount = await createTestAccount(harness);
const viewerAccount = await createTestAccount(harness);
await setUserFlags(harness, targetAccount.userId, UserFlags.STAFF);
const guild = await createGuild(harness, targetAccount.token, 'Timezone Rollout Guild');
await getInstanceConfigRepository().setProfileTimezoneConfig({
...DEFAULT_PROFILE_TIMEZONE_CONFIG,
enabled: true,
included_guild_ids: [guild.id],
});
const updated = await updateProfileTimezone(harness, targetAccount.token, {timezone: TEST_TIMEZONE});
expect(updated.timezone).toBe(TEST_TIMEZONE);
await createFriendship(harness, targetAccount, viewerAccount);
const profile = await getUserProfile(harness, viewerAccount.token, targetAccount.userId);
expect(profile.timezone_offset).toBe(TEST_TIMEZONE_OFFSET);
});
it('hides stored profile timezone after the user leaves the experiment', async () => {
const targetAccount = await createTestAccount(harness);
const viewerAccount = await createTestAccount(harness);
await setProfileTimezoneUsers([targetAccount.userId]);
await updateProfileTimezone(harness, targetAccount.token, {timezone: TEST_TIMEZONE});
await setUserFlags(harness, targetAccount.userId, 0n);
const currentUser = await createBuilder<UserPrivateResponse>(harness, targetAccount.token)
.get('/users/@me')
.execute();
expect(currentUser).not.toHaveProperty('timezone');
expect(currentUser).not.toHaveProperty('timezone_privacy_flags');
await setProfileTimezoneUsers([]);
await createFriendship(harness, targetAccount, viewerAccount);
const profile = await getUserProfile(harness, viewerAccount.token, targetAccount.userId);
expect(profile.timezone_offset).toBeNull();
@@ -9,6 +9,7 @@ import {
createGroupDmChannel,
createGuild,
getChannel,
updateUserSettings,
} from '@app/api/channel/tests/ChannelTestUtils';
import {ensureSessionStarted} from '@app/api/message/tests/MessageTestUtils';
import {type ApiTestHarness, createApiTestHarness} from '@app/api/test/ApiTestHarness';
@@ -39,6 +40,8 @@ describe('Voice Call Ringing', () => {
const user2 = await createTestAccount(harness);
await ensureSessionStarted(harness, user1.token);
await ensureSessionStarted(harness, user2.token);
await updateUserSettings(harness, user1.token, {default_guilds_restricted: false});
await updateUserSettings(harness, user2.token, {default_guilds_restricted: false});
const guild = await createGuild(harness, user1.token, 'Mutual Guild');
const invite = await createChannelInvite(harness, user1.token, guild.system_channel_id!);
await acceptInvite(harness, user2.token, invite.code);
+1 -1
View File
@@ -25,7 +25,7 @@
<!--{{FLUXER_BOOTSTRAP}}-->
<script nonce="{{CSP_NONCE_PLACEHOLDER}}">(function(){try{var loc=window.location;if(loc.pathname==='/'){var target='/channels/@me';if(loc.search)target+=loc.search;if(loc.hash)target+=loc.hash;loc.replace(target);}}catch(e){}})();</script>
<script nonce="{{CSP_NONCE_PLACEHOLDER}}">(function(){try{var t=localStorage.getItem('theme');if(t){document.documentElement.classList.add('theme-'+t)}}catch{}})()</script>
<script nonce="{{CSP_NONCE_PLACEHOLDER}}">(function(){try{if(typeof WebSocket!=='function')return;var t=localStorage.getItem('token');if(!t||t==='undefined'||t==='null')return;var b=window.__FLUXER_BOOTSTRAP__;var g=b&&b.instance&&b.instance.endpoints&&b.instance.endpoints.gateway;if(!g)return;var u=new URL(g);u.searchParams.set('v','1');u.searchParams.set('encoding','json');u.searchParams.set('compress','zstd-stream');u.searchParams.set('stream','1');var url=u.toString();var ws=new WebSocket(url);ws.binaryType='arraybuffer';var s={open:false,url:url,messages:[],startedAt:Date.now()};ws.onopen=function(){s.open=true};ws.onmessage=function(e){s.messages.push(e)};ws.onclose=ws.onerror=function(){window.__FLUXER_FAST_CONNECT__=null};window.__FLUXER_FAST_CONNECT__={ws:ws,state:s};setTimeout(function(){var h=window.__FLUXER_FAST_CONNECT__;if(h&&h.ws===ws){window.__FLUXER_FAST_CONNECT__=null;try{ws.close(1000,'Fast connect unclaimed')}catch(e){}}},30000)}catch(e){}})()</script>
<script nonce="{{CSP_NONCE_PLACEHOLDER}}">(function(){try{if(typeof WebSocket!=='function')return;if(window.location.pathname.indexOf('/migrate/')===0)return;var t=localStorage.getItem('token');if(!t||t==='undefined'||t==='null')return;var b=window.__FLUXER_BOOTSTRAP__;var g=b&&b.instance&&b.instance.endpoints&&b.instance.endpoints.gateway;if(!g)return;var u=new URL(g);u.searchParams.set('v','1');u.searchParams.set('encoding','json');u.searchParams.set('compress','zstd-stream');u.searchParams.set('stream','1');var url=u.toString();var ws=new WebSocket(url);ws.binaryType='arraybuffer';var s={open:false,url:url,messages:[],startedAt:Date.now()};ws.onopen=function(){s.open=true};ws.onmessage=function(e){s.messages.push(e)};ws.onclose=ws.onerror=function(){window.__FLUXER_FAST_CONNECT__=null};window.__FLUXER_FAST_CONNECT__={ws:ws,state:s};setTimeout(function(){var h=window.__FLUXER_FAST_CONNECT__;if(h&&h.ws===ws){window.__FLUXER_FAST_CONNECT__=null;try{ws.close(1000,'Fast connect unclaimed')}catch(e){}}},30000)}catch(e){}})()</script>
</head>
<body>
<div id="root"></div>
+1
View File
@@ -201,6 +201,7 @@
"@sapphi-red/web-noise-suppressor": "catalog:",
"@simplewebauthn/browser": "catalog:",
"@tanstack/react-virtual": "^3.14.13",
"altcha-lib": "catalog:",
"animejs": "4.5.0",
"bowser": "catalog:",
"clsx": "catalog:",
+7
View File
@@ -508,6 +508,13 @@ export default () => {
priority: 43,
reuseExistingChunk: true,
},
i18n: {
test: /[\\/]node_modules[\\/]@lingui[\\/]/,
name: 'i18n',
priority: 42,
reuseExistingChunk: true,
enforce: true,
},
reactAria: {
test: /[\\/]node_modules[\\/]react-aria-components[\\/]/,
name: 'react-aria',
@@ -557,10 +557,6 @@ const SKELETON_SURFACE_INVARIANTS: ReadonlyArray<SkeletonSurfaceInvariant> = [
file: 'src/features/channel/components/MemberListSkeleton.tsx',
counts: {'style={MEMBER_LIST_METRICS_STYLE}': 2},
},
{
file: 'src/features/channel/components/textarea/InputWrapper.module.css',
requires: ['.composerRoot:has(.statusTypingSlot)::before'],
},
{
file: 'src/features/app/components/layout/GuildsLayout.module.css',
requires: [
+204
View File
@@ -0,0 +1,204 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import 'urlpattern-polyfill';
import {bootstrapSyntheticHistory} from '@app/app/HistoryBootstrap';
import reactiveI18n, {initI18n} from '@app/app/I18n';
import {Routes} from '@app/app/Routes';
import {AppErrorBoundary} from '@app/features/app/components/AppErrorBoundary';
import {ErrorFallback} from '@app/features/app/components/ErrorFallback';
import type {DomainMigrationSide} from '@app/features/app/domain_migration/DomainMigrationCore';
import {installSelfXssNotice} from '@app/features/devtools/utils/SelfXssNotice';
import {AppI18nProvider} from '@app/features/i18n/components/AppI18nProvider';
import {installLocaleSwitchWatchdog} from '@app/features/i18n/utils/LocaleSwitchWatchdog';
import {installTranslationDomGuard} from '@app/features/i18n/utils/TranslationDomGuard';
import {installScrollRestoration} from '@app/features/platform/components/router/ScrollRestoration';
import {Logger} from '@app/features/platform/utils/AppLogger';
import {
getFormattedClientInfo,
getFormattedClientInfoSync,
installFluxerConfigDebugApi,
preloadClientInfo,
} from '@app/features/platform/utils/ClientInfo';
import {loadLazyModule} from '@app/features/platform/utils/LazyModuleLoader';
import {scheduleNonLatinScriptFaces} from '@app/features/theme/fonts/ScriptFontLoader';
import {installVoiceSubscriptionDebugApi} from '@app/features/voice/diagnostics/VoiceSubscriptionDebugApi';
import {PASSKEY_BRIDGE_PATH} from '@fluxer/constants/src/PasskeyConstants';
import {i18n} from '@lingui/core';
import type {ReactNode} from 'react';
import ReactDOM from 'react-dom/client';
const logger = new Logger('index');
if (typeof window !== 'undefined' && window.history) {
bootstrapSyntheticHistory();
installScrollRestoration();
}
installFluxerConfigDebugApi();
installVoiceSubscriptionDebugApi();
function createRoot(): ReactDOM.Root {
const container = document.getElementById('root');
if (!container) {
throw new Error('Missing #root element');
}
return ReactDOM.createRoot(container);
}
function mountRoot(content: ReactNode, dataFlxScope: string): void {
installTranslationDomGuard();
createRoot().render(
<AppErrorBoundary
fallback={(error) => (
<AppI18nProvider i18n={i18n}>
<ErrorFallback error={error ?? undefined} data-flx={`${dataFlxScope}.error-fallback`} />
</AppI18nProvider>
)}
data-flx={`${dataFlxScope}.app-error-boundary`}
>
{content}
</AppErrorBoundary>,
);
}
async function logClientInfo(): Promise<void> {
try {
const info = await getFormattedClientInfo();
logger.info(`[CLIENT INFO] ${info}`);
} catch (error) {
logger.warn('Failed to load full client info:', error);
logger.info(`[CLIENT INFO] ${getFormattedClientInfoSync()}`);
}
}
async function preloadMarkdownParser(): Promise<void> {
try {
const {preloadMarkdownParserWasm} = await loadLazyModule(
() => import('@app/features/messaging/utils/markdown/parser/MarkdownParserWasm'),
);
await preloadMarkdownParserWasm();
} catch (error) {
logger.warn('Failed to preload markdown parser:', error);
}
}
async function bootstrapThemeStudio(): Promise<void> {
const markdownParserReady = preloadMarkdownParser();
const [{ThemeStudioStandaloneApp}, {setupHttp}, {default: AccountManager}] = await Promise.all([
loadLazyModule(() => import('@app/features/theme_studio/ThemeStudioStandaloneApp')),
loadLazyModule(() => import('@app/app/SetupHttp')),
loadLazyModule(() => import('@app/features/auth/state/AccountManager')),
]);
await AccountManager.bootstrap();
setupHttp();
await markdownParserReady;
mountRoot(
<AppI18nProvider i18n={i18n}>
<ThemeStudioStandaloneApp data-flx="index.render-theme-studio.theme-studio-standalone-app" />
</AppI18nProvider>,
'index.render-theme-studio',
);
}
export async function runPasskeyBridge(side: DomainMigrationSide): Promise<void> {
const hash = window.location.hash;
const opensInOwnTab = window.history.length === 1;
window.history.replaceState(null, '', PASSKEY_BRIDGE_PATH);
const [{PasskeyBridgePage}] = await Promise.all([
loadLazyModule(() => import('@app/features/auth/passkey_migration/PasskeyBridgePage')),
initI18n(),
]);
mountRoot(
<AppI18nProvider i18n={i18n}>
<PasskeyBridgePage
side={side}
hash={hash}
opensInOwnTab={opensInOwnTab}
data-flx="index.passkey-bridge.passkey-bridge-page"
/>
</AppI18nProvider>,
'index.passkey-bridge',
);
}
async function probeSignedOutDomainMigration(): Promise<void> {
try {
const {probeSignedOutDeviceEnrollment} = await loadLazyModule(
() => import('@app/features/app/domain_migration/DomainMigrationTrigger'),
);
probeSignedOutDeviceEnrollment();
} catch (error) {
logger.warn('Failed to start the signed-out domain migration probe:', error);
}
}
async function bootstrapApp(): Promise<void> {
const markdownParserReady = preloadMarkdownParser();
const [
{App},
{setupHttp},
{default: CaptchaInterceptor},
{initializeEmojiParser},
{registerServiceWorker},
{default: AccountManager},
{default: ChannelDisplayName},
_channelFrecency,
_geoIp,
{default: Keybind},
{default: NewDeviceMonitoring},
{default: Notification},
{default: QuickSwitcher},
_runtimeConfig,
{default: StatusPage},
{installMigratedDeviceRemap},
] = await Promise.all([
loadLazyModule(() => import('@app/app/App')),
loadLazyModule(() => import('@app/app/SetupHttp')),
loadLazyModule(() => import('@app/features/auth/components/CaptchaInterceptor')),
loadLazyModule(() => import('@app/features/messaging/utils/markdown/EmojiProviderSetup')),
loadLazyModule(() => import('@app/features/platform/service_worker/Register')),
loadLazyModule(() => import('@app/features/auth/state/AccountManager')),
loadLazyModule(() => import('@app/features/channel/state/ChannelDisplayName')),
loadLazyModule(() => import('@app/features/channel/state/ChannelFrecency')),
loadLazyModule(() => import('@app/features/app/state/GeoIP')),
loadLazyModule(() => import('@app/features/input/state/InputKeybind')),
loadLazyModule(() => import('@app/features/auth/state/NewDeviceMonitoring')),
loadLazyModule(() => import('@app/features/ui/state/Notification')),
loadLazyModule(() => import('@app/features/search/state/QuickSwitcher')),
loadLazyModule(() => import('@app/features/app/state/RuntimeConfig')),
loadLazyModule(() => import('@app/features/user/state/StatusPage')),
loadLazyModule(() => import('@app/features/app/domain_migration/DomainMigrationDeviceRemap')),
]);
void preloadClientInfo();
QuickSwitcher.setI18n(reactiveI18n);
ChannelDisplayName.setI18n(reactiveI18n);
Keybind.setI18n(reactiveI18n);
NewDeviceMonitoring.setI18n(reactiveI18n);
Notification.setI18n(reactiveI18n);
CaptchaInterceptor.setI18n(reactiveI18n);
void StatusPage.checkIncidents();
StatusPage.startPolling();
await Promise.all([AccountManager.bootstrap(), installMigratedDeviceRemap()]);
if (AccountManager.currentUserId === null) {
void probeSignedOutDomainMigration();
}
setupHttp();
initializeEmojiParser();
await markdownParserReady;
mountRoot(<App data-flx="index.bootstrap.app" />, 'index.bootstrap');
QuickSwitcher.preloadModal();
registerServiceWorker();
}
export async function runApp(): Promise<void> {
scheduleNonLatinScriptFaces();
await initI18n();
installLocaleSwitchWatchdog();
installSelfXssNotice();
void logClientInfo();
if (window.location.pathname === Routes.THEME_STUDIO) {
await bootstrapThemeStudio();
} else {
await bootstrapApp();
}
}
@@ -0,0 +1,25 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {initI18n} from '@app/app/I18n';
import {BootstrapErrorScreen} from '@app/features/app/components/BootstrapErrorScreen';
import {AppI18nProvider} from '@app/features/i18n/components/AppI18nProvider';
import {Logger} from '@app/features/platform/utils/AppLogger';
import {i18n} from '@lingui/core';
import ReactDOM from 'react-dom/client';
const logger = new Logger('index');
export async function reportBootstrapError(error: unknown): Promise<void> {
const normalized = error instanceof Error ? error : new Error(String(error));
logger.error('Failed to bootstrap app:', normalized);
const container = document.getElementById('root');
if (!container) {
throw new Error('Missing #root element');
}
await initI18n();
ReactDOM.createRoot(container).render(
<AppI18nProvider i18n={i18n}>
<BootstrapErrorScreen error={normalized} data-flx="index.bootstrap-error-screen" />
</AppI18nProvider>,
);
}
@@ -7,9 +7,12 @@ import {
PRODUCT_NAME,
} from '@app/features/app/config/I18nDisplayConstants';
import {TRY_AGAIN_DESCRIPTOR} from '@app/features/i18n/utils/CommonMessageDescriptors';
import AppStorage, {PRESERVED_RESET_STORAGE_KEYS} from '@app/features/platform/state/PersistentStorage';
import AppStorage, {
PRESERVED_RESET_STORAGE_KEY_PREFIXES,
PRESERVED_RESET_STORAGE_KEYS,
} from '@app/features/platform/state/PersistentStorage';
import {Button} from '@app/features/ui/button/Button';
import {FluxerIcon} from '@app/features/ui/components/icons/FluxerIcon';
import {APPLICATION_ICON_DESCRIPTOR, FluxerIconMark} from '@app/features/ui/components/icons/FluxerIconMark';
import {ExternalUrls} from '@fluxer/constants/src/ExternalUrls';
import {Trans, useLingui} from '@lingui/react/macro';
import type React from 'react';
@@ -25,12 +28,16 @@ export const BootstrapErrorScreen: React.FC<BootstrapErrorScreenProps> = ({error
window.location.reload();
}, []);
const handleReset = useCallback(() => {
AppStorage.clearExcept(PRESERVED_RESET_STORAGE_KEYS);
AppStorage.clearExcept(PRESERVED_RESET_STORAGE_KEYS, PRESERVED_RESET_STORAGE_KEY_PREFIXES);
window.location.reload();
}, []);
return (
<div className={styles.errorFallbackContainer} data-flx="app.bootstrap-error-screen.error-fallback-container">
<FluxerIcon className={styles.errorFallbackIcon} data-flx="app.bootstrap-error-screen.error-fallback-icon" />
<FluxerIconMark
aria-label={i18n._(APPLICATION_ICON_DESCRIPTOR, {productName: PRODUCT_NAME})}
className={styles.errorFallbackIcon}
data-flx="app.bootstrap-error-screen.error-fallback-icon"
/>
<div className={styles.errorFallbackContent} data-flx="app.bootstrap-error-screen.error-fallback-content">
<h1 className={styles.errorFallbackTitle} data-flx="app.bootstrap-error-screen.error-fallback-title">
<Trans>Failed to start</Trans>
@@ -3,7 +3,10 @@
import errorFallbackStyles from '@app/features/app/components/ErrorFallback.module.css';
import {NativeTitlebar} from '@app/features/app/components/layout/NativeTitlebar';
import {useNativePlatform} from '@app/features/app/hooks/useNativePlatform';
import AppStorage, {PRESERVED_RESET_STORAGE_KEYS} from '@app/features/platform/state/PersistentStorage';
import AppStorage, {
PRESERVED_RESET_STORAGE_KEY_PREFIXES,
PRESERVED_RESET_STORAGE_KEYS,
} from '@app/features/platform/state/PersistentStorage';
import {ensureLatestAssets} from '@app/features/platform/types/Versioning';
import {Logger} from '@app/features/platform/utils/AppLogger';
import {Button} from '@app/features/ui/button/Button';
@@ -154,7 +157,7 @@ export const ErrorFallback: React.FC<ErrorFallbackProps> = ({error}) => {
)}
<Button
onClick={() => {
AppStorage.clearExcept(PRESERVED_RESET_STORAGE_KEYS);
AppStorage.clearExcept(PRESERVED_RESET_STORAGE_KEYS, PRESERVED_RESET_STORAGE_KEY_PREFIXES);
location.reload();
}}
variant="danger"
@@ -233,6 +233,15 @@
text-align: center;
}
.noticeLink {
align-self: flex-start;
border-radius: 0.25rem;
color: var(--text-link);
font-size: 0.875rem;
line-height: 1.45;
text-decoration: underline;
}
.integrationFields {
display: flex;
flex-direction: column;
@@ -30,6 +30,7 @@ import {
MediaExpiryStep,
type PremiumMode,
PremiumStep,
PushRelayConsentStep,
type RegistrationMode,
RegistrationStep,
type ServiceAvailability,
@@ -459,6 +460,7 @@ export const SelfHostedSetupWizardGate = observer(() => {
youtube: false,
bluesky: false,
});
const [pushRelayConsentAccepted, setPushRelayConsentAccepted] = useState(false);
const [premiumMode, setPremiumMode] = useState<PremiumMode>('mirror');
const [assets, setAssets] = useState<ReadonlyArray<BrandingAssetState>>(() =>
BRANDING_ASSET_KINDS.map((kind) => ({kind, url: null, preview: null})),
@@ -549,6 +551,7 @@ export const SelfHostedSetupWizardGate = observer(() => {
clearStepNavigationLock();
setIntegrationDraft({...DEFAULT_INTEGRATION_DRAFT});
setMediaExpiryDraft({...DEFAULT_MEDIA_EXPIRY_DRAFT});
setPushRelayConsentAccepted(false);
setSmtpTesting(false);
setSmtpTestResult(null);
try {
@@ -569,6 +572,7 @@ export const SelfHostedSetupWizardGate = observer(() => {
setSingleCommunityEnabled(next.policy.single_community_enabled);
setDirectMessagesDisabled(next.policy.direct_messages_disabled);
setPremiumMode(next.policy.premium_mode);
setPushRelayConsentAccepted(next.push_relay.relay_consent_accepted);
setServiceSelection({
gif: next.policy.services_resolved.gif_enabled,
youtube: next.policy.services_resolved.youtube_enabled,
@@ -677,6 +681,7 @@ export const SelfHostedSetupWizardGate = observer(() => {
if (step === 'branding') return !productNameError;
if (step === 'community') return !singleCommunityNameError;
if (step === 'media_expiry') return isMediaExpiryStepValid(mediaExpiryDraft);
if (step === 'push_relay_consent') return true;
const integrationKind = wizardStepToIntegrationKind(step);
if (integrationKind) return isIntegrationStepValid(integrationKind, integrationDraft);
return true;
@@ -766,6 +771,10 @@ export const SelfHostedSetupWizardGate = observer(() => {
const nextConfig = await updateInstanceConfig({
integrations: buildIntegrationsPatch(integrationDraft),
media: buildMediaPatch(mediaExpiryDraft),
push_relay:
config.push_relay.relay_consent_accepted === pushRelayConsentAccepted
? undefined
: {relay_consent_accepted: pushRelayConsentAccepted},
registration: {mode: registrationMode},
app_public: {
branding: {
@@ -804,6 +813,7 @@ export const SelfHostedSetupWizardGate = observer(() => {
singleCommunityEnabled,
singleCommunityNameTrimmed,
directMessagesDisabled,
pushRelayConsentAccepted,
premiumMode,
serviceAvailability,
serviceSelection,
@@ -972,6 +982,14 @@ export const SelfHostedSetupWizardGate = observer(() => {
data-flx="app.setup.self-hosted-setup-wizard-gate.integration-step"
/>
)}
{step === 'push_relay_consent' && (
<PushRelayConsentStep
accepted={pushRelayConsentAccepted}
disabled={submitting}
onChange={setPushRelayConsentAccepted}
data-flx="app.setup.self-hosted-setup-wizard-gate.push-relay-consent-step"
/>
)}
{step === 'services' && (
<ServicesStep
available={serviceAvailability}
@@ -996,6 +1014,7 @@ export const SelfHostedSetupWizardGate = observer(() => {
singleCommunityEnabled={singleCommunityEnabled}
directMessagesDisabled={directMessagesDisabled}
attachmentExpiryEnabled={mediaExpiryDraft.enabled}
pushRelayConsentAccepted={pushRelayConsentAccepted}
premiumMode={premiumMode}
submitError={submitError}
data-flx="app.setup.self-hosted-setup-wizard-gate.finish-step"
@@ -17,6 +17,7 @@ export type WizardStep =
| 'integration_captcha'
| 'integration_email'
| 'integration_bluesky'
| 'push_relay_consent'
| 'services'
| 'premium'
| 'finish';
@@ -36,6 +37,7 @@ export const CONFIGURE_STEPS: ReadonlyArray<WizardStep> = [
'integration_captcha',
'integration_email',
'integration_bluesky',
'push_relay_consent',
'services',
'premium',
'finish',
@@ -13,6 +13,7 @@ import {ColorPickerField} from '@app/features/ui/components/form/ColorPickerFiel
import {Input} from '@app/features/ui/components/form/FormInput';
import {Switch} from '@app/features/ui/components/form/FormSwitch';
import {Spinner} from '@app/features/ui/components/Spinner';
import FocusRing from '@app/features/ui/focus_ring/FocusRing';
import {RadioGroup, type RadioOption} from '@app/features/ui/radio_group/RadioGroup';
import {ThemeSelector} from '@app/features/user/components/modals/tabs/appearance_tab/theme/ThemeTabContent';
import {LanguageSelector} from '@app/features/user/components/modals/tabs/LanguageTab';
@@ -27,6 +28,8 @@ import {observer} from 'mobx-react-lite';
import type React from 'react';
import {useCallback, useEffect, useLayoutEffect, useRef, useState} from 'react';
const PUSH_RELAY_NOTICE_URL = 'https://fluxer.com/push-relay';
export type RegistrationMode = 'open' | 'approval' | 'closed';
export type PremiumMode = 'mirror' | 'everyone';
@@ -263,6 +266,29 @@ const MEDIA_RENEW_WINDOW_LABEL_DESCRIPTOR = msg({
comment: 'Label for attachment decay renewal window.',
});
const PUSH_RELAY_TITLE_DESCRIPTOR = msg({
message: 'Mobile push notifications',
comment: 'Setup wizard push relay consent step title.',
});
const PUSH_RELAY_BODY_DESCRIPTOR = msg({
message:
"The official Fluxer mobile apps receive notifications through Fluxer's push relay, which hands them to Apple and Google. Self-hosted UnifiedPush and ntfy endpoints never reach the relay and need no agreement.",
comment: 'Setup wizard push relay consent step body.',
});
const PUSH_RELAY_ACCEPT_LABEL_DESCRIPTOR = msg({
message: 'Accept the push relay supplemental privacy notice',
comment: 'Label for the push relay consent switch during setup.',
});
const PUSH_RELAY_ACCEPT_DESC_DESCRIPTOR = msg({
message:
'Leaving this off keeps the relay unused and drops notifications to the official mobile apps. You can accept it later in the admin panel.',
comment: 'Description for the push relay consent switch during setup.',
});
const PUSH_RELAY_NOTICE_LINK_DESCRIPTOR = msg({
message: 'Read the supplemental privacy notice',
comment: 'Link to the push relay supplemental privacy notice shown during setup.',
});
const SERVICES_TITLE_DESCRIPTOR = msg({
message: 'Optional services',
comment: 'Setup wizard optional services step title.',
@@ -353,10 +379,22 @@ const SUMMARY_ATTACHMENT_EXPIRY_DESCRIPTOR = msg({
message: 'Attachment expiration',
comment: 'Summary row label for the attachment expiry choice in the setup wizard.',
});
const SUMMARY_PUSH_RELAY_DESCRIPTOR = msg({
message: 'Push relay notice',
comment: 'Summary row label for the push relay consent on the setup wizard finish step.',
});
const SUMMARY_PREMIUM_DESCRIPTOR = msg({
message: 'Premium model',
comment: 'Summary row label for the premium model in the setup wizard.',
});
const SUMMARY_ACCEPTED_DESCRIPTOR = msg({
message: 'Accepted',
comment: 'Summary value when the operator accepted the push relay notice.',
});
const SUMMARY_NOT_ACCEPTED_DESCRIPTOR = msg({
message: 'Not accepted',
comment: 'Summary value when the operator left the push relay notice unaccepted.',
});
const SUMMARY_ON_DESCRIPTOR = msg({
message: 'Enabled',
comment: 'Summary value when a setup option is enabled.',
@@ -1531,6 +1569,40 @@ export const IntegrationStep = observer(
},
);
export const PushRelayConsentStep = observer(
({accepted, disabled, onChange}: {accepted: boolean; disabled: boolean; onChange: (value: boolean) => void}) => {
const {i18n} = useLingui();
return (
<section className={styles.step} data-flx="app.self-hosted-setup-wizard-gate.push-relay-consent-step">
<StepHeader
title={i18n._(PUSH_RELAY_TITLE_DESCRIPTOR)}
body={i18n._(PUSH_RELAY_BODY_DESCRIPTOR)}
data-flx="app.setup.setup-wizard-steps.push-relay-consent-step.step-header"
/>
<Switch
label={i18n._(PUSH_RELAY_ACCEPT_LABEL_DESCRIPTOR)}
description={i18n._(PUSH_RELAY_ACCEPT_DESC_DESCRIPTOR)}
value={accepted}
onChange={onChange}
disabled={disabled}
data-flx="app.self-hosted-setup-wizard-gate.push-relay-consent-switch"
/>
<FocusRing data-flx="app.setup.setup-wizard-steps.push-relay-consent-step.focus-ring">
<a
className={styles.noticeLink}
href={PUSH_RELAY_NOTICE_URL}
target="_blank"
rel="noreferrer"
data-flx="app.self-hosted-setup-wizard-gate.push-relay-notice-link"
>
{i18n._(PUSH_RELAY_NOTICE_LINK_DESCRIPTOR)}
</a>
</FocusRing>
</section>
);
},
);
export const ServicesStep = observer(
({
available,
@@ -1650,6 +1722,7 @@ export const FinishStep = observer(
singleCommunityEnabled,
directMessagesDisabled,
attachmentExpiryEnabled,
pushRelayConsentAccepted,
premiumMode,
submitError,
}: {
@@ -1658,6 +1731,7 @@ export const FinishStep = observer(
singleCommunityEnabled: boolean;
directMessagesDisabled: boolean;
attachmentExpiryEnabled: boolean;
pushRelayConsentAccepted: boolean;
premiumMode: PremiumMode;
submitError: string | null;
}) => {
@@ -1705,10 +1779,17 @@ export const FinishStep = observer(
value={attachmentExpiryEnabled ? onLabel : offLabel}
data-flx="app.setup.setup-wizard-steps.finish-step.summary-row--5"
/>
<SummaryRow
label={i18n._(SUMMARY_PUSH_RELAY_DESCRIPTOR)}
value={
pushRelayConsentAccepted ? i18n._(SUMMARY_ACCEPTED_DESCRIPTOR) : i18n._(SUMMARY_NOT_ACCEPTED_DESCRIPTOR)
}
data-flx="app.setup.setup-wizard-steps.finish-step.summary-row--6"
/>
<SummaryRow
label={i18n._(SUMMARY_PREMIUM_DESCRIPTOR)}
value={premiumLabel}
data-flx="app.setup.setup-wizard-steps.finish-step.summary-row--6"
data-flx="app.setup.setup-wizard-steps.finish-step.summary-row--7"
/>
</div>
{submitError && (
@@ -221,7 +221,13 @@ describe('rewriteImportedAccount', () => {
userId: '1',
token: 'token',
localStorageData: {runtimeConfig: '{}', token: 'token'},
managedStorageData: {runtimeConfig: '{}', token: 'token', 'fluxer.theme': 'dark'},
managedStorageData: {
runtimeConfig: '{}',
token: 'token',
'fluxer.theme': 'dark',
'fluxer.lastPushEndpoint': 'https://push',
[core.DOMAIN_MIGRATION_MARKER_KEY]: '{}',
},
lastActive: NOW,
instance: {apiEndpoint: 'https://web.fluxer.app/api'} as RuntimeConfigSnapshot,
};
@@ -269,7 +275,7 @@ describe('migration gate', () => {
['assignment off', {assignmentEnabled: false}],
['kill switch', {discovery: {...ENABLED_DISCOVERY, enabled: false}}],
['no discovery', {discovery: null}],
['already completed', {marker: {state: 'completed', target: 'https://fluxer.com', at: NOW}}],
['already completed', {marker: {state: 'completed', target: 'https://fluxer.com', at: NOW, attempts: 1}}],
['failed recently', {marker: {state: 'failed', at: NOW - 60_000, attempts: 1}}],
['failed too often', {marker: {state: 'failed', at: NOW - 3 * 24 * 60 * 60 * 1000, attempts: 3}}],
['Android web app', {environment: environment('chromium-android')}],
@@ -315,9 +321,10 @@ describe('marker state', () => {
state: 'completed',
target: 'https://fluxer.com',
at: NOW + 2,
attempts: 2,
});
core.markDomainMigrationFailed(storage, NOW + 3);
expect(core.readDomainMigrationMarker(storage)).toEqual({state: 'failed', at: NOW + 3, attempts: 1});
expect(core.readDomainMigrationMarker(storage)).toEqual({state: 'failed', at: NOW + 3, attempts: 2});
});
it('ignores malformed markers', () => {
@@ -352,7 +359,12 @@ describe('forwarding', () => {
});
it('honours the kill switch', () => {
const completed: core.DomainMigrationMarker = {state: 'completed', target: 'https://fluxer.com', at: NOW};
const completed: core.DomainMigrationMarker = {
state: 'completed',
target: 'https://fluxer.com',
at: NOW,
attempts: 1,
};
const browser = environment('none');
expect(core.shouldForwardCompletedSource(ENABLED_DISCOVERY, completed, browser)).toBe(true);
expect(core.shouldForwardCompletedSource({...ENABLED_DISCOVERY, enabled: false}, completed, browser)).toBe(false);
@@ -361,7 +373,12 @@ describe('forwarding', () => {
});
it('forwards installed apps only when standalone forwarding is on', () => {
const completed: core.DomainMigrationMarker = {state: 'completed', target: 'https://fluxer.com', at: NOW};
const completed: core.DomainMigrationMarker = {
state: 'completed',
target: 'https://fluxer.com',
at: NOW,
attempts: 1,
};
const forwarding = {...ENABLED_DISCOVERY, standalone_forwarding: true};
const desktop = environment('chromium-desktop');
expect(core.shouldForwardCompletedSource(ENABLED_DISCOVERY, completed, desktop)).toBe(false);
@@ -488,7 +505,12 @@ describe('classifyDomainMigrationInstallKind', () => {
describe('shouldShowDomainMovedNotice', () => {
const source = core.resolveDomainMigrationSide('https://web.fluxer.app');
const completed: core.DomainMigrationMarker = {state: 'completed', target: 'https://fluxer.com', at: NOW};
const completed: core.DomainMigrationMarker = {
state: 'completed',
target: 'https://fluxer.com',
at: NOW,
attempts: 1,
};
function notice(overrides: Partial<core.DomainMovedNoticeInput>): core.DomainMovedNoticeInput {
return {
@@ -632,10 +654,17 @@ describe('runDomainMigrationPreMount', () => {
});
it('forwards a migrated source tab with its hash', async () => {
visit('https://web.fluxer.app/reset#token=abc', ENABLED_DISCOVERY);
visit('https://web.fluxer.app/channels/1/2?a=1#b', ENABLED_DISCOVERY);
writeCompletedMarker();
expect(await runDomainMigrationPreMount()).toBe(true);
expect(replace).toHaveBeenCalledWith('https://fluxer.com/reset#token=abc');
expect(replace).toHaveBeenCalledWith('https://fluxer.com/channels/1/2?a=1#b');
});
it('keeps one-shot routes on the source', async () => {
visit('https://web.fluxer.app/reset#token=abc', ENABLED_DISCOVERY);
writeCompletedMarker();
expect(await runDomainMigrationPreMount()).toBe(false);
expect(replace).not.toHaveBeenCalled();
});
it('stays put when the kill switch is off', async () => {
@@ -703,10 +732,10 @@ describe('runDomainMigrationPreMount', () => {
it('resumes through the target when a migrated source still holds a session', async () => {
writeCompletedMarker();
window.localStorage.setItem('token', 'session-token');
visit('https://web.fluxer.app/reset#token=abc', ENABLED_DISCOVERY);
visit('https://web.fluxer.app/channels/1/2#x', ENABLED_DISCOVERY);
expect(await runDomainMigrationPreMount()).toBe(true);
expect(replace).toHaveBeenCalledWith(
`https://fluxer.com/migrate/begin?resume=1&next=${encodeURIComponent('/reset#token=abc')}`,
`https://fluxer.com/migrate/begin?resume=1&imported=1&next=${encodeURIComponent('/channels/1/2#x')}`,
);
expect(core.readDomainMigrationIntent(window.sessionStorage, Date.now())).not.toBeNull();
});
@@ -795,11 +824,14 @@ describe('runDomainMigrationPreMount', () => {
expect(replace.mock.calls[0]?.[0]).toMatch(/^https:\/\/web\.fluxer\.app\/migrate\/export\?n=[\w-]+$/u);
});
it('opens the target directly when the browser already migrated', async () => {
it('opens the target in place when the browser already migrated', async () => {
window.localStorage.setItem('token', 'session-token');
visit('https://fluxer.com/migrate/begin?start=1&next=%2Fapp', ENABLED_DISCOVERY);
expect(await runDomainMigrationPreMount()).toBe(true);
expect(replace).toHaveBeenCalledWith('https://fluxer.com/app');
const replaceState = vi.fn();
vi.stubGlobal('history', {state: null, replaceState});
expect(await runDomainMigrationPreMount()).toBe(false);
expect(replace).not.toHaveBeenCalled();
expect(replaceState).toHaveBeenCalledWith(null, '', '/app');
});
it('does not start a migration inside an installed Android app', async () => {
@@ -819,14 +851,24 @@ describe('runDomainMigrationPreMount', () => {
);
});
it('reports back to the source when the target already holds a session', async () => {
it('still imports into a target that already holds a session', async () => {
window.localStorage.setItem('token', 'session-token');
visit('https://fluxer.com/migrate/begin?next=%2Fchannels%2F1', ENABLED_DISCOVERY);
expect(await runDomainMigrationPreMount()).toBe(true);
expect(replace).toHaveBeenCalledWith('https://web.fluxer.app/migrate/done?next=%2Fchannels%2F1');
expect(replace.mock.calls[0]?.[0]).toMatch(/^https:\/\/web\.fluxer\.app\/migrate\/export\?n=[\w-]+$/u);
expect(window.sessionStorage.getItem(core.DOMAIN_MIGRATION_PENDING_KEY)).not.toBeNull();
window.localStorage.removeItem(core.DOMAIN_MIGRATION_IMPORT_KEY);
visit('https://fluxer.com/migrate/begin?resume=1&next=%2Fchannels%2F1', ENABLED_DISCOVERY);
expect(await runDomainMigrationPreMount()).toBe(true);
expect(replace).toHaveBeenCalledWith('https://fluxer.com/channels/1');
expect(replace.mock.calls[0]?.[0]).toMatch(/^https:\/\/web\.fluxer\.app\/migrate\/export\?n=[\w-]+$/u);
core.writeDomainMigrationImport(window.localStorage, {state: 'done', at: Date.now()});
visit('https://fluxer.com/migrate/begin?resume=1&next=%2Fchannels%2F1', ENABLED_DISCOVERY);
const replaceState = vi.fn();
vi.stubGlobal('history', {state: null, replaceState});
expect(await runDomainMigrationPreMount()).toBe(false);
expect(replace).not.toHaveBeenCalled();
expect(replaceState).toHaveBeenCalledWith(null, '', '/channels/1');
});
});
@@ -4,17 +4,23 @@ import {
classifyDomainMigrationInstallKind,
type DomainMigrationDisplayMode,
type DomainMigrationEnvironment,
type DomainMigrationGateInput,
type DomainMigrationInstallKind,
type DomainMigrationSide,
domainMovedBrowserMigrationUrl,
domainMovedInstallUrl,
domainMovedManifestId,
isDomainMigrationOneShotRoute,
markDomainMigrationFailed,
readDomainMigrationMarker,
writeDomainMigrationIntent,
} from '@app/features/app/domain_migration/DomainMigrationCore';
import {
AuthSessionStorageKey,
parseStoredSessionValue,
} from '@app/features/platform/state/auth_session/AuthSessionStorage';
import {getProtectedLocalStorage} from '@app/features/platform/state/ProtectedWebStorage';
import {hasUnavailableElectronNativeContext, isElectron} from '@app/features/ui/utils/NativeUtils';
import {getProtectedLocalStorage, getProtectedSessionStorage} from '@app/features/platform/state/ProtectedWebStorage';
import {hasUnavailableElectronNativeContext, isElectron} from '@app/features/ui/utils/ElectronRuntime';
import type {DomainMigrationDiscoveryResponse} from '@fluxer/schema/src/domains/admin/DomainMigrationSchemas';
interface NavigatorWithStandalone extends Navigator {
@@ -68,6 +74,29 @@ export function readDomainMigrationEnvironment(): DomainMigrationEnvironment {
};
}
export function readDomainMigrationGateInput(
assignmentEnabled: boolean,
voiceActive: boolean,
): DomainMigrationGateInput {
return {
environment: readDomainMigrationEnvironment(),
assignmentEnabled,
discovery: readDomainMigrationDiscovery(),
marker: readDomainMigrationMarker(getProtectedLocalStorage()),
now: Date.now(),
voiceActive,
oneShotRoute: isDomainMigrationOneShotRoute(window.location.pathname),
};
}
export function startDomainMigrationFromSource(side: DomainMigrationSide): true {
markDomainMigrationFailed(getProtectedLocalStorage(), Date.now());
writeDomainMigrationIntent(getProtectedSessionStorage(), {at: Date.now()});
const next = `${window.location.pathname}${window.location.search}${window.location.hash}`;
window.location.replace(`${side.target}/migrate/begin?next=${encodeURIComponent(next)}`);
return true;
}
export async function desktopPasskeysSupported(): Promise<boolean> {
if (!isElectronEnvironment()) {
return true;
@@ -22,6 +22,11 @@ export const DOMAIN_MIGRATION_PENDING_KEY = 'fluxer:domain-migration:pending';
export const DOMAIN_MIGRATION_INTENT_KEY = 'fluxer:domain-migration:intent';
export const DOMAIN_MIGRATION_NOTIFICATIONS_KEY = 'fluxer:domain-migration:notifications';
export const DOMAIN_MIGRATION_MOVED_DISMISSED_KEY = 'fluxer:domain-migration:moved-dismissed-at';
export const DOMAIN_MIGRATION_ENROLLED_KEY = 'fluxer:domain-migration:enrolled';
export const DOMAIN_MIGRATION_PROBED_AT_KEY = 'fluxer:domain-migration:probed-at';
export const DOMAIN_MIGRATION_IMPORT_KEY = 'fluxer:domain-migration:import';
export const DOMAIN_MIGRATION_DEVICES_KEY = 'fluxer:domain-migration:devices';
export const DOMAIN_MIGRATION_STORAGE_KEY_PREFIXES: ReadonlyArray<string> = [DOMAIN_MIGRATION_MARKER_KEY];
export const DOMAIN_MIGRATION_PAYLOAD_VERSION = 1;
export const DOMAIN_MIGRATION_DEFAULT_NEXT_PATH = '/channels/@me';
@@ -31,6 +36,21 @@ export const DOMAIN_MIGRATION_PENDING_MAX_AGE_MS = 10 * 60 * 1000;
export const DOMAIN_MIGRATION_CUSTOM_SOUNDS_MAX_BYTES = 4 * 1024 * 1024;
export const DOMAIN_MIGRATION_THEME_ASSETS_MAX_BYTES = 2 * 1024 * 1024;
export const DOMAIN_MIGRATION_MOVED_DISMISS_MS = 7 * 24 * 60 * 60 * 1000;
export const DOMAIN_MIGRATION_PROBE_INTERVAL_MS = 6 * 60 * 60 * 1000;
export const DOMAIN_MIGRATION_IMPORT_STALE_MS = 2 * 60 * 1000;
export const DOMAIN_MIGRATION_DEVICES_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000;
const ONE_SHOT_ROUTE_PREFIXES: ReadonlyArray<string> = [
'/reset',
'/verify',
'/authorize-ip',
'/wasntme',
'/oauth2/authorize',
'/auth/sso/callback',
'/premium-callback',
'/age-verification-callback',
'/connection-callback',
];
const NEXT_PATH_BASE = 'https://next.invalid';
@@ -92,12 +112,20 @@ export function sanitizeNextPath(value: unknown): string {
return `${pathname}${url.search}${url.hash}`;
}
export function isDomainMigrationStorageKey(key: string): boolean {
return key.startsWith(DOMAIN_MIGRATION_MARKER_KEY);
}
export function isDomainMigrationOneShotRoute(pathname: string): boolean {
return ONE_SHOT_ROUTE_PREFIXES.some((prefix) => pathname === prefix || pathname.startsWith(`${prefix}/`));
}
export function buildTargetUrl(target: string, pathname: string, search: string, hash: string): string {
return `${target}${sanitizeNextPath(`${pathname}${search}${hash}`)}`;
}
export type DomainMigrationMarker =
| {state: 'completed'; target: string; at: number}
| {state: 'completed' | 'handed-off'; target: string; at: number; attempts: number}
| {state: 'failed'; at: number; attempts: number};
export function parseDomainMigrationMarker(raw: string | null): DomainMigrationMarker | null {
@@ -109,8 +137,9 @@ export function parseDomainMigrationMarker(raw: string | null): DomainMigrationM
if (typeof value !== 'object' || value === null || typeof value.at !== 'number') {
return null;
}
if (value.state === 'completed' && typeof value.target === 'string') {
return {state: 'completed', target: value.target, at: value.at};
if ((value.state === 'completed' || value.state === 'handed-off') && typeof value.target === 'string') {
const attempts = typeof value.attempts === 'number' && value.attempts > 0 ? value.attempts : 0;
return {state: value.state, target: value.target, at: value.at, attempts};
}
if (value.state === 'failed') {
const attempts = typeof value.attempts === 'number' && value.attempts > 0 ? value.attempts : 1;
@@ -137,12 +166,26 @@ function writeDomainMigrationMarker(storage: StorageLike | null, marker: DomainM
}
export function markDomainMigrationCompleted(storage: StorageLike | null, target: string, now: number): void {
writeDomainMigrationMarker(storage, {state: 'completed', target, at: now});
const attempts = readDomainMigrationMarker(storage)?.attempts ?? 0;
writeDomainMigrationMarker(storage, {state: 'completed', target, at: now, attempts});
}
export function markDomainMigrationHandedOff(
storage: StorageLike | null,
target: string,
now: number,
attempts: number,
): void {
writeDomainMigrationMarker(storage, {state: 'handed-off', target, at: now, attempts});
}
export function isCompletedDomainMigrationMarker(marker: DomainMigrationMarker | null): boolean {
return marker !== null && marker.state !== 'failed';
}
export function markDomainMigrationFailed(storage: StorageLike | null, now: number): void {
const previous = readDomainMigrationMarker(storage);
const attempts = previous?.state === 'failed' ? previous.attempts + 1 : 1;
const attempts = previous?.state === 'failed' ? previous.attempts + 1 : Math.max(previous?.attempts ?? 0, 1);
writeDomainMigrationMarker(storage, {state: 'failed', at: now, attempts});
}
@@ -150,7 +193,7 @@ export function markerAllowsDomainMigration(marker: DomainMigrationMarker | null
if (marker === null) {
return true;
}
if (marker.state === 'completed') {
if (marker.state !== 'failed') {
return false;
}
return (
@@ -158,6 +201,150 @@ export function markerAllowsDomainMigration(marker: DomainMigrationMarker | null
);
}
function readTimestampRecord(storage: StorageLike | null, key: string): number | null {
try {
const value = JSON.parse(storage?.getItem(key) ?? 'null') as unknown;
return isRecord(value) && typeof value.at === 'number' ? value.at : null;
} catch {
return null;
}
}
function writeTimestampRecord(storage: StorageLike | null, key: string, now: number): void {
try {
storage?.setItem(key, JSON.stringify({at: now}));
} catch {}
}
export function readDomainMigrationEnrollment(storage: StorageLike | null): boolean {
return readTimestampRecord(storage, DOMAIN_MIGRATION_ENROLLED_KEY) !== null;
}
export function markDomainMigrationEnrolled(storage: StorageLike | null, now: number): void {
if (!readDomainMigrationEnrollment(storage)) {
writeTimestampRecord(storage, DOMAIN_MIGRATION_ENROLLED_KEY, now);
}
}
export function domainMigrationProbeIsDue(storage: StorageLike | null, now: number): boolean {
const probedAt = readTimestampRecord(storage, DOMAIN_MIGRATION_PROBED_AT_KEY);
return probedAt === null || now - probedAt >= DOMAIN_MIGRATION_PROBE_INTERVAL_MS || probedAt > now;
}
export function markDomainMigrationProbed(storage: StorageLike | null, now: number): void {
writeTimestampRecord(storage, DOMAIN_MIGRATION_PROBED_AT_KEY, now);
}
export interface DomainMigrationImportRecord {
state: 'running' | 'done';
at: number;
}
export function readDomainMigrationImport(storage: StorageLike | null): DomainMigrationImportRecord | null {
try {
const value = JSON.parse(storage?.getItem(DOMAIN_MIGRATION_IMPORT_KEY) ?? 'null') as unknown;
if (!isRecord(value) || typeof value.at !== 'number' || (value.state !== 'running' && value.state !== 'done')) {
return null;
}
return {state: value.state, at: value.at};
} catch {
return null;
}
}
export function writeDomainMigrationImport(
storage: StorageLike | null,
record: DomainMigrationImportRecord | null,
): void {
try {
if (record === null) {
storage?.removeItem(DOMAIN_MIGRATION_IMPORT_KEY);
} else {
storage?.setItem(DOMAIN_MIGRATION_IMPORT_KEY, JSON.stringify(record));
}
} catch {}
}
export function importIsRunning(record: DomainMigrationImportRecord | null, now: number): boolean {
return record?.state === 'running' && now - record.at < DOMAIN_MIGRATION_IMPORT_STALE_MS;
}
export type DomainMigrationMediaDeviceKind = 'audioinput' | 'audiooutput' | 'videoinput';
export interface DomainMigrationMediaDevice {
kind: DomainMigrationMediaDeviceKind;
device_id: string;
label: string;
}
export interface DomainMigrationDeviceMap {
at: number;
devices: Array<DomainMigrationMediaDevice>;
resolved: Array<DomainMigrationMediaDeviceKind>;
}
function isMediaDeviceKind(value: unknown): value is DomainMigrationMediaDeviceKind {
return value === 'audioinput' || value === 'audiooutput' || value === 'videoinput';
}
function isMediaDevice(value: unknown): value is DomainMigrationMediaDevice {
return (
isRecord(value) &&
isMediaDeviceKind(value.kind) &&
typeof value.device_id === 'string' &&
value.device_id.length > 0 &&
typeof value.label === 'string' &&
value.label.length > 0
);
}
export function readDomainMigrationDeviceMap(
storage: StorageLike | null,
now: number,
): DomainMigrationDeviceMap | null {
let raw: string | null;
try {
raw = storage?.getItem(DOMAIN_MIGRATION_DEVICES_KEY) ?? null;
} catch {
return null;
}
if (raw === null) {
return null;
}
let value: unknown;
try {
value = JSON.parse(raw);
} catch {
value = null;
}
if (
!isRecord(value) ||
typeof value.at !== 'number' ||
now - value.at > DOMAIN_MIGRATION_DEVICES_MAX_AGE_MS ||
value.at > now ||
!Array.isArray(value.devices) ||
!Array.isArray(value.resolved)
) {
writeDomainMigrationDeviceMap(storage, null);
return null;
}
return {
at: value.at,
devices: value.devices.filter(isMediaDevice),
resolved: value.resolved.filter(isMediaDeviceKind),
};
}
export function writeDomainMigrationDeviceMap(storage: StorageLike | null, map: DomainMigrationDeviceMap | null): void {
try {
if (map === null) {
storage?.removeItem(DOMAIN_MIGRATION_DEVICES_KEY);
} else {
storage?.setItem(DOMAIN_MIGRATION_DEVICES_KEY, JSON.stringify(map));
}
} catch {}
}
export interface DomainMigrationIntent {
at: number;
handoff_id?: string;
@@ -310,7 +497,11 @@ export function shouldForwardCompletedSource(
marker: DomainMigrationMarker | null,
environment: DomainMigrationEnvironment,
): boolean {
return discovery?.enabled === true && marker?.state === 'completed' && environmentMayForward(environment, discovery);
return (
discovery?.enabled === true &&
isCompletedDomainMigrationMarker(marker) &&
environmentMayForward(environment, discovery)
);
}
export interface DomainMovedNoticeInput {
@@ -330,7 +521,7 @@ export function shouldShowDomainMovedNotice(input: DomainMovedNoticeInput): bool
if (input.dismissedAt !== null && input.now - input.dismissedAt < DOMAIN_MIGRATION_MOVED_DISMISS_MS) {
return false;
}
const completed = input.marker?.state === 'completed';
const completed = isCompletedDomainMigrationMarker(input.marker);
if (input.installKind === 'chromium-desktop') {
return completed;
}
@@ -359,9 +550,7 @@ export function deviceIsInAnonymousRollout(discovery: DomainMigrationDiscoveryRe
export function isExportableLocalStorageKey(key: string): boolean {
return (
!DENIED_LOCAL_STORAGE_KEYS.has(key) &&
!key.startsWith(DOMAIN_MIGRATION_MARKER_KEY) &&
!PUSH_SUBSCRIPTION_KEY_PATTERN.test(key)
!DENIED_LOCAL_STORAGE_KEYS.has(key) && !isDomainMigrationStorageKey(key) && !PUSH_SUBSCRIPTION_KEY_PATTERN.test(key)
);
}
@@ -416,6 +605,7 @@ export interface DomainMigrationPayload {
accounts: Array<StoredAccount>;
custom_sounds?: Array<DomainMigrationCustomSound>;
theme_library?: DomainMigrationThemeLibrary;
media_devices?: Array<DomainMigrationMediaDevice>;
notification_permission: string;
}
@@ -508,16 +698,31 @@ export function parseDomainMigrationPayload(value: unknown, expectedSource: stri
if (value.theme_library !== undefined && !isThemeLibrary(value.theme_library)) {
return null;
}
return value as unknown as DomainMigrationPayload;
const mediaDevices = Array.isArray(value.media_devices) ? value.media_devices.filter(isMediaDevice) : [];
return {
...(value as unknown as DomainMigrationPayload),
media_devices: mediaDevices.length > 0 ? mediaDevices : undefined,
};
}
function withoutRuntimeConfig(snapshot: Record<string, string> | undefined): Record<string, string> {
const {runtimeConfig: _runtimeConfig, ...rest} = snapshot ?? {};
return rest;
function exportableSnapshot(snapshot: Record<string, string> | undefined): Record<string, string> {
return Object.fromEntries(Object.entries(snapshot ?? {}).filter(([key]) => isExportableLocalStorageKey(key)));
}
export function keepValidTargetSession(incoming: StoredAccount, existing: StoredAccount | undefined): StoredAccount {
if (incoming.isValid !== false || !existing?.token || existing.isValid === false) {
return incoming;
}
return {
...incoming,
token: existing.token,
isValid: existing.isValid,
userData: existing.userData ?? incoming.userData,
};
}
export function rewriteImportedAccount(record: StoredAccount, instance: RuntimeConfigSnapshot): StoredAccount {
const managed = withoutRuntimeConfig(record.managedStorageData ?? record.localStorageData);
const managed = exportableSnapshot(record.managedStorageData ?? record.localStorageData);
return {
...record,
localStorageData: managed,
@@ -0,0 +1,178 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {
type DomainMigrationDeviceMap,
type DomainMigrationMediaDevice,
type DomainMigrationMediaDeviceKind,
readDomainMigrationDeviceMap,
writeDomainMigrationDeviceMap,
} from '@app/features/app/domain_migration/DomainMigrationCore';
import {getProtectedLocalStorage} from '@app/features/platform/state/ProtectedWebStorage';
import {awaitHydration} from '@app/features/platform/utils/MobXPersistence';
import VoiceDevicePermissionState from '@app/features/voice/engine/VoiceDevicePermissionState';
import VoiceSettings from '@app/features/voice/state/VoiceSettings';
import {
getVoiceAudioDeviceMetadata,
normalizeDeviceMatchLabel,
type VoiceDeviceState,
} from '@app/features/voice/utils/VoiceDeviceManager';
const remappedDeviceIds = new Map<DomainMigrationMediaDeviceKind, Map<string, string>>();
let installed = false;
let pendingDeviceMap: DomainMigrationDeviceMap | null = null;
function devicesOfKind(state: VoiceDeviceState, kind: DomainMigrationMediaDeviceKind): Array<MediaDeviceInfo> {
const devices =
kind === 'audioinput' ? state.inputDevices : kind === 'audiooutput' ? state.outputDevices : state.videoDevices;
return devices.filter(
(device) =>
device.deviceId.trim().length > 0 &&
device.deviceId !== 'default' &&
device.deviceId !== 'communications' &&
getVoiceAudioDeviceMetadata(device) === null,
);
}
function hasLabelledDevices(state: VoiceDeviceState, kind: DomainMigrationMediaDeviceKind): boolean {
const devices = devicesOfKind(state, kind);
const permission = kind === 'videoinput' ? state.permissionStatus.video : state.permissionStatus.audio;
return devices.some((device) => device.label.trim().length > 0) || (permission === 'granted' && devices.length > 0);
}
function groupByLabel<T>(
entries: ReadonlyArray<T>,
labelOf: (entry: T) => string,
idOf: (entry: T) => string,
): Map<string, Array<string>> {
const groups = new Map<string, Array<string>>();
for (const entry of entries) {
const label = labelOf(entry);
if (label.length === 0) {
continue;
}
groups.set(label, [...(groups.get(label) ?? []), idOf(entry)]);
}
return groups;
}
function mapDevicesOfKind(
kind: DomainMigrationMediaDeviceKind,
sourceDevices: ReadonlyArray<DomainMigrationMediaDevice>,
targetDevices: ReadonlyArray<MediaDeviceInfo>,
): Map<string, string> {
const sourceByLabel = groupByLabel(
sourceDevices.filter((device) => device.kind === kind),
(device) => normalizeDeviceMatchLabel(kind, device.device_id, device.label),
(device) => device.device_id,
);
const targetByLabel = groupByLabel(
targetDevices,
(device) => normalizeDeviceMatchLabel(kind, device.deviceId, device.label),
(device) => device.deviceId,
);
const mapping = new Map<string, string>();
for (const [label, sourceIds] of sourceByLabel) {
const targetIds = targetByLabel.get(label);
if (sourceIds.length === 1 && targetIds?.length === 1 && sourceIds[0] !== targetIds[0]) {
mapping.set(sourceIds[0], targetIds[0]);
}
}
return mapping;
}
export function remapMigratedDeviceId(kind: DomainMigrationMediaDeviceKind, deviceId: string): string {
return remappedDeviceIds.get(kind)?.get(deviceId) ?? deviceId;
}
export function remapMigratedDeviceIds(
deviceIds: Array<string>,
kinds: ReadonlyArray<DomainMigrationMediaDeviceKind>,
): Array<string> {
let changed = false;
const remapped = deviceIds.flatMap((deviceId) => {
const targets = [...new Set(kinds.map((kind) => remapMigratedDeviceId(kind, deviceId)))].filter(
(target) => target !== deviceId,
);
if (targets.length === 0) {
return [deviceId];
}
changed = true;
return targets;
});
return changed ? [...new Set(remapped)] : deviceIds;
}
export function isPendingMigratedDeviceId(deviceId: string): boolean {
const map = pendingDeviceMap;
return map?.devices.some((device) => device.device_id === deviceId && !map.resolved.includes(device.kind)) ?? false;
}
function applyToVoiceSettings(): void {
const current = {
inputDeviceId: VoiceSettings.getInputDeviceId(),
outputDeviceId: VoiceSettings.getOutputDeviceId(),
videoDeviceId: VoiceSettings.getVideoDeviceId(),
screenShareAudioDeviceId: VoiceSettings.getScreenShareAudioDeviceId(),
};
const next = {
inputDeviceId: remapMigratedDeviceId('audioinput', current.inputDeviceId),
outputDeviceId: remapMigratedDeviceId('audiooutput', current.outputDeviceId),
videoDeviceId: remapMigratedDeviceId('videoinput', current.videoDeviceId),
screenShareAudioDeviceId: remapMigratedDeviceId('audioinput', current.screenShareAudioDeviceId),
};
if (
next.inputDeviceId !== current.inputDeviceId ||
next.outputDeviceId !== current.outputDeviceId ||
next.videoDeviceId !== current.videoDeviceId ||
next.screenShareAudioDeviceId !== current.screenShareAudioDeviceId
) {
VoiceSettings.updateSettings(next);
}
}
function reconcileMigratedDevices(state: VoiceDeviceState): void {
const map = pendingDeviceMap;
if (map === null) {
return;
}
const pendingKinds = [...new Set(map.devices.map((device) => device.kind))].filter(
(kind) => !map.resolved.includes(kind),
);
const resolvableKinds = pendingKinds.filter((kind) => hasLabelledDevices(state, kind));
if (pendingKinds.length > 0 && resolvableKinds.length === 0) {
return;
}
for (const kind of resolvableKinds) {
const mapping = mapDevicesOfKind(kind, map.devices, devicesOfKind(state, kind));
remappedDeviceIds.set(kind, new Map([...(remappedDeviceIds.get(kind) ?? []), ...mapping]));
}
applyToVoiceSettings();
const resolved = [...map.resolved, ...resolvableKinds];
const allResolved = pendingKinds.every((kind) => resolved.includes(kind));
pendingDeviceMap = allResolved ? null : {...map, resolved};
const storage = getProtectedLocalStorage();
const stored = readDomainMigrationDeviceMap(storage, Date.now());
if (stored === null) {
return;
}
const storedResolved = [...new Set([...stored.resolved, ...resolved])];
const storedKinds = new Set(stored.devices.map((device) => device.kind));
writeDomainMigrationDeviceMap(
storage,
[...storedKinds].every((kind) => storedResolved.includes(kind)) ? null : {...stored, resolved: storedResolved},
);
}
export async function installMigratedDeviceRemap(): Promise<void> {
if (installed) {
return;
}
const map = readDomainMigrationDeviceMap(getProtectedLocalStorage(), Date.now());
if (map === null) {
return;
}
installed = true;
pendingDeviceMap = {...map, resolved: []};
await awaitHydration('VoiceSettings');
VoiceDevicePermissionState.addDeviceStateReconciler(reconcileMigratedDevices);
}
@@ -1,10 +1,13 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {
desktopPasskeysSupported,
hasStoredAccount,
readActiveSessionToken,
readDomainMigrationDiscovery,
readDomainMigrationEnvironment,
readDomainMigrationGateInput,
startDomainMigrationFromSource,
} from '@app/features/app/domain_migration/DomainMigrationBrowser';
import {
anonymousRolloutIsOpen,
@@ -15,24 +18,33 @@ import {
DOMAIN_MIGRATION_DEFAULT_NEXT_PATH,
DOMAIN_MIGRATION_DEVICE_KEY,
DOMAIN_MIGRATION_MARKER_KEY,
DOMAIN_MIGRATION_MAX_FAILED_ATTEMPTS,
DOMAIN_MIGRATION_NOTIFICATIONS_KEY,
DOMAIN_MIGRATION_PAYLOAD_VERSION,
DOMAIN_MIGRATION_PENDING_KEY,
DOMAIN_MIGRATION_PENDING_MAX_AGE_MS,
DOMAIN_MIGRATION_THEME_ASSETS_MAX_BYTES,
type DomainMigrationCustomSound,
type DomainMigrationMediaDevice,
type DomainMigrationPayload,
type DomainMigrationSide,
type DomainMigrationThemeLibrary,
deviceIsInAnonymousRollout,
environmentAllowsDomainMigration,
environmentMayForward,
importIsRunning,
intentConfirmsCompletion,
isCompletedDomainMigrationMarker,
isDomainMigrationOneShotRoute,
isExportableLocalStorageKey,
keepValidTargetSession,
markDomainMigrationCompleted,
markDomainMigrationFailed,
markDomainMigrationHandedOff,
parseDomainMigrationMarker,
parseDomainMigrationPayload,
readDomainMigrationEnrollment,
readDomainMigrationImport,
readDomainMigrationIntent,
readDomainMigrationMarker,
resolveDomainMigrationSide,
@@ -40,7 +52,10 @@ import {
type StorageLike,
sanitizeNextPath,
shouldForwardCompletedSource,
shouldStartDomainMigration,
withoutOptionalPayloadData,
writeDomainMigrationDeviceMap,
writeDomainMigrationImport,
writeDomainMigrationIntent,
} from '@app/features/app/domain_migration/DomainMigrationCore';
import {
@@ -52,6 +67,11 @@ import {
sha256Hex,
} from '@app/features/app/domain_migration/DomainMigrationCrypto';
import type {SoundType} from '@app/features/notification/utils/SoundUtils';
import {
AuthSessionStorageKey,
parseStoredSessionValue,
readStoredSessionUserId,
} from '@app/features/platform/state/auth_session/AuthSessionStorage';
import {getProtectedLocalStorage, getProtectedSessionStorage} from '@app/features/platform/state/ProtectedWebStorage';
import {Logger} from '@app/features/platform/utils/AppLogger';
import type {
@@ -67,6 +87,11 @@ const NONCE_BYTES = 32;
const DEVICE_ID_BYTES = 16;
const BASE64URL_TOKEN_PATTERN = /^[A-Za-z0-9_-]{43}$/u;
const MAX_HANDOFF_PAYLOAD_LENGTH = 8 * 1024 * 1024;
const IMPORT_WAIT_MS = 20_000;
const IMPORT_POLL_MS = 250;
const MEDIA_DEVICES_WAIT_MS = 1500;
type DomainMigrationLanding = 'target' | 'source';
type DomainMigrationFailureReason =
| 'disabled'
@@ -97,6 +122,11 @@ function navigate(url: string): true {
return true;
}
function mountInPlace(next: string): false {
window.history.replaceState(null, '', next);
return false;
}
function readCurrentPath(): {pathname: string; search: string; hash: string} {
return {pathname: window.location.pathname, search: window.location.search, hash: window.location.hash};
}
@@ -155,6 +185,27 @@ async function restoreCustomSounds(sounds: ReadonlyArray<DomainMigrationCustomSo
}
}
async function collectMediaDevices(): Promise<Array<DomainMigrationMediaDevice> | undefined> {
try {
const devices = await Promise.race([
navigator.mediaDevices?.enumerateDevices?.() ?? Promise.resolve([]),
new Promise<Array<MediaDeviceInfo>>((resolve) => setTimeout(() => resolve([]), MEDIA_DEVICES_WAIT_MS)),
]);
const labelled = devices.flatMap((device): Array<DomainMigrationMediaDevice> => {
const deviceId = device.deviceId.trim();
const label = device.label.trim();
if (!deviceId || !label || deviceId === 'default' || deviceId === 'communications') {
return [];
}
return [{kind: device.kind, device_id: deviceId, label}];
});
return labelled.length > 0 ? labelled : undefined;
} catch (err) {
logger.warn('Skipping media devices in the domain migration export:', err);
return undefined;
}
}
async function collectThemeLibrary(): Promise<DomainMigrationThemeLibrary | undefined> {
try {
const db = await import('@app/features/theme/utils/ThemeLibraryDb');
@@ -279,7 +330,7 @@ function discoveryAllowsMigration(): boolean {
}
function revokeCompletedMarker(storage: StorageLike | null): void {
if (readDomainMigrationMarker(storage)?.state === 'completed') {
if (isCompletedDomainMigrationMarker(readDomainMigrationMarker(storage))) {
markDomainMigrationFailed(storage, Date.now());
}
}
@@ -296,11 +347,20 @@ async function exportFromSource(side: DomainMigrationSide, nonce: string | null)
if (nonce === null || !BASE64URL_TOKEN_PATTERN.test(nonce)) {
throw new Error('Missing or malformed nonce');
}
const [{default: accountStorage}, {default: RuntimeConfig}] = await Promise.all([
import('@app/features/auth/state/AccountStorage'),
import('@app/features/app/state/RuntimeConfig'),
]);
const accounts = (await accountStorage.getAllAccounts()).filter((account) => Boolean(account.token));
const previous = readDomainMigrationMarker(storage);
const attempts = previous?.state === 'handed-off' ? previous.attempts + 1 : Math.max(previous?.attempts ?? 0, 1);
if (attempts > DOMAIN_MIGRATION_MAX_FAILED_ATTEMPTS) {
throw new Error('Too many domain migration attempts');
}
const [{default: accountStorage}, {default: RuntimeConfig, runtimeConfigSnapshotsAreSameInstance}] =
await Promise.all([
import('@app/features/auth/state/AccountStorage'),
import('@app/features/app/state/RuntimeConfig'),
]);
const instance = RuntimeConfig.getSnapshot();
const accounts = (await accountStorage.getAllAccounts()).filter(
(account) => Boolean(account.token) && runtimeConfigSnapshotsAreSameInstance(account.instance, instance),
);
const token = readActiveSessionToken() ?? accounts.find((account) => account.isValid !== false)?.token ?? null;
if (!token) {
throw new Error('No stored account to export');
@@ -313,6 +373,7 @@ async function exportFromSource(side: DomainMigrationSide, nonce: string | null)
accounts,
custom_sounds: await collectCustomSounds(),
theme_library: await collectThemeLibrary(),
media_devices: await collectMediaDevices(),
notification_permission: readNotificationPermission(),
};
let sealed = await encryptDomainMigrationPayload(payload);
@@ -321,7 +382,14 @@ async function exportFromSource(side: DomainMigrationSide, nonce: string | null)
}
const handoffId = await createHandoff(RuntimeConfig.apiEndpoint, token, await sha256Hex(nonce), sealed.payload);
writeDomainMigrationIntent(sessionStorage, {at: intent.at, handoff_id: handoffId});
return navigate(`${side.target}/migrate/complete#h=${handoffId}&k=${sealed.key}`);
markDomainMigrationHandedOff(storage, side.target, Date.now(), attempts);
const landing: DomainMigrationLanding = environmentMayForward(
readDomainMigrationEnvironment(),
readDomainMigrationDiscovery(),
)
? 'target'
: 'source';
return navigate(`${side.target}/migrate/complete#h=${handoffId}&k=${sealed.key}&land=${landing}`);
} catch (err) {
logger.warn('Domain migration export failed:', err);
clearDomainMigrationIntent(sessionStorage);
@@ -330,20 +398,45 @@ async function exportFromSource(side: DomainMigrationSide, nonce: string | null)
}
}
async function forwardCompletedSource(side: DomainMigrationSide): Promise<true> {
const {pathname, search, hash} = readCurrentPath();
if (!(await hasStoredAccount())) {
return navigate(buildTargetUrl(side.target, pathname, search, hash));
}
writeDomainMigrationIntent(getProtectedSessionStorage(), {at: Date.now()});
const next = sanitizeNextPath(`${pathname}${search}${hash}`);
const imported = readDomainMigrationMarker(getProtectedLocalStorage())?.state === 'completed' ? '&imported=1' : '';
return navigate(`${side.target}/migrate/begin?resume=1${imported}&next=${encodeURIComponent(next)}`);
}
async function startEnrolledSource(side: DomainMigrationSide): Promise<boolean> {
if (
!readDomainMigrationEnrollment(getProtectedLocalStorage()) ||
!shouldStartDomainMigration(readDomainMigrationGateInput(true, false))
) {
return false;
}
if (!(await hasStoredAccount()) || !(await desktopPasskeysSupported())) {
return false;
}
return startDomainMigrationFromSource(side);
}
async function forwardFromSource(side: DomainMigrationSide): Promise<boolean> {
const {pathname, search, hash} = readCurrentPath();
if (isDomainMigrationOneShotRoute(pathname)) {
return false;
}
if (await startEnrolledSource(side)) {
return true;
}
const environment = readDomainMigrationEnvironment();
const discovery = readDomainMigrationDiscovery();
if (!environmentMayForward(environment, discovery)) {
return false;
}
const {pathname, search, hash} = readCurrentPath();
if (shouldForwardCompletedSource(discovery, readDomainMigrationMarker(getProtectedLocalStorage()), environment)) {
if (!(await hasStoredAccount())) {
return navigate(buildTargetUrl(side.target, pathname, search, hash));
}
writeDomainMigrationIntent(getProtectedSessionStorage(), {at: Date.now()});
const next = sanitizeNextPath(`${pathname}${search}${hash}`);
return navigate(`${side.target}/migrate/begin?resume=1&next=${encodeURIComponent(next)}`);
return forwardCompletedSource(side);
}
if (
discovery !== null &&
@@ -360,6 +453,7 @@ async function forwardWhenIdle(side: DomainMigrationSide): Promise<void> {
const {default: MediaEngine} = await import('@app/features/voice/engine/MediaEngineFacade');
await when(() => !MediaEngine.connected && !MediaEngine.connecting);
if (
isDomainMigrationOneShotRoute(window.location.pathname) ||
!shouldForwardCompletedSource(
readDomainMigrationDiscovery(),
readDomainMigrationMarker(getProtectedLocalStorage()),
@@ -368,8 +462,7 @@ async function forwardWhenIdle(side: DomainMigrationSide): Promise<void> {
) {
return;
}
const {pathname, search, hash} = readCurrentPath();
navigate(buildTargetUrl(side.target, pathname, search, hash));
await forwardCompletedSource(side);
}
function installSourceMarkerListener(side: DomainMigrationSide): void {
@@ -486,18 +579,35 @@ async function importHandoff(side: DomainMigrationSide, fragment: URLSearchParam
if (payload === null) {
throw new DomainMigrationImportError('invalid_payload');
}
const storage = getProtectedLocalStorage();
const previousImport = readDomainMigrationImport(storage);
writeDomainMigrationImport(storage, {state: 'running', at: Date.now()});
try {
const [{default: accountStorage}, {default: RuntimeConfig}] = await Promise.all([
import('@app/features/auth/state/AccountStorage'),
import('@app/features/app/state/RuntimeConfig'),
]);
const instance = RuntimeConfig.getSnapshot();
await accountStorage.importAccounts(payload.accounts.map((account) => rewriteImportedAccount(account, instance)));
const activeToken = readActiveSessionToken();
const activeUserId = storage ? readStoredSessionUserId(storage) : null;
const incomingToken = parseStoredSessionValue(payload.local_storage[AuthSessionStorageKey.Token] ?? null);
const incomingUserId = parseStoredSessionValue(payload.local_storage[AuthSessionStorageKey.UserId] ?? null);
const existing = new Map((await accountStorage.getAllAccounts()).map((account) => [account.userId, account]));
await accountStorage.importAccounts(
payload.accounts.map((account) =>
keepValidTargetSession(rewriteImportedAccount(account, instance), existing.get(account.userId)),
),
);
if (activeToken !== null && activeUserId !== null && incomingToken !== null && incomingUserId !== activeUserId) {
await accountStorage.stashAccountData(activeUserId, activeToken, undefined, instance).catch((err: unknown) => {
logger.warn('Failed to keep the replaced target session:', err);
});
}
} catch (err) {
writeDomainMigrationImport(storage, previousImport);
logger.warn('Failed to import migrated accounts:', err);
throw new DomainMigrationImportError('import_failed');
}
const storage = getProtectedLocalStorage();
for (const [storageKey, value] of Object.entries(payload.local_storage)) {
if (!isExportableLocalStorageKey(storageKey)) {
continue;
@@ -508,13 +618,17 @@ async function importHandoff(side: DomainMigrationSide, fragment: URLSearchParam
logger.warn(`Failed to import localStorage key ${storageKey}:`, err);
}
}
await restoreCustomSounds(payload.custom_sounds);
await restoreThemeLibrary(payload.theme_library);
if (payload.media_devices) {
writeDomainMigrationDeviceMap(storage, {at: Date.now(), devices: payload.media_devices, resolved: []});
}
if (payload.notification_permission === 'granted') {
try {
storage?.setItem(DOMAIN_MIGRATION_NOTIFICATIONS_KEY, 'granted');
} catch {}
}
writeDomainMigrationImport(storage, {state: 'done', at: Date.now()});
await restoreCustomSounds(payload.custom_sounds);
await restoreThemeLibrary(payload.theme_library);
await persistDesktopAppOrigin();
}
@@ -531,23 +645,50 @@ function doneUrl(side: DomainMigrationSide, next: string, handoffId: string | nu
return `${side.source}/migrate/done?${handoff}next=${encodeURIComponent(next)}`;
}
function landingUrl(side: DomainMigrationSide, fragment: URLSearchParams, next: string): string {
switch (fragment.get('land')) {
case 'target':
return `${side.target}${next}`;
case 'source':
return sourceUrl(side, next);
}
return doneUrl(side, next, fragment.get('h'));
}
function failedUrl(side: DomainMigrationSide, reason: DomainMigrationFailureReason, next: string): string {
return `${side.source}/migrate/failed?reason=${reason}&next=${encodeURIComponent(next)}`;
}
async function waitForRunningImport(): Promise<void> {
const deadline = Date.now() + IMPORT_WAIT_MS;
while (importIsRunning(readDomainMigrationImport(getProtectedLocalStorage()), Date.now()) && Date.now() < deadline) {
await new Promise((resolve) => setTimeout(resolve, IMPORT_POLL_MS));
}
}
async function beginOnTarget(side: DomainMigrationSide, params: URLSearchParams): Promise<boolean> {
const next = sanitizeNextPath(params.get('next'));
if (readDomainMigrationDiscovery()?.enabled !== true) {
return navigate(failedUrl(side, 'disabled', next));
}
const storage = getProtectedLocalStorage();
const started = params.get('start') === '1';
if (await hasStoredAccount()) {
const resumed = params.get('resume') === '1';
if (resumed) {
await waitForRunningImport();
}
const imported = readDomainMigrationImport(storage)?.state === 'done' || (resumed && params.get('imported') === '1');
if ((resumed || started) && (imported || (started && (await hasStoredAccount())))) {
await persistDesktopAppOrigin();
return navigate(params.get('resume') === '1' || started ? `${side.target}${next}` : doneUrl(side, next, null));
return mountInPlace(next);
}
if (started) {
return navigate(`${side.source}/migrate/start?next=${encodeURIComponent(next)}`);
}
if (imported) {
return navigate(doneUrl(side, next, null));
}
writeDomainMigrationImport(storage, {state: 'running', at: Date.now()});
const nonce = randomBase64Url(NONCE_BYTES);
const pending: PendingHandoff = {nonce, next, at: Date.now()};
getProtectedSessionStorage()?.setItem(DOMAIN_MIGRATION_PENDING_KEY, JSON.stringify(pending));
@@ -559,22 +700,29 @@ async function completeOnTarget(side: DomainMigrationSide): Promise<boolean> {
window.history.replaceState(window.history.state, '', `${window.location.pathname}${window.location.search}`);
const pending = takePendingHandoff();
if (pending === null || Date.now() - pending.at > DOMAIN_MIGRATION_PENDING_MAX_AGE_MS) {
const lastImport = readDomainMigrationImport(getProtectedLocalStorage());
if (lastImport?.state === 'done' && Date.now() - lastImport.at < DOMAIN_MIGRATION_PENDING_MAX_AGE_MS) {
return navigate(
fragment.get('land') === 'source'
? sourceUrl(side, DOMAIN_MIGRATION_DEFAULT_NEXT_PATH)
: `${side.target}${DOMAIN_MIGRATION_DEFAULT_NEXT_PATH}`,
);
}
return navigate(failedUrl(side, 'no_pending', DOMAIN_MIGRATION_DEFAULT_NEXT_PATH));
}
const next = sanitizeNextPath(pending.next);
const handoffId = fragment.get('h');
if (await hasStoredAccount()) {
await persistDesktopAppOrigin();
return navigate(doneUrl(side, next, handoffId));
}
try {
await importHandoff(side, fragment, pending.nonce);
} catch (err) {
const reason = err instanceof DomainMigrationImportError ? err.reason : 'import_failed';
logger.warn('Domain migration import failed:', err);
const storage = getProtectedLocalStorage();
if (readDomainMigrationImport(storage)?.state === 'running') {
writeDomainMigrationImport(storage, null);
}
return navigate(failedUrl(side, reason, next));
}
return navigate(doneUrl(side, next, handoffId));
return navigate(landingUrl(side, fragment, next));
}
async function handleTarget(side: DomainMigrationSide): Promise<boolean> {
@@ -1,13 +1,39 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {
markDomainMigrationEnrolled,
readDomainMigrationEnrollment,
} from '@app/features/app/domain_migration/DomainMigrationCore';
import ExperimentAssignments from '@app/features/experiment/state/ExperimentAssignments';
import SessionManager from '@app/features/platform/state/AuthSession';
import {getProtectedLocalStorage} from '@app/features/platform/state/ProtectedWebStorage';
import {Logger} from '@app/features/platform/utils/AppLogger';
import {readDomainMigrationAssignment} from '@fluxer/schema/src/domains/experiment/ExperimentSchemas';
import {
INERT_EXPERIMENT_ASSIGNMENTS_RESPONSE,
readDomainMigrationAssignment,
} from '@fluxer/schema/src/domains/experiment/ExperimentSchemas';
import {makeAutoObservable} from 'mobx';
const logger = new Logger('DomainMigrationRollout');
class DomainMigrationRolloutSelector {
get enabled(): boolean {
deviceEnrolled = readDomainMigrationEnrollment(getProtectedLocalStorage());
constructor() {
makeAutoObservable(this, {}, {autoBind: true});
}
get assignmentReady(): boolean {
return (
ExperimentAssignments.response !== INERT_EXPERIMENT_ASSIGNMENTS_RESPONSE &&
ExperimentAssignments.ownerId === SessionManager.userId
);
}
get assignmentEnabled(): boolean {
if (!this.assignmentReady) {
return false;
}
try {
return readDomainMigrationAssignment(ExperimentAssignments.response).enabled;
} catch (err) {
@@ -15,6 +41,15 @@ class DomainMigrationRolloutSelector {
return false;
}
}
get enabled(): boolean {
return this.assignmentEnabled || this.deviceEnrolled;
}
markDeviceEnrolled(): void {
markDomainMigrationEnrolled(getProtectedLocalStorage(), Date.now());
this.deviceEnrolled = true;
}
}
export const DomainMigrationRollout = new DomainMigrationRolloutSelector();
@@ -1,80 +1,158 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {Routes} from '@app/app/Routes';
import {
desktopPasskeysSupported,
readDomainMigrationDiscovery,
readDomainMigrationEnvironment,
readDomainMigrationGateInput,
startDomainMigrationFromSource,
} from '@app/features/app/domain_migration/DomainMigrationBrowser';
import {
DOMAIN_MIGRATION_IMPORT_KEY,
DOMAIN_MIGRATION_NOTIFICATIONS_KEY,
type DomainMigrationGateInput,
type DomainMigrationSide,
markDomainMigrationFailed,
readDomainMigrationMarker,
domainMigrationProbeIsDue,
environmentAllowsDomainMigration,
markDomainMigrationProbed,
markerAllowsDomainMigration,
readDomainMigrationImport,
resolveDomainMigrationSide,
shouldStartDomainMigration,
writeDomainMigrationIntent,
} from '@app/features/app/domain_migration/DomainMigrationCore';
import DomainMigrationRollout from '@app/features/app/domain_migration/DomainMigrationRollout';
import ExperimentAssignments from '@app/features/experiment/state/ExperimentAssignments';
import {getProtectedLocalStorage, getProtectedSessionStorage} from '@app/features/platform/state/ProtectedWebStorage';
import RuntimeConfig from '@app/features/app/state/RuntimeConfig';
import AccountManager from '@app/features/auth/state/AccountManager';
import {EXPERIMENT_ASSIGNMENTS_PATH} from '@app/features/experiment/state/ExperimentAssignments';
import SessionManager from '@app/features/platform/state/AuthSession';
import {getProtectedLocalStorage} from '@app/features/platform/state/ProtectedWebStorage';
import {Logger} from '@app/features/platform/utils/AppLogger';
import * as NagbarCommands from '@app/features/ui/commands/NagbarCommands';
import MediaEngine from '@app/features/voice/engine/MediaEngineFacade';
import {INERT_EXPERIMENT_ASSIGNMENTS_RESPONSE} from '@fluxer/schema/src/domains/experiment/ExperimentSchemas';
import {when} from 'mobx';
import {
ExperimentAssignmentsResponse,
readDomainMigrationAssignment,
} from '@fluxer/schema/src/domains/experiment/ExperimentSchemas';
import {compareShallow, reaction} from 'mobx';
const logger = new Logger('DomainMigrationTrigger');
const ONE_SHOT_ROUTE_PREFIXES: ReadonlyArray<string> = [
Routes.RESET_PASSWORD,
Routes.VERIFY_EMAIL,
Routes.AUTHORIZE_IP,
Routes.EMAIL_REVERT,
Routes.OAUTH_AUTHORIZE,
Routes.SSO_CALLBACK,
Routes.PREMIUM_CALLBACK,
Routes.AGE_VERIFICATION_CALLBACK,
Routes.CONNECTION_CALLBACK,
];
let started = false;
let navigating = false;
let probing = false;
interface SourceGateState {
assignmentReady: boolean;
assignmentEnabled: boolean;
deviceEnrolled: boolean;
userId: string | null;
sessionSettled: boolean;
voiceActive: boolean;
}
function isVoiceActive(): boolean {
return MediaEngine.connected || MediaEngine.connecting;
}
function isOneShotRoute(pathname: string): boolean {
return ONE_SHOT_ROUTE_PREFIXES.some((prefix) => pathname === prefix || pathname.startsWith(`${prefix}/`));
function isSessionSettled(): boolean {
return SessionManager.isAuthenticated && SessionManager.userId !== null && !AccountManager.transitioning;
}
function readGateInput(assignmentEnabled: boolean): DomainMigrationGateInput {
return {
environment: readDomainMigrationEnvironment(),
assignmentEnabled,
discovery: readDomainMigrationDiscovery(),
marker: readDomainMigrationMarker(getProtectedLocalStorage()),
now: Date.now(),
voiceActive: isVoiceActive(),
oneShotRoute: isOneShotRoute(window.location.pathname),
};
function sourceMayStart(): boolean {
return (
!navigating &&
isSessionSettled() &&
shouldStartDomainMigration(readDomainMigrationGateInput(DomainMigrationRollout.enabled, isVoiceActive()))
);
}
async function evaluateSource(side: DomainMigrationSide, assignmentEnabled: boolean): Promise<void> {
if (navigating || !shouldStartDomainMigration(readGateInput(assignmentEnabled))) {
async function evaluateSource(side: DomainMigrationSide): Promise<void> {
if (!sourceMayStart()) {
return;
}
const passkeysSupported = await desktopPasskeysSupported();
if (navigating || !passkeysSupported || !shouldStartDomainMigration(readGateInput(DomainMigrationRollout.enabled))) {
if (!passkeysSupported || !sourceMayStart()) {
return;
}
navigating = true;
markDomainMigrationFailed(getProtectedLocalStorage(), Date.now());
writeDomainMigrationIntent(getProtectedSessionStorage(), {at: Date.now()});
const next = `${window.location.pathname}${window.location.search}${window.location.hash}`;
window.location.replace(`${side.target}/migrate/begin?next=${encodeURIComponent(next)}`);
startDomainMigrationFromSource(side);
}
async function accountIsEnrolled(token: string): Promise<boolean> {
try {
const response = await fetch(
`${RuntimeConfig.apiEndpoint}/v${RuntimeConfig.apiCodeVersion}${EXPERIMENT_ASSIGNMENTS_PATH}`,
{credentials: 'omit', headers: {Authorization: token}},
);
if (!response.ok) {
return false;
}
const parsed = ExperimentAssignmentsResponse.safeParse(await response.json());
return parsed.success && readDomainMigrationAssignment(parsed.data).enabled;
} catch {
return false;
}
}
async function probeStoredAccounts(): Promise<void> {
const storage = getProtectedLocalStorage();
const gate = readDomainMigrationGateInput(false, false);
if (
probing ||
DomainMigrationRollout.deviceEnrolled ||
gate.discovery?.enabled !== true ||
!environmentAllowsDomainMigration(gate.environment) ||
!markerAllowsDomainMigration(gate.marker, gate.now) ||
!domainMigrationProbeIsDue(storage, gate.now)
) {
return;
}
const activeUserId = SessionManager.userId;
const tokens = SessionManager.accounts
.filter((account) => account.userId !== activeUserId && account.isValid !== false && Boolean(account.token))
.map((account) => account.token);
if (tokens.length === 0) {
return;
}
probing = true;
markDomainMigrationProbed(storage, gate.now);
try {
for (const token of tokens) {
if (await accountIsEnrolled(token)) {
DomainMigrationRollout.markDeviceEnrolled();
return;
}
}
} finally {
probing = false;
}
}
function readSourceGateState(): SourceGateState {
return {
assignmentReady: DomainMigrationRollout.assignmentReady,
assignmentEnabled: DomainMigrationRollout.assignmentEnabled,
deviceEnrolled: DomainMigrationRollout.deviceEnrolled,
userId: SessionManager.userId,
sessionSettled: isSessionSettled(),
voiceActive: isVoiceActive(),
};
}
function handleSourceGateState(side: DomainMigrationSide, state: SourceGateState): void {
if (state.assignmentEnabled && !state.deviceEnrolled) {
DomainMigrationRollout.markDeviceEnrolled();
return;
}
if (navigating || !state.sessionSettled || (!state.assignmentReady && !state.deviceEnrolled)) {
return;
}
if (!state.deviceEnrolled) {
probeStoredAccounts().catch((err) => {
logger.warn('Domain migration enrollment probe failed:', err);
});
return;
}
evaluateSource(side).catch((err) => {
logger.warn('Domain migration trigger failed:', err);
});
}
function offerNotificationReenable(): void {
@@ -92,6 +170,27 @@ function offerNotificationReenable(): void {
}
}
function reloadAfterSiblingImport(event: StorageEvent): void {
if (
event.key === DOMAIN_MIGRATION_IMPORT_KEY &&
readDomainMigrationImport(getProtectedLocalStorage())?.state === 'done'
) {
window.location.reload();
}
}
export function probeSignedOutDeviceEnrollment(): void {
if (typeof window === 'undefined' || SessionManager.isAuthenticated) {
return;
}
if (resolveDomainMigrationSide(window.location.origin)?.role !== 'source') {
return;
}
probeStoredAccounts().catch((err) => {
logger.warn('Domain migration enrollment probe failed:', err);
});
}
export function startDomainMigrationTrigger(): void {
if (started || typeof window === 'undefined') {
return;
@@ -103,14 +202,11 @@ export function startDomainMigrationTrigger(): void {
started = true;
if (side.role === 'target') {
setTimeout(offerNotificationReenable, 0);
window.addEventListener('storage', reloadAfterSiblingImport);
return;
}
when(
() => ExperimentAssignments.response !== INERT_EXPERIMENT_ASSIGNMENTS_RESPONSE,
() => {
evaluateSource(side, DomainMigrationRollout.enabled).catch((err) => {
logger.warn('Domain migration trigger failed:', err);
});
},
);
reaction(readSourceGateState, (state) => handleSourceGateState(side, state), {
fireImmediately: true,
equals: compareShallow,
});
}
@@ -0,0 +1,44 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {solveChallengeWorkers} from 'altcha-lib';
import type {Challenge} from 'altcha-lib/types';
export type AltchaChallenge = Challenge;
const MAX_SOLVER_WORKERS = 8;
const SOLVE_TIMEOUT_MS = 120_000;
function createSolverWorker(): Worker {
return new Worker(
new URL(/* webpackChunkName: "altcha-solver.worker" */ './AltchaSolverWorker.ts', import.meta.url),
{
type: 'module',
},
);
}
export function readAltchaChallenge(body: unknown): AltchaChallenge | null {
if (typeof body !== 'object' || body === null) return null;
const {captcha_provider: provider, altcha_challenge: challenge} = body as Record<string, unknown>;
if (provider !== 'altcha' || typeof challenge !== 'object' || challenge === null) return null;
const {parameters, signature} = challenge as Record<string, unknown>;
if (typeof parameters !== 'object' || parameters === null || typeof signature !== 'string') return null;
return challenge as AltchaChallenge;
}
export async function solveAltchaChallenge(
challenge: AltchaChallenge,
controller: AbortController,
): Promise<string | null> {
const solution = await solveChallengeWorkers({
challenge,
concurrency: Math.min(MAX_SOLVER_WORKERS, navigator.hardwareConcurrency || 2),
controller,
createWorker: createSolverWorker,
timeout: SOLVE_TIMEOUT_MS,
});
if (!solution) return null;
return btoa(
JSON.stringify({challenge: {parameters: challenge.parameters, signature: challenge.signature}, solution}),
);
}
@@ -0,0 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {deriveKey} from 'altcha-lib/algorithms/web/pbkdf2';
import {handler} from 'altcha-lib/workers/shared';
handler({deriveKey});
@@ -0,0 +1,16 @@
/* SPDX-License-Identifier: AGPL-3.0-or-later */
.container {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.75rem;
padding: 1rem 0;
}
.text {
font-size: 0.875rem;
line-height: 1.25rem;
text-align: center;
color: var(--text-secondary);
}
@@ -0,0 +1,66 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {type AltchaChallenge, solveAltchaChallenge} from '@app/features/auth/altcha/AltchaSolver';
import styles from '@app/features/auth/components/AltchaVerification.module.css';
import {Logger} from '@app/features/platform/utils/AppLogger';
import {Button} from '@app/features/ui/button/Button';
import {Spinner} from '@app/features/ui/components/Spinner';
import {Trans} from '@lingui/react/macro';
import {useCallback, useEffect, useRef, useState} from 'react';
const logger = new Logger('AltchaVerification');
interface AltchaVerificationProps {
challenge: AltchaChallenge;
onVerify: (token: string) => void;
}
export function AltchaVerification({challenge, onVerify}: AltchaVerificationProps) {
const onVerifyRef = useRef(onVerify);
const [attempt, setAttempt] = useState(0);
const [failed, setFailed] = useState(false);
useEffect(() => {
onVerifyRef.current = onVerify;
}, [onVerify]);
useEffect(() => {
const controller = new AbortController();
setFailed(false);
solveAltchaChallenge(challenge, controller).then(
(token) => {
if (controller.signal.aborted) return;
if (token) {
onVerifyRef.current(token);
} else {
setFailed(true);
}
},
(error: unknown) => {
if (controller.signal.aborted) return;
logger.error('ALTCHA solve failed:', error);
setFailed(true);
},
);
return () => controller.abort();
}, [challenge, attempt]);
const handleRetry = useCallback(() => setAttempt((value) => value + 1), []);
if (failed) {
return (
<div className={styles.container} data-flx="auth.altcha-verification.failed">
<p className={styles.text} data-flx="auth.altcha-verification.failed-text">
<Trans>Your browser couldn't finish the check.</Trans>
</p>
<Button small variant="secondary" onClick={handleRetry} data-flx="auth.altcha-verification.retry-button">
<Trans>Try again</Trans>
</Button>
</div>
);
}
return (
<div className={styles.container} role="status" aria-live="polite" data-flx="auth.altcha-verification.solving">
<Spinner data-flx="auth.altcha-verification.spinner" />
<p className={styles.text} data-flx="auth.altcha-verification.solving-text">
<Trans>Checking your browser. This takes a few seconds.</Trans>
</p>
</div>
);
}
@@ -1,5 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {type AltchaChallenge, readAltchaChallenge} from '@app/features/auth/altcha/AltchaSolver';
import {CaptchaModal, type CaptchaType} from '@app/features/auth/components/modals/CaptchaModal';
import {http} from '@app/features/platform/transport/RestTransport';
import type {RestResponse} from '@app/features/platform/types/TransportTypes';
@@ -69,7 +70,7 @@ class CaptchaInterceptorState {
return code === 'CAPTCHA_REQUIRED' || code === 'INVALID_CAPTCHA';
}
private showCaptchaModal(): Promise<CaptchaResult> {
private showCaptchaModal(altchaChallenge: AltchaChallenge | null): Promise<CaptchaResult> {
if (this.pendingPromise) {
this.pendingPromise.reject(new Error('Captcha cancelled'));
this.pendingPromise = null;
@@ -95,6 +96,7 @@ class CaptchaInterceptorState {
};
const CaptchaModalWrapper = observer(() => (
<CaptchaModal
altchaChallenge={altchaChallenge}
onVerify={handleVerify}
onCancel={handleCancel}
error={this.state.error}
@@ -119,7 +121,7 @@ class CaptchaInterceptorState {
const errorMessage = replyMessage(reply.body) || i18n._(CAPTCHA_VERIFICATION_FAILED_PLEASE_TRY_AGAIN_DESCRIPTOR);
this.state.setError(errorMessage);
this.state.setIsVerifying(false);
const promise = this.showCaptchaModal()
const promise = this.showCaptchaModal(readAltchaChallenge(reply.body))
.then((captchaResult) => {
this.state.setError(null);
this.state.setIsVerifying(false);
@@ -23,6 +23,7 @@
min-width: 0;
padding: 0.5rem 0.75rem;
border-radius: 0.75rem;
text-align: start;
background-color: var(--background-secondary);
border: 0.0625rem solid var(--background-modifier-accent);
}
@@ -71,7 +71,7 @@ export const AccountRow = observer(
const variantClassName = variant === 'manage' ? styles.manage : variant === 'compact' ? styles.compact : undefined;
const isClickable = typeof onClick === 'function';
const MainButtonComponent = isClickable ? 'button' : 'div';
const showMenuButton = Boolean(onMenuClick && variant !== 'compact' && !showCaretIndicator);
const showMenuButton = Boolean(onMenuClick && variant !== 'compact');
return (
<div
className={clsx(styles.row, variantClassName, showMenuButton && styles.withMenu, className)}
@@ -32,10 +32,6 @@
text-align: center;
}
.accountListWrapper {
min-height: 7.5rem;
}
.scroller {
max-height: 17.5rem;
}
@@ -18,7 +18,7 @@ import {modal} from '@app/features/ui/commands/ModalCommands';
import {Scroller} from '@app/features/ui/components/Scroller';
import {msg} from '@lingui/core/macro';
import {Trans, useLingui} from '@lingui/react/macro';
import {PlusIcon, SignOutIcon} from '@phosphor-icons/react';
import {PlusIcon, SignInIcon, SignOutIcon} from '@phosphor-icons/react';
import {observer} from 'mobx-react-lite';
import type React from 'react';
import {useCallback} from 'react';
@@ -101,17 +101,20 @@ export const AccountSelector = observer(
(account: Account) => (event: React.MouseEvent<HTMLButtonElement>) => {
event.preventDefault();
event.stopPropagation();
if (disabled) {
return;
}
ContextMenuCommands.openFromEvent(event, (props) => (
<MenuGroup data-flx="auth.accounts.account-selector.open-menu.menu-group">
<MenuItem
icon={<SignOutIcon size={18} data-flx="auth.accounts.account-selector.open-menu.sign-out-icon" />}
icon={<SignInIcon size={18} data-flx="auth.accounts.account-selector.open-menu.sign-in-icon" />}
onClick={() => {
props.onClose();
onSelectAccount(account);
}}
data-flx="auth.accounts.account-selector.open-menu.menu-item.close"
>
<Trans>Select account</Trans>
{account.isValid === false ? <Trans>Sign in again</Trans> : <Trans>Select account</Trans>}
</MenuItem>
<MenuItem
danger
@@ -127,7 +130,7 @@ export const AccountSelector = observer(
</MenuGroup>
));
},
[openSignOutConfirm, onSelectAccount],
[disabled, openSignOutConfirm, onSelectAccount],
);
return (
<div className={styles.container} data-flx="auth.accounts.account-selector.container">
@@ -142,38 +145,36 @@ export const AccountSelector = observer(
{error}
</div>
)}
<div className={styles.accountListWrapper} data-flx="auth.accounts.account-selector.account-list-wrapper">
{accounts.length === 0 ? (
<div className={styles.noAccounts} data-flx="auth.accounts.account-selector.no-accounts">
<Trans>No accounts</Trans>
{accounts.length === 0 ? (
<div className={styles.noAccounts} data-flx="auth.accounts.account-selector.no-accounts">
<Trans>No accounts</Trans>
</div>
) : (
<Scroller
className={styles.scroller}
key={scrollerKey ?? 'account-selector-scroller'}
data-flx="auth.accounts.account-selector.scroller"
>
<div className={styles.accountList} data-flx="auth.accounts.account-selector.account-list">
{accounts.map((account) => {
const isCurrent = account.userId === currentAccountId;
return (
<AccountRow
key={account.userId}
account={account}
variant="manage"
isCurrent={isCurrent}
isExpired={account.isValid === false}
onClick={clickableRows && !disabled ? () => onSelectAccount(account) : undefined}
showCaretIndicator={clickableRows && isCurrent}
onMenuClick={isCurrent ? undefined : openMenu(account)}
data-flx="auth.accounts.account-selector.account-row.select-account"
/>
);
})}
</div>
) : (
<Scroller
className={styles.scroller}
key={scrollerKey ?? 'account-selector-scroller'}
data-flx="auth.accounts.account-selector.scroller"
>
<div className={styles.accountList} data-flx="auth.accounts.account-selector.account-list">
{accounts.map((account) => {
const isCurrent = account.userId === currentAccountId;
return (
<AccountRow
key={account.userId}
account={account}
variant="manage"
isCurrent={isCurrent}
isExpired={account.isValid === false}
onClick={clickableRows && !disabled ? () => onSelectAccount(account) : undefined}
showCaretIndicator={clickableRows}
onMenuClick={!clickableRows && !disabled ? openMenu(account) : undefined}
data-flx="auth.accounts.account-selector.account-row.select-account"
/>
);
})}
</div>
</Scroller>
)}
</div>
</Scroller>
)}
{onAddAccount && (
<Button
variant="secondary"
@@ -2,6 +2,8 @@
import * as Modal from '@app/features/app/components/dialogs/Modal';
import RuntimeConfig from '@app/features/app/state/RuntimeConfig';
import type {AltchaChallenge} from '@app/features/auth/altcha/AltchaSolver';
import {AltchaVerification} from '@app/features/auth/components/AltchaVerification';
import styles from '@app/features/auth/components/modals/CaptchaModal.module.css';
import {TurnstileWidget} from '@app/features/auth/components/TurnstileWidget';
import {Logger} from '@app/features/platform/utils/AppLogger';
@@ -18,7 +20,7 @@ const VERIFY_YOU_RE_HUMAN_DESCRIPTOR = msg({
});
const logger = new Logger('CaptchaModal');
export type CaptchaType = 'turnstile' | 'hcaptcha';
export type CaptchaType = 'turnstile' | 'hcaptcha' | 'altcha';
interface HCaptchaComponentProps {
sitekey: string;
@@ -35,16 +37,26 @@ interface CaptchaModalProps {
onVerify: (token: string, captchaType: CaptchaType) => void;
onCancel?: () => void;
preferredType?: CaptchaType;
altchaChallenge?: AltchaChallenge | null;
error?: string | null;
isVerifying?: boolean;
closeOnVerify?: boolean;
}
export const CaptchaModal = observer(
({onVerify, onCancel, preferredType, error, isVerifying, closeOnVerify = true}: CaptchaModalProps) => {
({
onVerify,
onCancel,
preferredType,
altchaChallenge,
error,
isVerifying,
closeOnVerify = true,
}: CaptchaModalProps) => {
const {i18n} = useLingui();
const hcaptchaRef = useRef<HCaptcha>(null);
const [captchaType, setCaptchaType] = useState<CaptchaType>(() => {
if (altchaChallenge) return 'altcha';
if (preferredType) return preferredType;
if (RuntimeConfig.captchaProvider === 'turnstile' && RuntimeConfig.turnstileSiteKey) {
return 'turnstile';
@@ -123,7 +135,13 @@ export const CaptchaModal = observer(
</div>
)}
<div className={styles.captchaContainer} data-flx="auth.captcha-modal.captcha-container">
{captchaType === 'turnstile' ? (
{captchaType === 'altcha' && altchaChallenge ? (
<AltchaVerification
challenge={altchaChallenge}
onVerify={handleVerify}
data-flx="auth.captcha-modal.altcha-verification"
/>
) : captchaType === 'turnstile' ? (
<TurnstileWidget
sitekey={RuntimeConfig.turnstileSiteKey ?? ''}
onVerify={handleVerify}
@@ -66,6 +66,7 @@ const LoginPageMFA = observer(function LoginPageMFA() {
const handleMfaSuccess = useCallback(
async (payload: LoginSuccessPayload) => {
if (isHandoff) {
await AccountManager.refreshStoredAccount(payload.userId, payload.token, payload.userData);
await handoff.start(payload);
return;
} else {
@@ -141,6 +141,7 @@ export const OAuthAuthorizeFlowPanel: React.FC<OAuthAuthorizeFlowPanelProps> = o
<OAuthScopesStep
authParams={authParams}
botInviteWithoutRedirect={flow.botInviteWithoutRedirect}
cannotSubmit={flow.cannotSubmit}
clientLabel={flow.clientLabel}
hasNextStep={flow.hasNextStep}
hasPreviousStep={flow.hasPreviousStep}
@@ -29,6 +29,7 @@ const REQUIRED_DESCRIPTOR = msg({
interface OAuthScopesStepProps {
authParams: AuthorizeParams;
botInviteWithoutRedirect: boolean;
cannotSubmit: boolean;
clientLabel: string;
hasNextStep: boolean;
hasPreviousStep: boolean;
@@ -51,6 +52,7 @@ interface OAuthScopesStepProps {
export const OAuthScopesStep: React.FC<OAuthScopesStepProps> = ({
authParams,
botInviteWithoutRedirect,
cannotSubmit,
clientLabel,
hasNextStep,
hasPreviousStep,
@@ -151,10 +153,16 @@ export const OAuthScopesStep: React.FC<OAuthScopesStepProps> = ({
})
)}
</div>
{scopesAdjusted && (
<div className={styles.caution} data-flx="auth.o-auth-authorize-page.caution--2">
<Trans>Turning off scopes may prevent the app from working correctly.</Trans>
{scopes.length > 0 && selectedScopes.size === 0 ? (
<div className={styles.caution} data-flx="auth.o-auth-authorize-page.caution--no-scopes">
<Trans>Turn on at least one scope to authorize this app.</Trans>
</div>
) : (
scopesAdjusted && (
<div className={styles.caution} data-flx="auth.o-auth-authorize-page.caution--2">
<Trans>Turning off scopes may prevent the app from working correctly.</Trans>
</div>
)
)}
</div>
</div>
@@ -165,6 +173,7 @@ export const OAuthScopesStep: React.FC<OAuthScopesStepProps> = ({
showRedirectNotice={showRedirectNotice}
hasPreviousStep={hasPreviousStep}
hasNextStep={hasNextStep}
authorizeDisabled={cannotSubmit}
onAuthorize={onAuthorize}
onBack={onBack}
onCancel={onCancel}
@@ -306,7 +306,7 @@ export function useAuthorizeFlow(options: UseAuthorizeFlowOptions = {}): Authori
() => destinations.options.find((option) => option.value === selectedDestinationKey) ?? null,
[destinations.options, selectedDestinationKey],
);
const cannotSubmit = hasBotScope && !selectedDestination;
const cannotSubmit = scopeSelection.selected.size === 0 || (hasBotScope && !selectedDestination);
const needsPermissionsStep =
hasBotScope && selectedDestination?.kind !== 'group_dm' && permissionSelection.requestedKeys.length > 0;
const hasRequestedBotPermissions =
@@ -350,9 +350,9 @@ export function useAuthorizeFlow(options: UseAuthorizeFlowOptions = {}): Authori
setSubmitError(null);
setSubmitting('approve');
try {
const scopeToSend = scopeSelection.toScopeString() || params.scope;
const scopeToSend = scopeSelection.toScopeString();
const sendsBotScope = scopeToSend.split(/[\s+]+/).includes('bot');
if (sendsBotScope && !selectedDestination) {
if (!scopeToSend || (sendsBotScope && !selectedDestination)) {
setSubmitting(null);
return;
}
@@ -50,7 +50,7 @@ import {SteppedCarousel} from '@app/features/ui/stepped_carousel/SteppedCarousel
import {isDesktop} from '@app/features/ui/utils/NativeUtils';
import * as FormUtils from '@app/lib/forms';
import {msg} from '@lingui/core/macro';
import {useLingui} from '@lingui/react/macro';
import {Trans, useLingui} from '@lingui/react/macro';
import clsx from 'clsx';
import {observer} from 'mobx-react-lite';
import {cloneElement, type ReactElement, type ReactNode, useCallback, useEffect, useMemo, useState} from 'react';
@@ -151,14 +151,19 @@ export const AuthLoginLayout = observer(function AuthLoginLayout({
const [switchError, setSwitchError] = useState<string | null>(null);
const [prefillEmail, setPrefillEmail] = useState<string | null>(() => initialEmail ?? null);
const ssoRedirectPath = desktopHandoff ? `${location.pathname}${location.search}` : redirectPath;
const showLoginFormForAccount = useCallback((account: Account, message?: string | null) => {
setShowAccountSelector(false);
setSwitchError(message ?? null);
setPrefillEmail(account.userData?.email ?? null);
}, []);
const showExpiredLoginForm = useCallback(
(account: Account) => {
const identifier = account.userData?.email ?? account.userData?.username ?? account.userId;
setShowAccountSelector(false);
setSwitchError(i18n._(SESSION_EXPIRED_SIGN_IN_AGAIN_DESCRIPTOR, {identifier}));
setPrefillEmail(account.userData?.email ?? null);
},
[i18n],
);
const handleLoginSuccess = useCallback(
async (payload: LoginSuccessPayload) => {
if (desktopHandoff) {
await AccountManager.refreshStoredAccount(payload.userId, payload.token, payload.userData);
await handoff.start(payload);
return;
}
@@ -235,20 +240,26 @@ export const AuthLoginLayout = observer(function AuthLoginLayout({
}, [form.setValue, prefillEmail]);
const handleSelectExistingAccount = useCallback(
async (account: Account) => {
const identifier = account.userData?.email ?? account.userData?.username ?? account.userId;
const expiredMessage = i18n._(SESSION_EXPIRED_SIGN_IN_AGAIN_DESCRIPTOR, {identifier});
if (account.isValid === false || !AccountManager.canSwitchAccounts) {
showLoginFormForAccount(account, expiredMessage);
if (account.isValid === false) {
showExpiredLoginForm(account);
return;
}
setIsSwitching(true);
setSwitchError(null);
try {
await AccountManager.switchToAccount(account.userId);
if (AccountManager.canSwitchAccounts) {
await AccountManager.switchToAccount(account.userId);
} else {
const {token, userId} = await AccountManager.generateTokenForAccount(account.userId);
await handleLoginSuccess({token, userId, userData: account.userData});
if (redirectPath) {
RouterUtils.replaceWith(redirectPath);
}
}
} catch (error) {
const updatedAccount = AccountManager.accounts.get(account.userId);
if (error instanceof SessionExpiredError || updatedAccount?.isValid === false) {
showLoginFormForAccount(updatedAccount ?? account, expiredMessage);
showExpiredLoginForm(updatedAccount ?? account);
return;
}
setSwitchError(
@@ -260,8 +271,26 @@ export const AuthLoginLayout = observer(function AuthLoginLayout({
setIsSwitching(false);
}
},
[i18n, showLoginFormForAccount],
[handleLoginSuccess, i18n, redirectPath, showExpiredLoginForm],
);
const handleHandoffReLogin = useCallback(
(account: Account) => {
showExpiredLoginForm(account);
handoff.switchToLogin();
},
[handoff, showExpiredLoginForm],
);
const canChooseSavedAccount = desktopHandoff ? hasHandoffAccounts : hasStoredAccounts;
const handleChooseSavedAccount = useCallback(() => {
setSwitchError(null);
setPrefillEmail(initialEmail ?? null);
form.setValue('email', initialEmail ?? '');
if (desktopHandoff) {
handoff.setMode('selecting');
} else {
setShowAccountSelector(true);
}
}, [desktopHandoff, form.setValue, handoff, initialEmail]);
const handleAddAnotherAccount = useCallback(() => {
setShowAccountSelector(false);
setSwitchError(null);
@@ -292,13 +321,21 @@ export const AuthLoginLayout = observer(function AuthLoginLayout({
});
}, [registerLink]);
const authLoginStep: AuthLoginStep = useMemo(() => {
if (desktopHandoff && handoff.mode === 'selecting') return 'desktop_handoff_account';
if (desktopHandoff && handoff.mode === 'selecting' && hasHandoffAccounts) return 'desktop_handoff_account';
if (desktopHandoff && isApprovalFlowMode(handoff.mode)) return 'desktop_handoff_approval';
if (isSsoEnforced) return 'sso';
if (showAccountSelector && hasStoredAccounts && !desktopHandoff) return 'account';
if (ipAuthChallenge) return 'ip_authorization';
return 'credentials';
}, [desktopHandoff, handoff.mode, hasStoredAccounts, ipAuthChallenge, isSsoEnforced, showAccountSelector]);
}, [
desktopHandoff,
handoff.mode,
hasHandoffAccounts,
hasStoredAccounts,
ipAuthChallenge,
isSsoEnforced,
showAccountSelector,
]);
const hasExtraTopContent = extraTopContent !== undefined && extraTopContent !== null;
const startedFromAccountSelector = hasStoredAccounts && !desktopHandoff && !initialEmail;
const showSplitLogo =
@@ -316,6 +353,7 @@ export const AuthLoginLayout = observer(function AuthLoginLayout({
<DesktopHandoffAccountSelector
excludeCurrentUser={excludeCurrentUser}
onSelectNewAccount={handoff.switchToLogin}
onReLoginAccount={handleHandoffReLogin}
onAccountSelected={handoff.start}
data-flx="auth.flow.auth-login-layout.desktop-handoff-account-selector"
/>
@@ -443,6 +481,18 @@ export const AuthLoginLayout = observer(function AuthLoginLayout({
data-flx="auth.flow.auth-login-layout.auth-login-passkey-actions"
/>
<div className={styles.footer} data-flx="auth.flow.auth-login-layout.footer">
{canChooseSavedAccount ? (
<div className={styles.footerText} data-flx="auth.flow.auth-login-layout.footer-text--choose-account">
<button
type="button"
className={styles.footerLink}
onClick={handleChooseSavedAccount}
data-flx="auth.flow.auth-login-layout.button.choose-saved-account"
>
<Trans>Choose an account</Trans>
</button>
</div>
) : null}
<div className={styles.footerText} data-flx="auth.flow.auth-login-layout.footer-text">
<span className={styles.footerLabel} data-flx="auth.flow.auth-login-layout.footer-label">
{i18n._(NEED_ACCOUNT_DESCRIPTOR)}{' '}
@@ -9,11 +9,6 @@ import {Trans, useLingui} from '@lingui/react/macro';
import {observer} from 'mobx-react-lite';
import {useCallback, useState} from 'react';
const SESSION_EXPIRED_PLEASE_SIGN_IN_AGAIN_DESCRIPTOR = msg({
message: 'Session expired. Sign in again.',
comment:
'Toast error shown in the desktop-handoff account picker when the session for the chosen account has expired.',
});
const FAILED_TO_GENERATE_TOKEN_DESCRIPTOR = msg({
message: 'Failed to generate token',
comment: 'Short label in the authentication desktop handoff account selector. Keep the tone plain and specific.',
@@ -22,12 +17,14 @@ const FAILED_TO_GENERATE_TOKEN_DESCRIPTOR = msg({
interface DesktopHandoffAccountSelectorProps {
excludeCurrentUser?: boolean;
onSelectNewAccount: () => void;
onReLoginAccount: (account: Account) => void;
onAccountSelected: (payload: {token: string; userId: string}) => void;
}
const DesktopHandoffAccountSelector = observer(function DesktopHandoffAccountSelector({
excludeCurrentUser = false,
onSelectNewAccount,
onReLoginAccount,
onAccountSelected,
}: DesktopHandoffAccountSelectorProps) {
const {i18n} = useLingui();
@@ -38,6 +35,10 @@ const DesktopHandoffAccountSelector = observer(function DesktopHandoffAccountSel
const accounts = excludeCurrentUser ? allAccounts.filter((account) => account.userId !== currentUserId) : allAccounts;
const handleSelectAccount = useCallback(
async (account: Account) => {
if (account.isValid === false) {
onReLoginAccount(account);
return;
}
setIsLoading(true);
setError(null);
try {
@@ -48,7 +49,7 @@ const DesktopHandoffAccountSelector = observer(function DesktopHandoffAccountSel
onAccountSelected({token, userId});
} catch (err) {
if (err instanceof SessionExpiredError) {
setError(i18n._(SESSION_EXPIRED_PLEASE_SIGN_IN_AGAIN_DESCRIPTOR));
onReLoginAccount(AccountManager.accounts.get(account.userId) ?? account);
} else {
setError(
err && typeof err === 'object' && 'body' in err
@@ -60,11 +61,12 @@ const DesktopHandoffAccountSelector = observer(function DesktopHandoffAccountSel
setIsLoading(false);
}
},
[onAccountSelected, i18n],
[onAccountSelected, onReLoginAccount, i18n],
);
return (
<AccountSelector
accounts={accounts}
currentAccountId={currentUserId}
title={<Trans>Choose an account</Trans>}
description={<Trans>Select the account you want to sign in with on your new device.</Trans>}
disabled={isLoading}
@@ -2,11 +2,12 @@
import {Routes} from '@app/app/Routes';
import type {UserData} from '@app/features/auth/state/AccountStorage';
import ExperimentAssignments from '@app/features/experiment/state/ExperimentAssignments';
import GatewayConnection from '@app/features/gateway/transport/GatewayConnection';
import * as RouterUtils from '@app/features/navigation/utils/RouterUtils';
import * as NotificationUtils from '@app/features/notification/utils/NotificationUtils';
import * as PushSubscriptionService from '@app/features/platform/push/PushSubscriptionService';
import SessionManager, {type Account, SessionExpiredError} from '@app/features/platform/state/AuthSession';
import SessionManager, {type Account} from '@app/features/platform/state/AuthSession';
import {Logger} from '@app/features/platform/utils/AppLogger';
import {isInstalledPwa} from '@app/features/ui/utils/PwaUtils';
import MediaEngine from '@app/features/voice/engine/MediaEngineFacade';
@@ -15,6 +16,8 @@ import {computed, makeAutoObservable} from 'mobx';
const logger = new Logger('AccountManager');
class AccountManager {
transitioning = false;
constructor() {
makeAutoObservable(
this,
@@ -82,16 +85,27 @@ class AccountManager {
token: string;
userId: string;
}> {
await SessionManager.initialize();
const account = SessionManager.requireAccountOnCurrentInstance(userId);
const ok = await SessionManager.validateToken(account.token);
if (!ok) {
SessionManager.markAccountInvalid(userId);
throw new SessionExpiredError();
}
const account = await SessionManager.requireUsableAccount(userId);
return {token: account.token, userId};
}
async refreshStoredAccount(userId: string, token: string, userData?: UserData): Promise<void> {
await SessionManager.refreshStoredAccount(userId, token, userData);
}
private setTransitioning(value: boolean): void {
this.transitioning = value;
}
private async runTransition(run: () => Promise<void>): Promise<void> {
this.setTransitioning(true);
try {
await run();
} finally {
this.setTransitioning(false);
}
}
private async leaveActiveVoiceChannel(context: 'account switch' | 'logout'): Promise<void> {
try {
await MediaEngine.disconnectFromVoiceChannel('user');
@@ -108,11 +122,14 @@ class AccountManager {
if (!SessionManager.canSwitchAccount()) {
throw new Error(`Cannot switch from state: ${SessionManager.state}`);
}
await SessionManager.requireUsableAccount(userId);
if (this.shouldManagePushSubscriptions()) {
await PushSubscriptionService.unregisterAllPushSubscriptions();
}
await this.leaveActiveVoiceChannel('account switch');
await SessionManager.switchAccount(userId);
await this.runTransition(async () => {
await this.leaveActiveVoiceChannel('account switch');
await SessionManager.switchAccount(userId);
});
GatewayConnection.startSession(SessionManager.token ?? undefined);
if (redirectPath !== null) {
RouterUtils.replaceWith(redirectPath);
@@ -135,8 +152,10 @@ class AccountManager {
if (SessionManager.userId && SessionManager.userId !== userId) {
SessionManager.prepareForAccountTransition('account-switch');
}
await this.leaveActiveVoiceChannel('account switch');
await SessionManager.login(token, userId, userData);
await this.runTransition(async () => {
await this.leaveActiveVoiceChannel('account switch');
await SessionManager.login(token, userId, userData);
});
GatewayConnection.startSession(token);
if (redirectPath !== null) {
RouterUtils.replaceWith(redirectPath);
@@ -159,8 +178,11 @@ class AccountManager {
}
async logout(): Promise<void> {
await this.leaveActiveVoiceChannel('logout');
await SessionManager.logout();
await this.runTransition(async () => {
await this.leaveActiveVoiceChannel('logout');
await SessionManager.logout();
});
ExperimentAssignments.reset();
RouterUtils.replaceWith('/login');
}
}
@@ -1,5 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {isDomainMigrationStorageKey} from '@app/features/app/domain_migration/DomainMigrationCore';
import {
normalizeAppPublicConfig,
normalizeInstanceRegistration,
@@ -69,7 +70,7 @@ const MANAGED_KEY_PREFIXES: ReadonlyArray<string> = ['mobx', 'mobx-persist', 'pe
const MANAGED_KEY_PREFIX_PATTERN = new RegExp(`^(?:${MANAGED_KEY_PREFIXES.join('|')})`);
function isManagedKey(key: string): boolean {
if (!key) {
if (!key || isDomainMigrationStorageKey(key)) {
return false;
}
return MANAGED_KEY_EXACT.has(key) || MANAGED_KEY_PREFIX_PATTERN.test(key);
@@ -196,6 +197,9 @@ class AccountStorage {
}
const keysToRemove = collectManagedKeys(browserLocalStorage).filter((key) => snapshot[key] === undefined);
for (const [key, value] of Object.entries(snapshot)) {
if (isDomainMigrationStorageKey(key)) {
continue;
}
try {
browserLocalStorage.setItem(key, value);
} catch (err) {
@@ -500,14 +504,30 @@ class AccountStorage {
}
}
async updateAccountValidity(userId: string, isValid: boolean): Promise<void> {
async refreshAccountCredentials(userId: string, token: string, userData?: UserData): Promise<void> {
await this.ensureDb();
if (!userId || !token) {
return;
}
try {
const record = await this.getRecord(userId);
if (!record) {
return;
}
await this.putRecord({...record, token, userData: userData ?? record.userData, isValid: true});
} catch (err) {
logger.error(`Failed to refresh credentials for account ${userId}`, err);
}
}
async updateAccountValidity(userId: string, isValid: boolean, expectedToken?: string): Promise<void> {
await this.ensureDb();
if (!userId) {
return;
}
try {
const record = await this.getRecord(userId);
if (!record) {
if (!record || (expectedToken !== undefined && record.token !== expectedToken)) {
return;
}
await this.putRecord({...record, isValid});
@@ -2,9 +2,12 @@
import {ConfirmModal} from '@app/features/app/components/dialogs/ConfirmModal';
import {PRODUCT_NAME} from '@app/features/app/config/I18nDisplayConstants';
import type {DomainMigrationMediaDeviceKind} from '@app/features/app/domain_migration/DomainMigrationCore';
import {remapMigratedDeviceIds} from '@app/features/app/domain_migration/DomainMigrationDeviceRemap';
import styles from '@app/features/auth/state/NewDeviceMonitoring.module.css';
import {
getNewDevicePromptCandidates,
getRealDeviceIds,
type PendingDevicePrompt,
} from '@app/features/auth/state/NewDeviceMonitoringDevices';
import {Logger} from '@app/features/platform/utils/AppLogger';
@@ -32,6 +35,9 @@ const NOT_NOW_DESCRIPTOR = msg({
message: 'Not now',
comment: 'Short label in the authentication new device monitoring. Keep the tone plain and specific.',
});
const NEW_DEVICE_MODAL_KEY = 'new-audio-device';
const MAX_PROMPTABLE_NEW_DEVICE_GROUPS = 2;
const MIGRATED_AUDIO_DEVICE_KINDS: ReadonlyArray<DomainMigrationMediaDeviceKind> = ['audioinput', 'audiooutput'];
const logger = new Logger('NewDeviceMonitoring');
interface IgnoreDeviceLinkProps {
@@ -60,8 +66,6 @@ class NewDeviceMonitoring {
private isStarted = false;
private startPromise: Promise<void> | null = null;
private startEpoch = 0;
private pendingPrompts: Array<PendingDevicePrompt> = [];
private isShowingPrompt = false;
private unsubscribe: (() => void) | null = null;
private i18n: I18n | null = null;
@@ -91,7 +95,9 @@ class NewDeviceMonitoring {
this.isStarted = true;
const epoch = ++this.startEpoch;
this.startPromise = (async () => {
await makePersistent(this, 'NewDeviceMonitoring', ['knownDeviceIds', 'ignoredDeviceIds', 'suppressAlerts']);
await makePersistent(this, 'NewDeviceMonitoring', ['knownDeviceIds', 'ignoredDeviceIds', 'suppressAlerts'], {
syncAcrossTabs: true,
});
if (!this.isStarted || epoch !== this.startEpoch) return;
await this.refreshDeviceSnapshot();
if (!this.isStarted || epoch !== this.startEpoch) return;
@@ -102,6 +108,12 @@ class NewDeviceMonitoring {
private handleDeviceStateChange(state: VoiceDeviceState): void {
if (!this.isStarted) return;
const ignoredDeviceIds = remapMigratedDeviceIds(this.ignoredDeviceIds, MIGRATED_AUDIO_DEVICE_KINDS);
if (ignoredDeviceIds !== this.ignoredDeviceIds) {
runInAction(() => {
this.ignoredDeviceIds = ignoredDeviceIds;
});
}
if (state.permissionStatus.audio !== 'granted') {
return;
}
@@ -112,27 +124,34 @@ class NewDeviceMonitoring {
const currentOutputIds = state.outputDevices.map((d) => d.deviceId);
const allCurrentIds = [...currentInputIds, ...currentOutputIds];
if (!this.isInitialized) {
const promptCandidates =
this.knownDeviceIds.length > 0
? getNewDevicePromptCandidates(state, this.knownDeviceIds, this.ignoredDeviceIds, {
inputDeviceId: VoiceSettings.getInputDeviceId(),
outputDeviceId: VoiceSettings.getOutputDeviceId(),
})
: [];
const knownDeviceIdSet = new Set(this.knownDeviceIds);
const realDeviceIds = getRealDeviceIds(state);
const isFreshIdSpace =
realDeviceIds.length > 0 && !realDeviceIds.some((deviceId) => knownDeviceIdSet.has(deviceId));
const knownDeviceIds = isFreshIdSpace
? this.knownDeviceIds
: remapMigratedDeviceIds(this.knownDeviceIds, MIGRATED_AUDIO_DEVICE_KINDS);
const promptCandidates = isFreshIdSpace
? []
: getNewDevicePromptCandidates(state, knownDeviceIds, this.ignoredDeviceIds, {
inputDeviceId: VoiceSettings.getInputDeviceId(),
outputDeviceId: VoiceSettings.getOutputDeviceId(),
});
runInAction(() => {
if (promptCandidates.length > 0) {
this.pendingPrompts.push(...promptCandidates);
this.knownDeviceIds = isFreshIdSpace
? [...new Set(allCurrentIds)]
: [...new Set([...knownDeviceIds, ...allCurrentIds])];
if (ignoredDeviceIds !== this.ignoredDeviceIds) {
this.ignoredDeviceIds = ignoredDeviceIds;
}
this.knownDeviceIds = [...new Set([...this.knownDeviceIds, ...allCurrentIds])];
this.isInitialized = true;
});
logger.debug('Initialized with known devices', {
count: this.knownDeviceIds.length,
isFreshIdSpace,
promptCount: promptCandidates.length,
});
if (promptCandidates.length > 0) {
this.processNextPrompt();
}
this.promptForNewDevice(promptCandidates);
return;
}
const promptCandidates = getNewDevicePromptCandidates(state, this.knownDeviceIds, this.ignoredDeviceIds, {
@@ -140,9 +159,6 @@ class NewDeviceMonitoring {
outputDeviceId: VoiceSettings.getOutputDeviceId(),
});
runInAction(() => {
if (promptCandidates.length > 0) {
this.pendingPrompts.push(...promptCandidates);
}
this.knownDeviceIds = [...new Set([...this.knownDeviceIds, ...allCurrentIds])];
});
if (promptCandidates.length > 0) {
@@ -153,21 +169,17 @@ class NewDeviceMonitoring {
deviceType: prompt.deviceType,
})),
});
this.processNextPrompt();
}
this.promptForNewDevice(promptCandidates);
}
private processNextPrompt(): void {
if (!this.isStarted) return;
if (this.isShowingPrompt || this.pendingPrompts.length === 0) {
private promptForNewDevice(promptCandidates: ReadonlyArray<PendingDevicePrompt>): void {
if (promptCandidates.length === 0 || promptCandidates.length > MAX_PROMPTABLE_NEW_DEVICE_GROUPS) {
return;
}
const prompt = this.pendingPrompts.shift();
if (!prompt) {
return;
}
this.isShowingPrompt = true;
this.showNewDeviceModal(prompt);
this.showNewDeviceModal(
promptCandidates.find((prompt) => prompt.inputDeviceId !== undefined) ?? promptCandidates[0],
);
}
private showNewDeviceModal(prompt: PendingDevicePrompt): void {
@@ -176,7 +188,7 @@ class NewDeviceMonitoring {
}
const i18n = this.i18n;
const {deviceIds, deviceName, deviceType, inputDeviceId, outputDeviceId} = prompt;
ModalCommands.push(
ModalCommands.pushWithKey(
modal(() => (
<ConfirmModal
title={i18n._(NEW_AUDIO_DEVICE_DETECTED_DESCRIPTOR)}
@@ -208,8 +220,7 @@ class NewDeviceMonitoring {
deviceName={deviceName}
onClick={() => {
this.addToIgnored(deviceIds);
ModalCommands.pop();
setTimeout(() => this.onModalClosed(), 0);
ModalCommands.popWithKey(NEW_DEVICE_MODAL_KEY);
}}
data-flx="auth.new-device-monitoring.ignore-device-link.add-to-ignored"
/>
@@ -225,25 +236,19 @@ class NewDeviceMonitoring {
if (dontAskAgain) {
this.addToIgnored(deviceIds);
}
setTimeout(() => this.onModalClosed(), 0);
}}
onSecondary={(dontAskAgain) => {
if (dontAskAgain) {
this.addToIgnored(deviceIds);
}
setTimeout(() => this.onModalClosed(), 0);
}}
data-flx="auth.new-device-monitoring.confirm-modal"
/>
)),
NEW_DEVICE_MODAL_KEY,
);
}
private onModalClosed(): void {
this.isShowingPrompt = false;
this.processNextPrompt();
}
private addToIgnored(deviceIds: string | ReadonlyArray<string>): void {
const ids = typeof deviceIds === 'string' ? [deviceIds] : deviceIds;
const newDeviceIds = ids.filter((deviceId) => !this.ignoredDeviceIds.includes(deviceId));
@@ -290,8 +295,6 @@ class NewDeviceMonitoring {
this.isStarted = false;
this.startPromise = null;
this.startEpoch++;
this.pendingPrompts = [];
this.isShowingPrompt = false;
if (this.unsubscribe) {
this.unsubscribe();
this.unsubscribe = null;
@@ -59,6 +59,12 @@ function isVirtualRouteDevice(device: MediaDeviceInfo): boolean {
return getVoiceAudioDeviceMetadata(device) !== null;
}
export function getRealDeviceIds(state: VoiceDeviceState): Array<string> {
return [...state.inputDevices, ...state.outputDevices]
.filter((device) => !isVirtualRouteDevice(device) && device.deviceId.trim().length > 0)
.map((device) => device.deviceId);
}
function isPromptableDevice(device: MediaDeviceInfo, context: PromptableDeviceContext): boolean {
if (isVirtualRouteDevice(device)) {
return false;
@@ -14,6 +14,8 @@
}
.scrollerContainer {
--scroller-track-block-end-inset: var(--composer-status-safe-area, 0px);
--messages-bottom-fade-fill: var(--composer-surface-color, var(--background-secondary-lighter));
position: absolute;
inset: 0;
display: flex;
@@ -24,6 +26,36 @@
width: 100%;
}
.bottomFade {
position: absolute;
inset-block-end: 0;
inset-inline: 0 1rem;
z-index: 1;
height: 1rem;
background: linear-gradient(to bottom, transparent, var(--messages-bottom-fade-fill));
pointer-events: none;
}
.bottomFadeStatusAtBottom {
height: var(--composer-status-safe-area);
background: linear-gradient(
to bottom,
transparent,
var(--messages-bottom-fade-fill) var(--composer-status-safe-gap),
var(--messages-bottom-fade-fill) 100%
);
}
.bottomFadeStatusScrolled {
height: calc(var(--composer-status-safe-area) + 4rem);
background: linear-gradient(
to bottom,
transparent,
var(--messages-bottom-fade-fill) calc(4rem + var(--composer-status-safe-gap)),
var(--messages-bottom-fade-fill) 100%
);
}
.scrollerContent {
display: flex;
flex-direction: column;
@@ -14,6 +14,7 @@ import styles from '@app/features/channel/components/ChannelMessages.module.css'
import {ChannelWelcomeSection} from '@app/features/channel/components/ChannelWelcomeSection';
import {CollapsedMessageVisibilityProvider} from '@app/features/channel/components/CollapsedMessageVisibilityContext';
import {NewMessagesBar} from '@app/features/channel/components/NewMessagesBar';
import {usePresentableTypingUsers} from '@app/features/channel/components/TypingUsers';
import {UploadManager} from '@app/features/channel/components/UploadManager';
import type {Channel} from '@app/features/channel/models/Channel';
import GatewayConnection from '@app/features/gateway/transport/GatewayConnection';
@@ -637,6 +638,7 @@ export const Messages = observer(function Messages({
? i18n._(MESSAGE_LIST_FOR_DESCRIPTOR, {channelName: channel.name})
: i18n._(MESSAGE_LIST_DESCRIPTOR);
const messageListLiveMode = Accessibility.screenReaderAnnounceNewMessages && state.isAtBottom ? 'polite' : 'off';
const composerStatusVisible = usePresentableTypingUsers(channel).length > 0 || channel.rateLimitPerUser > 0;
const topFillerVisible = selectChannelMessagesFillerVisible({
reducedMotion: Accessibility.useReducedMotion,
scrollManagerInitialized: scrollManager.lifecycleIsInitialized(),
@@ -719,6 +721,15 @@ export const Messages = observer(function Messages({
</div>
</div>
</Scroller>
<div
className={clsx(
styles.bottomFade,
composerStatusVisible &&
(state.isAtBottom ? styles.bottomFadeStatusAtBottom : styles.bottomFadeStatusScrolled),
)}
aria-hidden="true"
data-flx="channel.messages.bottom-fade"
/>
</div>
{bottomBar}
</div>
@@ -326,7 +326,7 @@ export const LexicalChannelTextareaContent = observer(
}, [value, segmentManagerRef]);
const handleEmojiSelect = useCallback(
(emoji: FlatEmoji, shiftKey?: boolean): boolean => {
const didInsert = insertComposerEmoji(handleRef.current, emoji);
const didInsert = insertComposerEmoji(handleRef.current, emoji, {reactionShorthand: true});
if (didInsert && !shiftKey) {
ExpressionPickerCommands.close();
PopoutCommands.close(`expression-picker-${channel.id}`);
@@ -3,7 +3,7 @@
.container {
display: flex;
align-items: center;
gap: 0.1875rem;
gap: 0.25rem;
height: var(--slowmode-indicator-height, 1.125rem);
max-width: 100%;
min-width: 0;
@@ -22,9 +22,9 @@
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-weight: 600;
font-weight: 500;
font-size: 0.75rem;
line-height: 1;
line-height: 1rem;
font-variant-numeric: tabular-nums;
}
@@ -115,15 +115,15 @@ export const SlowmodeIndicator = observer(({slowmodeRemaining, slowmodeDuration,
className={clsx(styles.container, onCooldown && styles.cooldown)}
data-flx="channel.slowmode-indicator.container"
>
<span className={styles.label} data-flx="channel.slowmode-indicator.label">
{statusLabel}
</span>
<ClockIcon
size={remFromPx(12)}
weight="fill"
className={styles.icon}
data-flx="channel.slowmode-indicator.clock-icon"
/>
<span className={styles.label} data-flx="channel.slowmode-indicator.label">
{statusLabel}
</span>
</div>
</Tooltip>
);
@@ -31,7 +31,6 @@ export const DEFAULT_DEVELOPER_OPTIONS = {
selfHostedModeOverride: false,
forceShowVanityURLDisclaimer: false,
forceShowVoiceConnection: false,
showProfileTimezoneSettings: false,
premiumScenarioOverride: null,
premiumTypeOverride: null,
premiumLifetimeSequenceOverride: null,
@@ -48,23 +48,6 @@
--composer-surface-fill: var(--composer-surface-color, var(--background-secondary-lighter));
}
.composerRoot:has(.statusTypingSlot)::before {
content: '';
position: absolute;
bottom: 100%;
inset-inline: calc(-1 * var(--composer-box-inset-inline, 0px));
height: var(--composer-status-safe-area);
background: linear-gradient(
to bottom,
transparent 0%,
color-mix(in srgb, var(--composer-surface-fill) 48%, transparent) 26%,
color-mix(in srgb, var(--composer-surface-fill) 82%, transparent) 54%,
var(--composer-surface-fill) 78%,
var(--composer-surface-fill) 100%
);
pointer-events: none;
}
.statusRail {
--typing-pill-height: var(--composer-status-line-height, 1.125rem);
--slowmode-indicator-height: var(--composer-status-line-height, 1.125rem);
@@ -80,9 +63,6 @@
max-width: 100%;
height: var(--composer-status-line-height, 1.125rem);
box-sizing: border-box;
text-shadow:
0 0 0.1875rem var(--composer-surface-fill),
0 0 0.5rem var(--composer-surface-fill);
pointer-events: none;
}
@@ -112,6 +92,7 @@
min-width: 0;
max-width: min(14rem, 35vw);
justify-self: end;
padding-inline-end: max(0px, calc(1rem - var(--composer-box-padding-inline, 0px)));
pointer-events: auto;
}
@@ -132,15 +132,6 @@ const FORCE_SHOW_VOICE_CONNECTION_DESCRIPTOR = msg({
message: 'Force show voice connection',
comment: 'Developer option label for always showing the voice connection status bar.',
});
const SHOW_PROFILE_TIMEZONE_SETTINGS_DESCRIPTOR = msg({
message: 'Show profile time zone settings',
comment: 'Developer option label for exposing the staff-only profile timezone section in profile settings.',
});
const SHOW_PROFILE_TIMEZONE_SETTINGS_DESC_DESCRIPTOR = msg({
message: 'Expose the staff-only time zone section in profile settings.',
comment:
'Developer / debug surface — keep terse and technical. Tooltip / description for the profile timezone settings toggle.',
});
const NO_OP_IN_APP_REPORTS_DESCRIPTOR = msg({
message: 'No-op in-app reports',
comment:
@@ -255,11 +246,6 @@ export const getToggleGroups = (): Array<ToggleGroup> => [
label: FORCE_SHOW_VOICE_CONNECTION_DESCRIPTOR,
description: ALWAYS_DISPLAY_THE_VOICE_CONNECTION_STATUS_BAR_IN_DESCRIPTOR,
},
{
key: 'showProfileTimezoneSettings',
label: SHOW_PROFILE_TIMEZONE_SETTINGS_DESCRIPTOR,
description: SHOW_PROFILE_TIMEZONE_SETTINGS_DESC_DESCRIPTOR,
},
{
key: 'noOpInAppReports',
label: NO_OP_IN_APP_REPORTS_DESCRIPTOR,
@@ -46,7 +46,6 @@ export type DeveloperOptionsState = Readonly<{
selfHostedModeOverride: boolean;
forceShowVanityURLDisclaimer: boolean;
forceShowVoiceConnection: boolean;
showProfileTimezoneSettings: boolean;
premiumScenarioOverride: PremiumScenarioOverride | null;
premiumTypeOverride: number | null;
premiumLifetimeSequenceOverride: number | null;
@@ -129,7 +128,6 @@ class DeveloperOptions implements DeveloperOptionsState {
selfHostedModeOverride = false;
forceShowVanityURLDisclaimer = false;
forceShowVoiceConnection = false;
showProfileTimezoneSettings = false;
premiumScenarioOverride: PremiumScenarioOverride | null = null;
premiumTypeOverride: number | null = null;
premiumLifetimeSequenceOverride: number | null = null;
@@ -215,7 +213,6 @@ class DeveloperOptions implements DeveloperOptionsState {
'selfHostedModeOverride',
'forceShowVanityURLDisclaimer',
'forceShowVoiceConnection',
'showProfileTimezoneSettings',
'premiumScenarioOverride',
'premiumTypeOverride',
'premiumLifetimeSequenceOverride',
@@ -65,6 +65,7 @@ function isDocumentHidden(): boolean {
class ExperimentAssignmentsStore {
response: ExperimentAssignmentsResponse = INERT_EXPERIMENT_ASSIGNMENTS_RESPONSE;
ownerId: string | null = null;
private started = false;
private etag: string | null = null;
private listening = false;
@@ -100,11 +101,15 @@ class ExperimentAssignmentsStore {
);
}
start(): void {
start(ownerId: string | null = null): void {
if (this.started && this.ownerId !== ownerId) {
this.reset();
}
if (this.started || this.pollingDisabled) {
return;
}
this.started = true;
this.ownerId = ownerId;
try {
this.addVisibilityListener();
if (isDocumentHidden()) {
@@ -139,6 +144,7 @@ class ExperimentAssignmentsStore {
this.notFoundStreak = 0;
this.pollingDisabled = false;
this.lastFetchStartedAt = 0;
this.ownerId = null;
this.setResponse(INERT_EXPERIMENT_ASSIGNMENTS_RESPONSE);
}
@@ -111,6 +111,7 @@ const GiftLoginPageMFA = observer(function GiftLoginPageMFA() {
const handleMfaSuccess = useCallback(
async ({token, userId}: LoginSuccessPayload) => {
if (isHandoff) {
await AccountManager.refreshStoredAccount(userId, token);
await handoff.start({token, userId});
return;
}
@@ -131,7 +131,7 @@ function handleReadyInternal(data: ReadyPayload, context: GatewayHandlerContext)
if (data.rtc_regions) {
RtcRegions.setRegions(data.rtc_regions);
}
ExperimentAssignments.start();
ExperimentAssignments.start(data.user.id);
Users.handleGatewayReady(data.user);
if (data.users && data.users.length > 0) {
Users.cacheUsers(data.users);
@@ -23,7 +23,10 @@ import {
type GatewayTimings,
type RpcTimings,
} from '@app/features/gateway/transport/GatewayTimingsFormatter';
import AppStorage, {PRESERVED_RESET_STORAGE_KEYS} from '@app/features/platform/state/PersistentStorage';
import AppStorage, {
PRESERVED_RESET_STORAGE_KEY_PREFIXES,
PRESERVED_RESET_STORAGE_KEYS,
} from '@app/features/platform/state/PersistentStorage';
import {Logger, LogLevel} from '@app/features/platform/utils/AppLogger';
import {ExponentialBackoff} from '@app/features/platform/utils/RetryScheduler';
import LayerManager from '@app/features/ui/state/LayerManager';
@@ -1263,7 +1266,7 @@ export class GatewaySocket extends EventEmitter<GatewaySocketEvents> {
private handleAuthFailure(): void {
this.log.error('Authentication failed: clearing client state and logging out');
this.updateState(GatewayState.Disconnected);
AppStorage.clearExcept(PRESERVED_RESET_STORAGE_KEYS);
AppStorage.clearExcept(PRESERVED_RESET_STORAGE_KEYS, PRESERVED_RESET_STORAGE_KEY_PREFIXES);
LayerManager.closeAll();
GatewayConnection.logout();
Authentication.handleConnectionClosed({code: 4004});

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