mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-09 20:22:11 +09:00
Compare commits
32
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
997d98c65c | ||
|
|
c9ae5b6ee8 | ||
|
|
a728be4062 | ||
|
|
fce81367fb | ||
|
|
5a4edc0b59 | ||
|
|
713ae5f7f5 | ||
|
|
eaee820216 | ||
|
|
564c5ae164 | ||
|
|
dd8ed6f205 | ||
|
|
ed8c412415 | ||
|
|
12417a6942 | ||
|
|
d05f6c9aaa | ||
|
|
0ca035c547 | ||
|
|
dfd46ccc2c | ||
|
|
f6df3169ca | ||
|
|
5b280898c5 | ||
|
|
2a9e25c788 | ||
|
|
463c03fb6d | ||
|
|
153dad11e1 | ||
|
|
e2d05a44a8 | ||
|
|
30ba55bd4d | ||
|
|
9def9fbef6 | ||
|
|
fa3fd0027c | ||
|
|
7e1b934637 | ||
|
|
33a118d12a | ||
|
|
01f53a168d | ||
|
|
336b8b7dcd | ||
|
|
48d0034239 | ||
|
|
677ef8491e | ||
|
|
6a6119ed1e | ||
|
|
931327d1dc | ||
|
|
858a2d9e2b |
@@ -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
|
||||
|
||||
@@ -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
@@ -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"
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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#"{
|
||||
|
||||
@@ -56,6 +56,7 @@
|
||||
"@simplewebauthn/server": "catalog:",
|
||||
"@types/node": "catalog:",
|
||||
"@vvo/tzdb": "catalog:",
|
||||
"altcha-lib": "catalog:",
|
||||
"archiver": "catalog:",
|
||||
"argon2": "catalog:",
|
||||
"bowser": "catalog:",
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
+5
-5
@@ -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) => {
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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:",
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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 {
|
||||
|
||||
+1
@@ -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}
|
||||
|
||||
+12
-3
@@ -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}
|
||||
|
||||
+3
-3
@@ -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>
|
||||
);
|
||||
|
||||
-1
@@ -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
Reference in New Issue
Block a user