mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-09 20:22:11 +09:00
Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5ab8d745c0 | ||
|
|
ff62bc89a4 | ||
|
|
838bbdb5ec | ||
|
|
1c36a59b2c | ||
|
|
6730a242db | ||
|
|
e62ae77643 | ||
|
|
f4f39e6a89 |
@@ -10525,6 +10525,7 @@
|
||||
"gateway_rollout": {"$ref": "#/components/schemas/GatewayRolloutConfigResponse"},
|
||||
"voice_noise_suppression": {"$ref": "#/components/schemas/VoiceNoiseSuppressionConfigResponse"},
|
||||
"push_service_delivery": {"$ref": "#/components/schemas/PushServiceDeliveryConfigResponse"},
|
||||
"domain_migration": {"$ref": "#/components/schemas/DomainMigrationConfigResponse"},
|
||||
"experiment_delivery": {"$ref": "#/components/schemas/ExperimentDeliveryConfigResponse"},
|
||||
"registration": {
|
||||
"type": "object",
|
||||
@@ -10953,6 +10954,7 @@
|
||||
"gateway_rollout",
|
||||
"voice_noise_suppression",
|
||||
"push_service_delivery",
|
||||
"domain_migration",
|
||||
"experiment_delivery",
|
||||
"registration",
|
||||
"self_hosted",
|
||||
@@ -11091,6 +11093,10 @@
|
||||
"nullable": true,
|
||||
"allOf": [{"$ref": "#/components/schemas/PushServiceDeliveryConfigUpdateRequest"}]
|
||||
},
|
||||
"domain_migration": {
|
||||
"nullable": true,
|
||||
"allOf": [{"$ref": "#/components/schemas/DomainMigrationConfigUpdateRequest"}]
|
||||
},
|
||||
"experiment_delivery": {
|
||||
"nullable": true,
|
||||
"allOf": [{"$ref": "#/components/schemas/ExperimentDeliveryConfigUpdateRequest"}]
|
||||
@@ -15184,6 +15190,26 @@
|
||||
"poll_jitter_percent": {"type": "integer", "minimum": 0, "maximum": 50}
|
||||
}
|
||||
},
|
||||
"DomainMigrationConfigUpdateRequest": {
|
||||
"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}$"}
|
||||
},
|
||||
"anonymous_rollout_basis_points": {"type": "integer", "minimum": 0, "maximum": 10000},
|
||||
"standalone_forwarding": {"type": "boolean"}
|
||||
}
|
||||
},
|
||||
"PushServiceDeliveryConfigUpdateRequest": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -15267,6 +15293,46 @@
|
||||
"required": ["poll_interval_seconds", "poll_jitter_percent"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"DomainMigrationConfigResponse": {
|
||||
"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": "domain-migration-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}$"}
|
||||
},
|
||||
"excluded_user_ids": {
|
||||
"default": [],
|
||||
"maxItems": 1000,
|
||||
"type": "array",
|
||||
"items": {"type": "string", "pattern": "^\\d{1,20}$"}
|
||||
},
|
||||
"anonymous_rollout_basis_points": {"default": 0, "type": "integer", "minimum": 0, "maximum": 10000},
|
||||
"standalone_forwarding": {"default": false, "type": "boolean"}
|
||||
},
|
||||
"required": [
|
||||
"enabled",
|
||||
"config_version",
|
||||
"rollout_basis_points",
|
||||
"rollout_salt",
|
||||
"included_user_ids",
|
||||
"excluded_user_ids",
|
||||
"anonymous_rollout_basis_points",
|
||||
"standalone_forwarding"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"PushServiceDeliveryConfigResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
||||
@@ -25,6 +25,8 @@ pub struct InstanceConfigResponse {
|
||||
#[serde(default)]
|
||||
pub push_service_delivery: PushServiceDeliveryConfigResponse,
|
||||
#[serde(default)]
|
||||
pub domain_migration: DomainMigrationConfigResponse,
|
||||
#[serde(default)]
|
||||
pub experiment_delivery: ExperimentDeliveryConfigResponse,
|
||||
}
|
||||
|
||||
@@ -450,6 +452,7 @@ 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 VOICE_NS_MAX_GUILD_OVERRIDES: usize = 200;
|
||||
|
||||
impl NoiseSuppressionBackend {
|
||||
@@ -578,6 +581,52 @@ pub struct PushServiceDeliveryConfigUpdateRequest {
|
||||
pub excluded_user_ids: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
#[serde(default)]
|
||||
pub struct DomainMigrationConfigResponse {
|
||||
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>,
|
||||
pub anonymous_rollout_basis_points: u32,
|
||||
pub standalone_forwarding: bool,
|
||||
}
|
||||
|
||||
impl Default for DomainMigrationConfigResponse {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: false,
|
||||
config_version: 0,
|
||||
rollout_basis_points: 0,
|
||||
rollout_salt: DOMAIN_MIGRATION_DEFAULT_SALT.to_owned(),
|
||||
included_user_ids: Vec::new(),
|
||||
excluded_user_ids: Vec::new(),
|
||||
anonymous_rollout_basis_points: 0,
|
||||
standalone_forwarding: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Serialize)]
|
||||
pub struct DomainMigrationConfigUpdateRequest {
|
||||
#[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>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub anonymous_rollout_basis_points: Option<u32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub standalone_forwarding: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
#[serde(default)]
|
||||
pub struct ExperimentDeliveryConfigResponse {
|
||||
@@ -696,6 +745,8 @@ pub struct InstanceConfigUpdateRequest {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub push_service_delivery: Option<PushServiceDeliveryConfigUpdateRequest>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub domain_migration: Option<DomainMigrationConfigUpdateRequest>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub experiment_delivery: Option<ExperimentDeliveryConfigUpdateRequest>,
|
||||
}
|
||||
|
||||
@@ -1033,18 +1084,30 @@ mod tests {
|
||||
.expect("admin schema");
|
||||
let noise = serde_json::from_value::<VoiceNoiseSuppressionConfigResponse>(json!({}))
|
||||
.expect("default noise config");
|
||||
let domain_migration = serde_json::from_value::<DomainMigrationConfigResponse>(json!({}))
|
||||
.expect("default domain migration 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 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_delivery: generated_types::ExperimentDeliveryConfigResponse =
|
||||
serde_json::from_value(delivery.clone()).expect("generated delivery config contract");
|
||||
assert_eq!(
|
||||
serde_json::to_value(generated_noise).expect("serializable generated noise config"),
|
||||
noise
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::to_value(generated_domain_migration)
|
||||
.expect("serializable generated domain migration config"),
|
||||
domain_migration
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::to_value(generated_delivery)
|
||||
.expect("serializable generated delivery config"),
|
||||
@@ -1052,6 +1115,7 @@ mod tests {
|
||||
);
|
||||
for (name, value) in [
|
||||
("VoiceNoiseSuppressionConfigResponse", noise),
|
||||
("DomainMigrationConfigResponse", domain_migration),
|
||||
("ExperimentDeliveryConfigResponse", delivery),
|
||||
] {
|
||||
for (field, value) in value.as_object().expect("config object") {
|
||||
@@ -1087,4 +1151,27 @@ mod tests {
|
||||
json!({})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn domain_migration_update_preserves_empty_lists_and_omitted_fields() {
|
||||
let update = DomainMigrationConfigUpdateRequest {
|
||||
included_user_ids: Some(Vec::new()),
|
||||
excluded_user_ids: Some(Vec::new()),
|
||||
..Default::default()
|
||||
};
|
||||
let value = serde_json::to_value(update).expect("serializable update");
|
||||
serde_json::from_value::<generated_types::DomainMigrationConfigUpdateRequest>(
|
||||
value.clone(),
|
||||
)
|
||||
.expect("generated update contract");
|
||||
assert_eq!(
|
||||
value,
|
||||
json!({"included_user_ids": [], "excluded_user_ids": []})
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::to_value(DomainMigrationConfigUpdateRequest::default())
|
||||
.expect("serializable update"),
|
||||
json!({})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,20 +7,21 @@ use crate::{
|
||||
AppBrandingConfigUpdateRequest, AppLegalConfigUpdateRequest,
|
||||
AppPublicConfigUpdateRequest, AppRegistrationConfigUpdateRequest,
|
||||
AppSetupConfigUpdateRequest, CreateRegistrationUrlRequest,
|
||||
DeferredPhoneGateUpdateRequest, 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,
|
||||
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,
|
||||
},
|
||||
},
|
||||
config::AdminConfig,
|
||||
@@ -211,6 +212,10 @@ pub async fn instance_config_post(
|
||||
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) {
|
||||
Ok(update) => instance_config_result(client.update_instance_config(&update).await),
|
||||
Err(message) => FlashData::error(message),
|
||||
},
|
||||
"update_experiment_delivery" => match build_experiment_delivery_update(&form) {
|
||||
Ok(update) => instance_config_result(client.update_instance_config(&update).await),
|
||||
Err(message) => FlashData::error(message),
|
||||
@@ -496,7 +501,7 @@ fn parse_experiment_rollout_salt(
|
||||
Ok(Some(salt.to_owned()))
|
||||
}
|
||||
|
||||
fn parse_push_service_delivery_rollout_salt(
|
||||
fn parse_ascii_experiment_rollout_salt(
|
||||
form: &MultiValueForm,
|
||||
key: &str,
|
||||
) -> Result<Option<String>, String> {
|
||||
@@ -661,7 +666,7 @@ fn build_push_service_delivery_update(
|
||||
0,
|
||||
EXPERIMENT_ROLLOUT_BASIS_POINTS_MAX,
|
||||
)?,
|
||||
rollout_salt: parse_push_service_delivery_rollout_salt(
|
||||
rollout_salt: parse_ascii_experiment_rollout_salt(
|
||||
form,
|
||||
"push_service_delivery_rollout_salt",
|
||||
)?,
|
||||
@@ -680,6 +685,46 @@ fn build_push_service_delivery_update(
|
||||
})
|
||||
}
|
||||
|
||||
fn build_domain_migration_update(
|
||||
form: &MultiValueForm,
|
||||
) -> Result<InstanceConfigUpdateRequest, String> {
|
||||
Ok(InstanceConfigUpdateRequest {
|
||||
domain_migration: Some(DomainMigrationConfigUpdateRequest {
|
||||
enabled: Some(form.bool_value("domain_migration_enabled")),
|
||||
rollout_basis_points: parse_form_number(
|
||||
form,
|
||||
"domain_migration_rollout_basis_points",
|
||||
"Rollout basis points",
|
||||
0,
|
||||
EXPERIMENT_ROLLOUT_BASIS_POINTS_MAX,
|
||||
)?,
|
||||
rollout_salt: parse_ascii_experiment_rollout_salt(
|
||||
form,
|
||||
"domain_migration_rollout_salt",
|
||||
)?,
|
||||
included_user_ids: Some(parse_experiment_user_ids(
|
||||
form.first("domain_migration_included_user_ids")
|
||||
.unwrap_or_default(),
|
||||
"Included user IDs",
|
||||
)?),
|
||||
excluded_user_ids: Some(parse_experiment_user_ids(
|
||||
form.first("domain_migration_excluded_user_ids")
|
||||
.unwrap_or_default(),
|
||||
"Excluded user IDs",
|
||||
)?),
|
||||
anonymous_rollout_basis_points: parse_form_number(
|
||||
form,
|
||||
"domain_migration_anonymous_rollout_basis_points",
|
||||
"Anonymous rollout basis points",
|
||||
0,
|
||||
EXPERIMENT_ROLLOUT_BASIS_POINTS_MAX,
|
||||
)?,
|
||||
standalone_forwarding: Some(form.bool_value("domain_migration_standalone_forwarding")),
|
||||
}),
|
||||
..Default::default()
|
||||
})
|
||||
}
|
||||
|
||||
fn build_experiment_delivery_update(
|
||||
form: &MultiValueForm,
|
||||
) -> Result<InstanceConfigUpdateRequest, String> {
|
||||
@@ -1601,6 +1646,91 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_domain_migration_update_reads_the_rollout_fields() {
|
||||
let form = MultiValueForm::parse(
|
||||
b"domain_migration_enabled=true&domain_migration_rollout_basis_points=%20250%20&domain_migration_rollout_salt=%20domain-migration-v2%20&domain_migration_included_user_ids=1500000000000000001%0A1500000000000000002&domain_migration_excluded_user_ids=1500000000000000003%2C%201500000000000000004&domain_migration_anonymous_rollout_basis_points=%20100%20&domain_migration_standalone_forwarding=true",
|
||||
);
|
||||
let update = build_domain_migration_update(&form)
|
||||
.expect("valid form")
|
||||
.domain_migration
|
||||
.expect("domain migration update");
|
||||
assert_eq!(update.enabled, Some(true));
|
||||
assert_eq!(update.rollout_basis_points, Some(250));
|
||||
assert_eq!(update.rollout_salt, Some("domain-migration-v2".to_owned()));
|
||||
assert_eq!(
|
||||
update.included_user_ids,
|
||||
Some(vec![
|
||||
"1500000000000000001".to_owned(),
|
||||
"1500000000000000002".to_owned()
|
||||
])
|
||||
);
|
||||
assert_eq!(
|
||||
update.excluded_user_ids,
|
||||
Some(vec![
|
||||
"1500000000000000003".to_owned(),
|
||||
"1500000000000000004".to_owned()
|
||||
])
|
||||
);
|
||||
assert_eq!(update.anonymous_rollout_basis_points, Some(100));
|
||||
assert_eq!(update.standalone_forwarding, Some(true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_domain_migration_update_leaves_the_feature_inert_when_nothing_is_submitted() {
|
||||
let form = MultiValueForm::parse(b"_csrf=token");
|
||||
let request = build_domain_migration_update(&form).expect("valid form");
|
||||
assert_eq!(
|
||||
serde_json::to_value(request).expect("serializable update"),
|
||||
serde_json::json!({"domain_migration": {
|
||||
"enabled": false,
|
||||
"included_user_ids": [],
|
||||
"excluded_user_ids": [],
|
||||
"standalone_forwarding": false,
|
||||
}})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_domain_migration_update_rejects_invalid_rollout_fields() {
|
||||
for (form, message) in [
|
||||
(
|
||||
"domain_migration_rollout_basis_points=10001",
|
||||
"Rollout basis points must be a whole number between 0 and 10000",
|
||||
),
|
||||
(
|
||||
"domain_migration_anonymous_rollout_basis_points=10001",
|
||||
"Anonymous rollout basis points must be a whole number between 0 and 10000",
|
||||
),
|
||||
(
|
||||
"domain_migration_anonymous_rollout_basis_points=abc",
|
||||
"Anonymous rollout basis points must be a whole number between 0 and 10000",
|
||||
),
|
||||
(
|
||||
"domain_migration_rollout_salt=%20%20",
|
||||
"Rollout salt must be between 1 and 64 characters",
|
||||
),
|
||||
(
|
||||
"domain_migration_rollout_salt=caf%C3%A9",
|
||||
"Rollout salt must use printable ASCII",
|
||||
),
|
||||
(
|
||||
"domain_migration_included_user_ids=123%2Cinvalid",
|
||||
"Included user IDs entry 2 must contain 1 to 20 decimal digits",
|
||||
),
|
||||
(
|
||||
"domain_migration_excluded_user_ids=123%2Cinvalid",
|
||||
"Excluded user IDs entry 2 must contain 1 to 20 decimal digits",
|
||||
),
|
||||
] {
|
||||
let form = MultiValueForm::parse(form.as_bytes());
|
||||
assert_eq!(
|
||||
build_domain_migration_update(&form).expect_err("invalid rollout field"),
|
||||
message
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_experiment_delivery_update_leaves_both_fields_unchanged_when_absent() {
|
||||
let form = MultiValueForm::parse(b"_csrf=token");
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
|
||||
use crate::{
|
||||
api::types::{
|
||||
AppPublicConfigResponse, EXPERIMENT_MAX_TARGETED_USERS, ExperimentDeliveryConfigResponse,
|
||||
AppPublicConfigResponse, DOMAIN_MIGRATION_DEFAULT_SALT, DomainMigrationConfigResponse,
|
||||
EXPERIMENT_MAX_TARGETED_USERS, ExperimentDeliveryConfigResponse,
|
||||
GatewayRolloutConfigResponse, InstanceConfigResponse, InstanceIntegrationsResponse,
|
||||
InstanceMediaResponse, InstancePolicyResponse, InstanceRegistrationResponse,
|
||||
LimitConfigResponse, NoiseSuppressionBackend, PUSH_SERVICE_DELIVERY_DEFAULT_SALT,
|
||||
@@ -149,6 +150,7 @@ pub fn instance_config_page(
|
||||
(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))
|
||||
(experiment_delivery_section(base, csrf_token, &instance_config.experiment_delivery))
|
||||
@if let Some(limit_config) = limit_config {
|
||||
(limit_config_section(base, limit_config))
|
||||
@@ -1286,6 +1288,139 @@ fn push_service_delivery_section(
|
||||
)
|
||||
}
|
||||
|
||||
fn domain_migration_section(
|
||||
base: &str,
|
||||
csrf_token: &str,
|
||||
domain_migration: &DomainMigrationConfigResponse,
|
||||
) -> Markup {
|
||||
let status = if domain_migration.enabled {
|
||||
("Live", BadgeVariant::Success)
|
||||
} else {
|
||||
("Inert", BadgeVariant::Default)
|
||||
};
|
||||
let included_user_ids = domain_migration.included_user_ids.join("\n");
|
||||
let excluded_user_ids = domain_migration.excluded_user_ids.join("\n");
|
||||
section_card_with_description(
|
||||
"Domain Migration",
|
||||
"Moves web clients of the official instance from the legacy web app origin to the new \
|
||||
one. Selected accounts copy their local data across and continue on the new origin. \
|
||||
Clients of other instances read this configuration and ignore it.",
|
||||
html! {
|
||||
form method="post" action={(base) "/instance-config?action=update_domain_migration"} {
|
||||
(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 " (domain_migration.config_version)
|
||||
}
|
||||
}
|
||||
(checkbox(
|
||||
"domain_migration_enabled",
|
||||
"true",
|
||||
"Move selected web clients to the new origin",
|
||||
domain_migration.enabled,
|
||||
true,
|
||||
))
|
||||
p class="text-xs text-neutral-500" {
|
||||
"Off is the safe state and the kill switch. With this unchecked no client \
|
||||
starts a migration and clients that already migrated stop forwarding the \
|
||||
legacy origin, so the rollout and targeting fields below have no effect at all."
|
||||
}
|
||||
|
||||
h3 class="text-sm font-semibold text-neutral-900" { "Installed apps" }
|
||||
(checkbox(
|
||||
"domain_migration_standalone_forwarding",
|
||||
"true",
|
||||
"Forward installed desktop web apps to the new origin",
|
||||
domain_migration.standalone_forwarding,
|
||||
true,
|
||||
))
|
||||
p class="text-xs text-neutral-500" {
|
||||
"Leave this off until the manifest scope extension and the association file \
|
||||
are live and verified. While it is off, installed Chromium desktop apps copy \
|
||||
their data across but stay on the legacy origin and offer to install the new \
|
||||
app. Installed mobile and Safari apps never forward either way."
|
||||
}
|
||||
|
||||
h3 class="text-sm font-semibold text-neutral-900" { "Rollout" }
|
||||
(number_field(
|
||||
"domain_migration_rollout_basis_points",
|
||||
"Rollout (basis points)",
|
||||
&domain_migration.rollout_basis_points.to_string(),
|
||||
Some(0), Some(10000), "1",
|
||||
Some("Share of logged-in users bucketed into the migration, in basis points: 0 is nobody, 100 is 1%, 10000 is everybody."),
|
||||
))
|
||||
(number_field(
|
||||
"domain_migration_anonymous_rollout_basis_points",
|
||||
"Anonymous rollout (basis points)",
|
||||
&domain_migration.anonymous_rollout_basis_points.to_string(),
|
||||
Some(0), Some(10000), "1",
|
||||
Some("Share of logged-out devices sent to the new origin, in basis points. Each device is bucketed on its own random ID."),
|
||||
))
|
||||
div class="flex flex-col gap-2" {
|
||||
(text_input(
|
||||
"domain_migration_rollout_salt",
|
||||
"Rollout Salt",
|
||||
&domain_migration.rollout_salt,
|
||||
DOMAIN_MIGRATION_DEFAULT_SALT,
|
||||
))
|
||||
p class="text-xs text-neutral-500" {
|
||||
"Seeds the bucketing hash for users and devices. Changing it reshuffles \
|
||||
which users and devices fall inside the percentages above. Leave it \
|
||||
alone to keep the current cohort stable."
|
||||
}
|
||||
}
|
||||
div class="flex flex-col gap-2" {
|
||||
(textarea_input(
|
||||
"domain_migration_included_user_ids",
|
||||
"Always-on User IDs",
|
||||
"1500000000000000001\n1500000000000000002",
|
||||
&included_user_ids,
|
||||
4,
|
||||
false,
|
||||
))
|
||||
(entry_count_hint(
|
||||
domain_migration.included_user_ids.len(),
|
||||
EXPERIMENT_MAX_TARGETED_USERS,
|
||||
))
|
||||
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(
|
||||
"domain_migration_excluded_user_ids",
|
||||
"Never-on User IDs",
|
||||
"1500000000000000003\n1500000000000000004",
|
||||
&excluded_user_ids,
|
||||
4,
|
||||
false,
|
||||
))
|
||||
(entry_count_hint(
|
||||
domain_migration.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. It stops new migrations only. A user who already moved \
|
||||
stays on the new origin."
|
||||
}
|
||||
}
|
||||
|
||||
(form_actions(html! {
|
||||
(submit_button("Save Domain Migration Configuration"))
|
||||
}))
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
fn experiment_delivery_section(
|
||||
base: &str,
|
||||
csrf_token: &str,
|
||||
@@ -1929,6 +2064,28 @@ mod tests {
|
||||
assert!(!markup.contains("at the cap"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn domain_migration_section_shows_both_rollouts_and_list_counts() {
|
||||
let domain_migration = DomainMigrationConfigResponse {
|
||||
anonymous_rollout_basis_points: 250,
|
||||
included_user_ids: vec!["1500000000000000001".to_owned()],
|
||||
excluded_user_ids: vec![
|
||||
"1500000000000000002".to_owned(),
|
||||
"1500000000000000003".to_owned(),
|
||||
],
|
||||
..DomainMigrationConfigResponse::default()
|
||||
};
|
||||
let markup = domain_migration_section("/admin", "csrf", &domain_migration).into_string();
|
||||
assert!(markup.contains("action=update_domain_migration"));
|
||||
assert!(markup.contains("domain_migration_enabled"));
|
||||
assert!(markup.contains("name=\"domain_migration_anonymous_rollout_basis_points\""));
|
||||
assert!(markup.contains("value=\"250\""));
|
||||
assert!(markup.contains("name=\"domain_migration_standalone_forwarding\""));
|
||||
assert!(markup.contains("1 of 1000 stored"));
|
||||
assert!(markup.contains("2 of 1000 stored"));
|
||||
assert!(!markup.contains("at the cap"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn voice_noise_suppression_section_flags_a_list_at_its_cap() {
|
||||
let voice_noise_suppression = VoiceNoiseSuppressionConfigResponse {
|
||||
|
||||
@@ -417,6 +417,17 @@ fn deserialize_instance_config_response_with_unknown_keys() {
|
||||
"included_user_ids": ["1500000000000000002"],
|
||||
"excluded_user_ids": []
|
||||
},
|
||||
"domain_migration": {
|
||||
"enabled": true,
|
||||
"config_version": 2,
|
||||
"rollout_basis_points": 2500,
|
||||
"rollout_salt": "domain-migration-v1",
|
||||
"included_user_ids": ["1500000000000000001"],
|
||||
"excluded_user_ids": [],
|
||||
"future_migration_knob": 9,
|
||||
"anonymous_rollout_basis_points": 100,
|
||||
"standalone_forwarding": true
|
||||
},
|
||||
"experiment_delivery": {"poll_interval_seconds": 300, "poll_jitter_percent": 15},
|
||||
"registration": {
|
||||
"mode": "open",
|
||||
@@ -546,6 +557,13 @@ fn deserialize_instance_config_response_with_unknown_keys() {
|
||||
assert_eq!(resp.voice_noise_suppression.rollout_basis_points, 10000);
|
||||
assert_eq!(*resp.voice_noise_suppression.rollout_salt, "voice-ns-v1");
|
||||
assert_eq!(resp.voice_noise_suppression.enabled_backends.len(), 3);
|
||||
assert!(resp.domain_migration.enabled);
|
||||
assert_eq!(resp.domain_migration.config_version, 2);
|
||||
assert_eq!(resp.domain_migration.rollout_basis_points, 2500);
|
||||
assert_eq!(*resp.domain_migration.rollout_salt, "domain-migration-v1");
|
||||
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_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));
|
||||
@@ -556,6 +574,7 @@ fn deserialize_instance_config_response_with_unknown_keys() {
|
||||
.replace("\"future_rollout_knob\": 3,", "")
|
||||
.replace("\"future_presentation_knob\": \"verbose\",", "")
|
||||
.replace("\"future_knob\": 7,", "")
|
||||
.replace("\"future_migration_knob\": 9,", "")
|
||||
.replace("\"future_object_knob\": {\"nested\": true},", "")
|
||||
.replace("\"future_list_knob\": [\"a\", \"b\"],", "")
|
||||
.replace(
|
||||
|
||||
@@ -465,6 +465,7 @@ async fn mutating_admin_pages_render_usable_csrf_tokens() {
|
||||
"/instance-config?action=update_gateway_rollout",
|
||||
"/instance-config?action=update_sso",
|
||||
"/instance-config?action=update_voice_noise_suppression",
|
||||
"/instance-config?action=update_domain_migration",
|
||||
"/instance-config?action=update_experiment_delivery",
|
||||
][..],
|
||||
),
|
||||
@@ -1199,6 +1200,16 @@ fn instance_config() -> Value {
|
||||
"guild_overrides": [],
|
||||
"suppression_strength": 80
|
||||
},
|
||||
"domain_migration": {
|
||||
"enabled": false,
|
||||
"config_version": 0,
|
||||
"rollout_basis_points": 0,
|
||||
"rollout_salt": "domain-migration-v1",
|
||||
"included_user_ids": [],
|
||||
"excluded_user_ids": [],
|
||||
"anonymous_rollout_basis_points": 0,
|
||||
"standalone_forwarding": false
|
||||
},
|
||||
"experiment_delivery": {
|
||||
"poll_interval_seconds": 300,
|
||||
"poll_jitter_percent": 15
|
||||
|
||||
@@ -45,7 +45,7 @@ export async function createAPIApp(options: CreateAPIAppOptions): Promise<APIApp
|
||||
configureMiddleware(routes, {
|
||||
logger,
|
||||
nodeEnv: config.nodeEnv,
|
||||
corsOrigins: [config.endpoints.webApp, config.endpoints.marketing],
|
||||
corsOrigins: [...config.endpoints.webAppOrigins, config.endpoints.marketing],
|
||||
trustClientIpHeader: config.proxy.trust_client_ip_header,
|
||||
clientIpHeaderName: config.proxy.client_ip_header,
|
||||
maxInflightRequests: config.maxInflightRequests,
|
||||
|
||||
@@ -258,6 +258,7 @@ export function buildAPIConfigFromMaster(master: MasterConfig): APIConfig {
|
||||
apiPublic: master.endpoints.api,
|
||||
apiClient: master.endpoints.api_client,
|
||||
webApp: master.endpoints.app,
|
||||
webAppOrigins: [...new Set([new URL(master.endpoints.app).origin, ...master.services.api.app_origin_aliases])],
|
||||
gateway: master.endpoints.gateway,
|
||||
media: master.endpoints.media,
|
||||
marketing: master.endpoints.marketing,
|
||||
|
||||
@@ -34,6 +34,7 @@ import {
|
||||
PendingRegistrationActionRequest,
|
||||
RegistrationUrlIdParam,
|
||||
} from '@fluxer/schema/src/domains/admin/AdminSchemas';
|
||||
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 {VoiceNoiseSuppressionConfigSchema} from '@fluxer/schema/src/domains/admin/VoiceNoiseSuppressionSchemas';
|
||||
@@ -65,6 +66,7 @@ async function buildInstanceConfigResponse(): Promise<InstanceConfigResponse> {
|
||||
gatewayRollout,
|
||||
voiceNoiseSuppression,
|
||||
pushServiceDelivery,
|
||||
domainMigration,
|
||||
experimentDelivery,
|
||||
registrationConfig,
|
||||
registrationUrls,
|
||||
@@ -74,6 +76,7 @@ async function buildInstanceConfigResponse(): Promise<InstanceConfigResponse> {
|
||||
instanceConfigRepository.getGatewayRolloutConfig(),
|
||||
instanceConfigRepository.getVoiceNoiseSuppressionConfig(),
|
||||
instanceConfigRepository.getPushServiceDeliveryConfig(),
|
||||
instanceConfigRepository.getDomainMigrationConfig(),
|
||||
instanceConfigRepository.getExperimentDeliveryConfig(),
|
||||
instanceConfigRepository.getRegistrationConfig(),
|
||||
instanceConfigRepository.getRegistrationUrlsForAdmin(),
|
||||
@@ -106,6 +109,7 @@ async function buildInstanceConfigResponse(): Promise<InstanceConfigResponse> {
|
||||
gateway_rollout: gatewayRollout,
|
||||
voice_noise_suppression: voiceNoiseSuppression,
|
||||
push_service_delivery: pushServiceDelivery,
|
||||
domain_migration: domainMigration,
|
||||
experiment_delivery: experimentDelivery,
|
||||
registration: {
|
||||
...registrationConfig,
|
||||
@@ -282,6 +286,18 @@ export function InstanceConfigAdminController(app: HonoApp) {
|
||||
await getPushServiceDeliveryConfigPublisher().publish(landed);
|
||||
}
|
||||
}
|
||||
if (data.domain_migration) {
|
||||
const patch = omitUndefinedFields(data.domain_migration);
|
||||
if (Object.keys(patch).length > 0) {
|
||||
await instanceConfigRepository.updateDomainMigrationConfig((current) =>
|
||||
DomainMigrationConfigSchema.parse({
|
||||
...current,
|
||||
...patch,
|
||||
config_version: current.config_version + 1,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
if (data.experiment_delivery) {
|
||||
const patch = data.experiment_delivery;
|
||||
await instanceConfigRepository.updateExperimentDeliveryConfig((current) =>
|
||||
|
||||
@@ -69,6 +69,20 @@ describe('instance config admin PATCH under concurrent writes', () => {
|
||||
return logs.filter((log) => log.action === 'update_instance_config');
|
||||
}
|
||||
|
||||
it('merges a standalone forwarding patch into the stored domain migration config', async () => {
|
||||
const admin = await createAdmin();
|
||||
await patchConfig(admin, {domain_migration: {enabled: true, rollout_basis_points: 250}}).execute();
|
||||
|
||||
const updated = await patchConfig(admin, {domain_migration: {standalone_forwarding: true}}).execute();
|
||||
|
||||
expect(updated.domain_migration).toMatchObject({
|
||||
enabled: true,
|
||||
rollout_basis_points: 250,
|
||||
standalone_forwarding: true,
|
||||
config_version: 2,
|
||||
});
|
||||
});
|
||||
|
||||
it('answers with a conflict and neither writes, publishes nor audits once every attempt has lost the race', async () => {
|
||||
const publish = spyOnPushDeliveryPublishes();
|
||||
const admin = await createAdmin();
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import {registerAdminControllers} from '@app/api/admin/controllers/index';
|
||||
import {AttachmentController} from '@app/api/attachment/AttachmentController';
|
||||
import {AuthController} from '@app/api/auth/AuthController';
|
||||
import {OriginHandoffController} from '@app/api/auth/OriginHandoffController';
|
||||
import {BlueskyOAuthController} from '@app/api/bluesky/BlueskyOAuthController';
|
||||
import {Config} from '@app/api/Config';
|
||||
import {ChannelController} from '@app/api/channel/ChannelController';
|
||||
@@ -46,6 +47,7 @@ export function registerControllers(routes: HonoApp, config: APIConfig): void {
|
||||
GeolocationController(routes);
|
||||
registerAdminControllers(routes);
|
||||
AuthController(routes);
|
||||
OriginHandoffController(routes);
|
||||
AttachmentController(routes);
|
||||
ChannelController(routes);
|
||||
ConnectionController(routes);
|
||||
|
||||
@@ -602,6 +602,7 @@ export function AuthController(app: HonoApp) {
|
||||
data: ctx.req.valid('json'),
|
||||
clientIp,
|
||||
authToken: ctx.get('authToken') ?? undefined,
|
||||
approverOrigin: ctx.req.header('origin'),
|
||||
});
|
||||
return ctx.body(null, 204);
|
||||
},
|
||||
|
||||
@@ -8,12 +8,19 @@ import * as AuthMfa from '@app/api/auth/AuthMfa';
|
||||
import * as AuthPassword from '@app/api/auth/AuthPassword';
|
||||
import * as AuthRegistration from '@app/api/auth/AuthRegistration';
|
||||
import * as AuthSession from '@app/api/auth/AuthSession';
|
||||
import {getTokenIdHash} from '@app/api/auth/AuthUtility';
|
||||
import type {DesktopHandoffService} from '@app/api/auth/services/DesktopHandoffService';
|
||||
import type {SsoService} from '@app/api/auth/services/SsoService';
|
||||
import {createUserID, type UserID} from '@app/api/BrandedTypes';
|
||||
import {Logger} from '@app/api/Logger';
|
||||
import type {RequestCache} from '@app/api/middleware/RequestCacheMiddleware';
|
||||
import {getInstanceConfigRepository} from '@app/api/middleware/ServiceSingletons';
|
||||
import type {User} from '@app/api/models/User';
|
||||
import {
|
||||
classifyWebPushOrigin,
|
||||
encodePushSessionIdHash,
|
||||
recordPushSessionPredecessor,
|
||||
} from '@app/api/user/services/WebPushOriginReplacement';
|
||||
import {mapUserToPartialResponse} from '@app/api/user/UserMappers';
|
||||
import {lookupGeoip} from '@app/api/utils/IpUtils';
|
||||
import {parseJsonRecord} from '@app/api/utils/JsonBoundaryUtils';
|
||||
@@ -91,6 +98,7 @@ interface AuthHandoffCompleteRequest {
|
||||
data: HandoffCompleteRequest;
|
||||
clientIp: string;
|
||||
authToken?: string;
|
||||
approverOrigin?: string | null;
|
||||
}
|
||||
|
||||
interface AuthAuthorizeIpRequest {
|
||||
@@ -305,7 +313,10 @@ export class AuthRequestService {
|
||||
|
||||
async initiateHandoff({request}: AuthHandoffInitiateRequest): Promise<HandoffInitiateResponse> {
|
||||
const origin = AuthSession.resolveSessionOrigin(this.apiContext, request);
|
||||
const result = await this.desktopHandoffService.initiateHandoff({origin});
|
||||
const result = await this.desktopHandoffService.initiateHandoff({
|
||||
origin,
|
||||
initiatorOrigin: request.headers.get('origin'),
|
||||
});
|
||||
return {
|
||||
code: result.code,
|
||||
expires_at: result.expiresAt.toISOString(),
|
||||
@@ -340,21 +351,53 @@ export class AuthRequestService {
|
||||
};
|
||||
}
|
||||
|
||||
async completeHandoff({data, clientIp, authToken}: AuthHandoffCompleteRequest): Promise<void> {
|
||||
async completeHandoff({data, clientIp, authToken, approverOrigin}: AuthHandoffCompleteRequest): Promise<void> {
|
||||
const sessionToken = data.token ?? authToken;
|
||||
if (!sessionToken) {
|
||||
throw new UnauthorizedError();
|
||||
}
|
||||
await this.desktopHandoffService.completeHandoff(
|
||||
let createdToken: string | null = null;
|
||||
const {initiatorOrigin} = await this.desktopHandoffService.completeHandoff(
|
||||
data.code,
|
||||
(origin) =>
|
||||
AuthSession.createAdditionalAuthSessionFromToken(this.apiContext, {
|
||||
async (origin) => {
|
||||
const created = await AuthSession.createAdditionalAuthSessionFromToken(this.apiContext, {
|
||||
token: sessionToken,
|
||||
expectedUserId: data.user_id,
|
||||
origin,
|
||||
}),
|
||||
});
|
||||
createdToken = created.token;
|
||||
return created;
|
||||
},
|
||||
clientIp,
|
||||
);
|
||||
if (createdToken !== null) {
|
||||
await this.recordPushSessionPredecessor(createdToken, sessionToken, initiatorOrigin, approverOrigin);
|
||||
}
|
||||
}
|
||||
|
||||
private async recordPushSessionPredecessor(
|
||||
createdToken: string,
|
||||
approverToken: string,
|
||||
initiatorOrigin: string | null,
|
||||
approverOrigin: string | null | undefined,
|
||||
): Promise<void> {
|
||||
const {config, kv} = this.apiContext.services;
|
||||
const {selfHosted} = config.instance;
|
||||
if (
|
||||
classifyWebPushOrigin(initiatorOrigin, selfHosted) !== 'target' ||
|
||||
classifyWebPushOrigin(approverOrigin, selfHosted) !== 'legacy'
|
||||
) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await recordPushSessionPredecessor(
|
||||
kv,
|
||||
encodePushSessionIdHash(getTokenIdHash(this.apiContext, createdToken)),
|
||||
encodePushSessionIdHash(getTokenIdHash(this.apiContext, approverToken)),
|
||||
);
|
||||
} catch (error) {
|
||||
Logger.warn({error}, 'Failed to record the push session predecessor');
|
||||
}
|
||||
}
|
||||
|
||||
async getHandoffStatus({code, clientIp, pollSecret}: AuthHandoffStatusRequest): Promise<HandoffStatusResponse> {
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {createOriginHandoff, redeemOriginHandoff} from '@app/api/auth/services/OriginHandoffService';
|
||||
import {Config} from '@app/api/Config';
|
||||
import {DefaultUserOnly, LoginRequired} from '@app/api/middleware/AuthMiddleware';
|
||||
import {RateLimitMiddleware} from '@app/api/middleware/RateLimitMiddleware';
|
||||
import {OpenAPI} from '@app/api/middleware/ResponseTypeMiddleware';
|
||||
import {RateLimitConfigs} from '@app/api/RateLimitConfig';
|
||||
import type {HonoApp} from '@app/api/types/HonoEnv';
|
||||
import {Validator} from '@app/api/Validator';
|
||||
import {FileSizeTooLargeError} from '@fluxer/errors/src/domains/core/FileSizeTooLargeError';
|
||||
import {InvalidApiOriginError} from '@fluxer/errors/src/domains/core/InvalidApiOriginError';
|
||||
import {
|
||||
ORIGIN_HANDOFF_MAX_PAYLOAD_LENGTH,
|
||||
OriginHandoffCreateRequest,
|
||||
OriginHandoffCreateResponse,
|
||||
OriginHandoffRedeemRequest,
|
||||
OriginHandoffRedeemResponse,
|
||||
} from '@fluxer/schema/src/domains/auth/OriginHandoffSchemas';
|
||||
import {bodyLimit} from 'hono/body-limit';
|
||||
|
||||
const ORIGIN_HANDOFF_CREATE_MAX_BODY_BYTES = ORIGIN_HANDOFF_MAX_PAYLOAD_LENGTH + 1024;
|
||||
|
||||
export function OriginHandoffController(app: HonoApp) {
|
||||
app.post(
|
||||
'/auth/origin-handoff',
|
||||
RateLimitMiddleware(RateLimitConfigs.AUTH_ORIGIN_HANDOFF_CREATE),
|
||||
LoginRequired,
|
||||
DefaultUserOnly,
|
||||
bodyLimit({
|
||||
maxSize: ORIGIN_HANDOFF_CREATE_MAX_BODY_BYTES,
|
||||
onError: () => {
|
||||
throw new FileSizeTooLargeError(ORIGIN_HANDOFF_CREATE_MAX_BODY_BYTES);
|
||||
},
|
||||
}),
|
||||
Validator('json', OriginHandoffCreateRequest),
|
||||
OpenAPI({
|
||||
operationId: 'create_origin_handoff',
|
||||
summary: 'Create origin handoff',
|
||||
responseSchema: OriginHandoffCreateResponse,
|
||||
statusCode: 200,
|
||||
security: ['sessionToken'],
|
||||
tags: ['Auth'],
|
||||
description:
|
||||
'Store encrypted client state for up to two minutes so another first-party web origin can redeem it once. The receiving origin must present the nonce whose SHA-256 digest is sent here.',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const body = ctx.req.valid('json');
|
||||
const handoffId = await createOriginHandoff(ctx.get('cacheService'), {
|
||||
userId: ctx.get('user').id,
|
||||
nonceHash: body.nonce_hash,
|
||||
payload: body.payload,
|
||||
});
|
||||
const response: OriginHandoffCreateResponse = {handoff_id: handoffId};
|
||||
return ctx.json(response);
|
||||
},
|
||||
);
|
||||
app.post(
|
||||
'/auth/origin-handoff/redeem',
|
||||
RateLimitMiddleware(RateLimitConfigs.AUTH_ORIGIN_HANDOFF_REDEEM),
|
||||
Validator('json', OriginHandoffRedeemRequest),
|
||||
OpenAPI({
|
||||
operationId: 'redeem_origin_handoff',
|
||||
summary: 'Redeem origin handoff',
|
||||
responseSchema: OriginHandoffRedeemResponse,
|
||||
statusCode: 200,
|
||||
security: [],
|
||||
tags: ['Auth'],
|
||||
description:
|
||||
'Return the encrypted client state stored by create origin handoff and delete it in the same step. A wrong nonce also consumes the handoff. On the official instance the request must come from a first-party web origin.',
|
||||
}),
|
||||
async (ctx) => {
|
||||
if (!Config.instance.selfHosted) {
|
||||
const origin = ctx.req.header('origin');
|
||||
if (origin === undefined || !Config.endpoints.webAppOrigins.includes(origin)) {
|
||||
throw new InvalidApiOriginError();
|
||||
}
|
||||
}
|
||||
const body = ctx.req.valid('json');
|
||||
const payload = await redeemOriginHandoff(ctx.get('cacheService'), {
|
||||
handoffId: body.handoff_id,
|
||||
nonce: body.nonce,
|
||||
});
|
||||
const response: OriginHandoffRedeemResponse = {payload};
|
||||
return ctx.json(response);
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -25,6 +25,7 @@ const POLL_SECRET_BYTES = 32;
|
||||
interface HandoffData {
|
||||
createdAt: number;
|
||||
origin: SessionOrigin;
|
||||
initiatorOrigin?: string | null;
|
||||
infoLookupCount: number;
|
||||
pollSecretHash: string;
|
||||
}
|
||||
@@ -84,7 +85,7 @@ function pollSecretMatches(presented: string | undefined, storedHash: string | u
|
||||
export class DesktopHandoffService {
|
||||
constructor(private readonly apiContext: ApiContext) {}
|
||||
|
||||
async initiateHandoff(args: {origin: SessionOrigin}): Promise<{
|
||||
async initiateHandoff(args: {origin: SessionOrigin; initiatorOrigin?: string | null}): Promise<{
|
||||
code: string;
|
||||
expiresAt: Date;
|
||||
pollSecret: string;
|
||||
@@ -95,6 +96,7 @@ export class DesktopHandoffService {
|
||||
const handoffData: HandoffData = {
|
||||
createdAt: Date.now(),
|
||||
origin: args.origin,
|
||||
initiatorOrigin: args.initiatorOrigin ?? null,
|
||||
infoLookupCount: 0,
|
||||
pollSecretHash: hashPollSecret(pollSecret),
|
||||
};
|
||||
@@ -108,7 +110,7 @@ export class DesktopHandoffService {
|
||||
code: string,
|
||||
createTokenData: (origin: SessionOrigin) => Promise<{token: string; userId: string}>,
|
||||
approverIp: string,
|
||||
): Promise<void> {
|
||||
): Promise<{initiatorOrigin: string | null}> {
|
||||
const {cache} = this.apiContext.services;
|
||||
const normalizedCode = requireNormalizedHandoffCode(code);
|
||||
await this.checkAttemptLimit(approverIp);
|
||||
@@ -138,6 +140,7 @@ export class DesktopHandoffService {
|
||||
await cache.set(`${HANDOFF_TOKEN_PREFIX}${normalizedCode}`, tokenData, remainingSeconds);
|
||||
await cache.delete(`${HANDOFF_CODE_PREFIX}${normalizedCode}`);
|
||||
await cache.delete(`${HANDOFF_APPROVER_PREFIX}${normalizedCode}`);
|
||||
return {initiatorOrigin: handoffData.initiatorOrigin ?? null};
|
||||
}
|
||||
|
||||
async getHandoffInfo(
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {createHash, randomBytes, timingSafeEqual} from 'node:crypto';
|
||||
import type {UserID} from '@app/api/BrandedTypes';
|
||||
import {InvalidOriginHandoffNonceError} from '@fluxer/errors/src/domains/auth/InvalidOriginHandoffNonceError';
|
||||
import {UnknownOriginHandoffError} from '@fluxer/errors/src/domains/auth/UnknownOriginHandoffError';
|
||||
import type {ICacheService} from '@pkgs/cache/src/ICacheService';
|
||||
import {seconds} from 'itty-time';
|
||||
|
||||
const ORIGIN_HANDOFF_KEY_PREFIX = 'origin_handoff:';
|
||||
const ORIGIN_HANDOFF_ID_BYTES = 32;
|
||||
|
||||
interface OriginHandoffRecord {
|
||||
nonce_hash: string;
|
||||
payload: string;
|
||||
user_id: string;
|
||||
created_at: number;
|
||||
}
|
||||
|
||||
function sha256Hex(value: string): string {
|
||||
return createHash('sha256').update(value).digest('hex');
|
||||
}
|
||||
|
||||
function originHandoffKey(handoffId: string): string {
|
||||
return `${ORIGIN_HANDOFF_KEY_PREFIX}${sha256Hex(handoffId)}`;
|
||||
}
|
||||
|
||||
export async function createOriginHandoff(
|
||||
cache: ICacheService,
|
||||
args: {userId: UserID; nonceHash: string; payload: string},
|
||||
): Promise<string> {
|
||||
const handoffId = randomBytes(ORIGIN_HANDOFF_ID_BYTES).toString('base64url');
|
||||
const record: OriginHandoffRecord = {
|
||||
nonce_hash: args.nonceHash,
|
||||
payload: args.payload,
|
||||
user_id: args.userId.toString(),
|
||||
created_at: Date.now(),
|
||||
};
|
||||
await cache.set(originHandoffKey(handoffId), record, seconds('2 minutes'));
|
||||
return handoffId;
|
||||
}
|
||||
|
||||
export async function redeemOriginHandoff(
|
||||
cache: ICacheService,
|
||||
args: {handoffId: string; nonce: string},
|
||||
): Promise<string> {
|
||||
const record = await cache.getAndDelete<OriginHandoffRecord>(originHandoffKey(args.handoffId));
|
||||
if (!record) {
|
||||
throw new UnknownOriginHandoffError();
|
||||
}
|
||||
const presented = Buffer.from(sha256Hex(args.nonce), 'hex');
|
||||
const stored = Buffer.from(record.nonce_hash, 'hex');
|
||||
if (presented.length !== stored.length || !timingSafeEqual(presented, stored)) {
|
||||
throw new InvalidOriginHandoffNonceError();
|
||||
}
|
||||
return record.payload;
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {createHash, randomBytes} from 'node:crypto';
|
||||
import {createAuthHarness, createTestAccount} from '@app/api/auth/tests/AuthTestUtils';
|
||||
import {createTestBotAccount} from '@app/api/bot/tests/BotTestUtils';
|
||||
import {getConfig} from '@app/api/Config';
|
||||
import type {ApiTestHarness} from '@app/api/test/ApiTestHarness';
|
||||
import {HTTP_STATUS} from '@app/api/test/TestConstants';
|
||||
import {createBuilder, createBuilderWithoutAuth} from '@app/api/test/TestRequestBuilder';
|
||||
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
|
||||
import {SuspiciousActivityFlags} from '@fluxer/constants/src/UserConstants';
|
||||
import {
|
||||
ORIGIN_HANDOFF_MAX_PAYLOAD_LENGTH,
|
||||
type OriginHandoffCreateResponse,
|
||||
type OriginHandoffRedeemResponse,
|
||||
} from '@fluxer/schema/src/domains/auth/OriginHandoffSchemas';
|
||||
import {afterAll, afterEach, beforeAll, beforeEach, describe, expect, it} from 'vitest';
|
||||
|
||||
const CREATE_PATH = '/auth/origin-handoff';
|
||||
const REDEEM_PATH = '/auth/origin-handoff/redeem';
|
||||
const PAYLOAD = randomBytes(96).toString('base64url');
|
||||
|
||||
function createNonce(): {nonce: string; nonceHash: string} {
|
||||
const nonce = randomBytes(32).toString('base64url');
|
||||
return {nonce, nonceHash: createHash('sha256').update(nonce).digest('hex')};
|
||||
}
|
||||
|
||||
describe('Origin handoff', () => {
|
||||
let harness: ApiTestHarness;
|
||||
let webAppOrigin: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
harness = await createAuthHarness();
|
||||
webAppOrigin = getConfig().endpoints.webAppOrigins[0];
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await harness.reset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
getConfig().instance.selfHosted = false;
|
||||
getConfig().endpoints.webAppOrigins = [webAppOrigin];
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await harness?.shutdown();
|
||||
});
|
||||
|
||||
async function createHandoff(token: string, nonceHash: string): Promise<string> {
|
||||
const response = await createBuilder<OriginHandoffCreateResponse>(harness, token)
|
||||
.post(CREATE_PATH)
|
||||
.body({nonce_hash: nonceHash, payload: PAYLOAD})
|
||||
.execute();
|
||||
expect(response.handoff_id).toMatch(/^[A-Za-z0-9_-]{43}$/);
|
||||
return response.handoff_id;
|
||||
}
|
||||
|
||||
it('hands the payload over once to the origin that holds the nonce', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const {nonce, nonceHash} = createNonce();
|
||||
const handoffId = await createHandoff(account.token, nonceHash);
|
||||
|
||||
const redeemed = await createBuilderWithoutAuth<OriginHandoffRedeemResponse>(harness)
|
||||
.post(REDEEM_PATH)
|
||||
.header('origin', webAppOrigin)
|
||||
.body({handoff_id: handoffId, nonce})
|
||||
.execute();
|
||||
expect(redeemed).toEqual({payload: PAYLOAD});
|
||||
|
||||
await createBuilderWithoutAuth(harness)
|
||||
.post(REDEEM_PATH)
|
||||
.header('origin', webAppOrigin)
|
||||
.body({handoff_id: handoffId, nonce})
|
||||
.expect(HTTP_STATUS.NOT_FOUND, APIErrorCodes.UNKNOWN_ORIGIN_HANDOFF)
|
||||
.execute();
|
||||
});
|
||||
|
||||
it('consumes the handoff when the nonce does not match', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const {nonce, nonceHash} = createNonce();
|
||||
const handoffId = await createHandoff(account.token, nonceHash);
|
||||
|
||||
await createBuilderWithoutAuth(harness)
|
||||
.post(REDEEM_PATH)
|
||||
.header('origin', webAppOrigin)
|
||||
.body({handoff_id: handoffId, nonce: createNonce().nonce})
|
||||
.expect(HTTP_STATUS.BAD_REQUEST, APIErrorCodes.INVALID_ORIGIN_HANDOFF_NONCE)
|
||||
.execute();
|
||||
|
||||
await createBuilderWithoutAuth(harness)
|
||||
.post(REDEEM_PATH)
|
||||
.header('origin', webAppOrigin)
|
||||
.body({handoff_id: handoffId, nonce})
|
||||
.expect(HTTP_STATUS.NOT_FOUND, APIErrorCodes.UNKNOWN_ORIGIN_HANDOFF)
|
||||
.execute();
|
||||
});
|
||||
|
||||
it('answers an unknown handoff id with its own error code', async () => {
|
||||
await createBuilderWithoutAuth(harness)
|
||||
.post(REDEEM_PATH)
|
||||
.header('origin', webAppOrigin)
|
||||
.body({handoff_id: randomBytes(32).toString('base64url'), nonce: createNonce().nonce})
|
||||
.expect(HTTP_STATUS.NOT_FOUND, APIErrorCodes.UNKNOWN_ORIGIN_HANDOFF)
|
||||
.execute();
|
||||
});
|
||||
|
||||
it('requires a logged-in user to create a handoff', async () => {
|
||||
await createBuilderWithoutAuth(harness)
|
||||
.post(CREATE_PATH)
|
||||
.body({nonce_hash: createNonce().nonceHash, payload: PAYLOAD})
|
||||
.expect(HTTP_STATUS.UNAUTHORIZED)
|
||||
.execute();
|
||||
});
|
||||
|
||||
it('refuses to create a handoff for an account flagged as suspicious', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
await createBuilderWithoutAuth(harness)
|
||||
.post(`/test/users/${account.userId}/security-flags`)
|
||||
.body({suspicious_activity_flags: SuspiciousActivityFlags.REQUIRE_VERIFIED_PHONE})
|
||||
.execute();
|
||||
await createBuilder(harness, account.token)
|
||||
.post(CREATE_PATH)
|
||||
.body({nonce_hash: createNonce().nonceHash, payload: PAYLOAD})
|
||||
.expect(HTTP_STATUS.FORBIDDEN, APIErrorCodes.ACCOUNT_SUSPICIOUS_ACTIVITY)
|
||||
.execute();
|
||||
});
|
||||
|
||||
it('refuses a create body larger than the payload ceiling before parsing it', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
await createBuilder(harness, account.token)
|
||||
.post(CREATE_PATH)
|
||||
.body({nonce_hash: createNonce().nonceHash, payload: 'a'.repeat(ORIGIN_HANDOFF_MAX_PAYLOAD_LENGTH + 2048)})
|
||||
.expect(HTTP_STATUS.BAD_REQUEST, APIErrorCodes.FILE_SIZE_TOO_LARGE)
|
||||
.execute();
|
||||
});
|
||||
|
||||
it('refuses to create a handoff for a bot', async () => {
|
||||
const bot = await createTestBotAccount(harness);
|
||||
await createBuilder(harness, `Bot ${bot.botToken}`)
|
||||
.post(CREATE_PATH)
|
||||
.body({nonce_hash: createNonce().nonceHash, payload: PAYLOAD})
|
||||
.expect(HTTP_STATUS.FORBIDDEN)
|
||||
.execute();
|
||||
});
|
||||
|
||||
it.each([
|
||||
{name: 'an uppercase nonce hash', body: {nonce_hash: 'A'.repeat(64), payload: PAYLOAD}},
|
||||
{name: 'a short nonce hash', body: {nonce_hash: 'a'.repeat(63), payload: PAYLOAD}},
|
||||
{name: 'a payload outside base64url', body: {nonce_hash: 'a'.repeat(64), payload: 'not+base64/url='}},
|
||||
{name: 'an empty payload', body: {nonce_hash: 'a'.repeat(64), payload: ''}},
|
||||
])('rejects $name', async ({body}) => {
|
||||
const account = await createTestAccount(harness);
|
||||
await createBuilder(harness, account.token)
|
||||
.post(CREATE_PATH)
|
||||
.body(body)
|
||||
.expect(HTTP_STATUS.BAD_REQUEST, APIErrorCodes.INVALID_FORM_BODY)
|
||||
.execute();
|
||||
});
|
||||
|
||||
it('refuses a redeem from an origin outside the first-party web origins', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const {nonce, nonceHash} = createNonce();
|
||||
const handoffId = await createHandoff(account.token, nonceHash);
|
||||
|
||||
await createBuilderWithoutAuth(harness)
|
||||
.post(REDEEM_PATH)
|
||||
.header('origin', 'https://evil.example')
|
||||
.body({handoff_id: handoffId, nonce})
|
||||
.expect(HTTP_STATUS.FORBIDDEN, APIErrorCodes.INVALID_API_ORIGIN)
|
||||
.execute();
|
||||
|
||||
await createBuilderWithoutAuth(harness)
|
||||
.post(REDEEM_PATH)
|
||||
.body({handoff_id: handoffId, nonce})
|
||||
.expect(HTTP_STATUS.FORBIDDEN, APIErrorCodes.INVALID_API_ORIGIN)
|
||||
.execute();
|
||||
|
||||
const redeemed = await createBuilderWithoutAuth<OriginHandoffRedeemResponse>(harness)
|
||||
.post(REDEEM_PATH)
|
||||
.header('origin', webAppOrigin)
|
||||
.body({handoff_id: handoffId, nonce})
|
||||
.execute();
|
||||
expect(redeemed.payload).toBe(PAYLOAD);
|
||||
});
|
||||
|
||||
it('accepts a redeem from a configured web app origin alias', async () => {
|
||||
getConfig().endpoints.webAppOrigins = [webAppOrigin, 'https://fluxer.com'];
|
||||
const account = await createTestAccount(harness);
|
||||
const {nonce, nonceHash} = createNonce();
|
||||
const handoffId = await createHandoff(account.token, nonceHash);
|
||||
|
||||
const redeemed = await createBuilderWithoutAuth<OriginHandoffRedeemResponse>(harness)
|
||||
.post(REDEEM_PATH)
|
||||
.header('origin', 'https://fluxer.com')
|
||||
.body({handoff_id: handoffId, nonce})
|
||||
.execute();
|
||||
expect(redeemed.payload).toBe(PAYLOAD);
|
||||
});
|
||||
|
||||
it('skips the origin check on a self-hosted instance', async () => {
|
||||
getConfig().instance.selfHosted = true;
|
||||
const account = await createTestAccount(harness);
|
||||
const {nonce, nonceHash} = createNonce();
|
||||
const handoffId = await createHandoff(account.token, nonceHash);
|
||||
|
||||
const redeemed = await createBuilderWithoutAuth<OriginHandoffRedeemResponse>(harness)
|
||||
.post(REDEEM_PATH)
|
||||
.body({handoff_id: handoffId, nonce})
|
||||
.execute();
|
||||
expect(redeemed.payload).toBe(PAYLOAD);
|
||||
});
|
||||
});
|
||||
@@ -129,6 +129,7 @@ export interface APIConfig {
|
||||
apiPublic: string;
|
||||
apiClient: string;
|
||||
webApp: string;
|
||||
webAppOrigins: Array<string>;
|
||||
gateway: string;
|
||||
media: string;
|
||||
staticCdn: string;
|
||||
|
||||
@@ -8,6 +8,7 @@ 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 {resolveDomainMigrationAssignment} from '@fluxer/schema/src/domains/admin/DomainMigrationSchemas';
|
||||
import {resolveVoiceNoiseSuppressionAssignment} from '@fluxer/schema/src/domains/admin/VoiceNoiseSuppressionSchemas';
|
||||
import {ExperimentAssignmentsResponse} from '@fluxer/schema/src/domains/experiment/ExperimentSchemas';
|
||||
|
||||
@@ -28,9 +29,10 @@ export function ExperimentController(app: HonoApp) {
|
||||
}),
|
||||
async (ctx) => {
|
||||
const instanceConfigRepository = ctx.get('instanceConfigRepository');
|
||||
const [delivery, voiceConfig] = await Promise.all([
|
||||
const [delivery, voiceConfig, domainMigrationConfig] = await Promise.all([
|
||||
instanceConfigRepository.getExperimentDeliveryConfig(),
|
||||
instanceConfigRepository.getVoiceNoiseSuppressionConfig(),
|
||||
instanceConfigRepository.getDomainMigrationConfig(),
|
||||
]);
|
||||
const userId = ctx.get('user').id.toString();
|
||||
const body: ExperimentAssignmentsResponse = {
|
||||
@@ -38,6 +40,7 @@ export function ExperimentController(app: HonoApp) {
|
||||
poll_jitter_percent: delivery.poll_jitter_percent,
|
||||
assignments: {
|
||||
voice_noise_suppression: resolveVoiceNoiseSuppressionAssignment(voiceConfig, userId),
|
||||
domain_migration: resolveDomainMigrationAssignment(domainMigrationConfig, userId),
|
||||
},
|
||||
};
|
||||
const etag = `"${createHash('sha256').update(JSON.stringify(body)).digest('hex')}"`;
|
||||
|
||||
@@ -6,6 +6,10 @@ import {type ApiTestHarness, createApiTestHarness} from '@app/api/test/ApiTestHa
|
||||
import {HTTP_STATUS} from '@app/api/test/TestConstants';
|
||||
import {createBuilder, createBuilderWithoutAuth} from '@app/api/test/TestRequestBuilder';
|
||||
import {AdminACLs} from '@fluxer/constants/src/AdminACLs';
|
||||
import {
|
||||
DEFAULT_DOMAIN_MIGRATION_CONFIG,
|
||||
INERT_DOMAIN_MIGRATION_ASSIGNMENT,
|
||||
} from '@fluxer/schema/src/domains/admin/DomainMigrationSchemas';
|
||||
import {
|
||||
DEFAULT_VOICE_NOISE_SUPPRESSION_CONFIG,
|
||||
INERT_VOICE_NOISE_SUPPRESSION_ASSIGNMENT,
|
||||
@@ -15,6 +19,7 @@ import {
|
||||
DEFAULT_EXPERIMENT_POLL_JITTER_PERCENT,
|
||||
type ExperimentAssignmentsResponse,
|
||||
type ExperimentDeliveryConfigResponse,
|
||||
readDomainMigrationAssignment,
|
||||
readVoiceNoiseSuppressionAssignment,
|
||||
} from '@fluxer/schema/src/domains/experiment/ExperimentSchemas';
|
||||
import {afterAll, beforeAll, beforeEach, describe, expect, it} from 'vitest';
|
||||
@@ -51,6 +56,7 @@ describe('GET /experiments', () => {
|
||||
poll_jitter_percent: DEFAULT_EXPERIMENT_POLL_JITTER_PERCENT,
|
||||
assignments: {
|
||||
voice_noise_suppression: INERT_VOICE_NOISE_SUPPRESSION_ASSIGNMENT,
|
||||
domain_migration: INERT_DOMAIN_MIGRATION_ASSIGNMENT,
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -82,6 +88,52 @@ describe('GET /experiments', () => {
|
||||
expect(readVoiceNoiseSuppressionAssignment(body).enabled).toBe(false);
|
||||
});
|
||||
|
||||
it('populates the domain migration assignment key even when the rollout is disabled', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
|
||||
const body = await createBuilder<ExperimentAssignmentsResponse>(harness, account.token).get(ENDPOINT).execute();
|
||||
|
||||
expect(Object.hasOwn(body.assignments, 'domain_migration')).toBe(true);
|
||||
expect(readDomainMigrationAssignment(body).enabled).toBe(false);
|
||||
});
|
||||
|
||||
it('resolves the domain migration caller through the allowlist', async () => {
|
||||
const targeted = await createTestAccount(harness);
|
||||
const untargeted = await createTestAccount(harness);
|
||||
await getInstanceConfigRepository().setDomainMigrationConfig({
|
||||
...DEFAULT_DOMAIN_MIGRATION_CONFIG,
|
||||
enabled: true,
|
||||
config_version: 4,
|
||||
rollout_basis_points: 0,
|
||||
included_user_ids: [targeted.userId],
|
||||
});
|
||||
|
||||
const targetedBody = await createBuilder<ExperimentAssignmentsResponse>(harness, targeted.token)
|
||||
.get(ENDPOINT)
|
||||
.execute();
|
||||
expect(targetedBody.assignments.domain_migration).toEqual({enabled: true});
|
||||
|
||||
const untargetedBody = await createBuilder<ExperimentAssignmentsResponse>(harness, untargeted.token)
|
||||
.get(ENDPOINT)
|
||||
.execute();
|
||||
expect(untargetedBody.assignments.domain_migration).toEqual({enabled: false});
|
||||
});
|
||||
|
||||
it('keeps the domain migration exclusion ahead of a full rollout', async () => {
|
||||
const excluded = await createTestAccount(harness);
|
||||
await getInstanceConfigRepository().setDomainMigrationConfig({
|
||||
...DEFAULT_DOMAIN_MIGRATION_CONFIG,
|
||||
enabled: true,
|
||||
rollout_basis_points: 10000,
|
||||
included_user_ids: [excluded.userId],
|
||||
excluded_user_ids: [excluded.userId],
|
||||
});
|
||||
|
||||
const body = await createBuilder<ExperimentAssignmentsResponse>(harness, excluded.token).get(ENDPOINT).execute();
|
||||
|
||||
expect(body.assignments.domain_migration).toEqual({enabled: false});
|
||||
});
|
||||
|
||||
it('serves the delivery cadence from the delivery config and not from the voice config', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
await getInstanceConfigRepository().setExperimentDeliveryConfig({
|
||||
@@ -196,6 +248,30 @@ describe('GET /experiments', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('serves a fresh body once the domain migration config changes', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
|
||||
const first = await createBuilder<ExperimentAssignmentsResponse>(harness, account.token)
|
||||
.get(ENDPOINT)
|
||||
.executeWithResponse();
|
||||
const staleEtag = first.response.headers.get('etag') as string;
|
||||
|
||||
await getInstanceConfigRepository().setDomainMigrationConfig({
|
||||
...DEFAULT_DOMAIN_MIGRATION_CONFIG,
|
||||
enabled: true,
|
||||
config_version: 1,
|
||||
rollout_basis_points: 10000,
|
||||
});
|
||||
|
||||
const refreshed = await createBuilder<ExperimentAssignmentsResponse>(harness, account.token)
|
||||
.get(ENDPOINT)
|
||||
.header('If-None-Match', staleEtag)
|
||||
.executeWithResponse();
|
||||
expect(refreshed.response.status).toBe(HTTP_STATUS.OK);
|
||||
expect(refreshed.response.headers.get('etag')).not.toBe(staleEtag);
|
||||
expect(refreshed.json?.assignments.domain_migration).toEqual({enabled: true});
|
||||
});
|
||||
|
||||
it('serves a fresh body once the delivery config changes', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
|
||||
@@ -252,6 +328,47 @@ describe('GET /experiments', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('bumps the domain migration 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<{domain_migration: {config_version: number; enabled: boolean}}>(
|
||||
harness,
|
||||
admin.token,
|
||||
)
|
||||
.patch('/admin/instance/config')
|
||||
.body({domain_migration: {enabled: true, rollout_basis_points: 10000}})
|
||||
.execute();
|
||||
expect(afterFirst.domain_migration).toMatchObject({config_version: 1, enabled: true});
|
||||
|
||||
const afterSecond = await createBuilder<{
|
||||
domain_migration: {config_version: number; enabled: boolean; anonymous_rollout_basis_points: number};
|
||||
}>(harness, admin.token)
|
||||
.patch('/admin/instance/config')
|
||||
.body({domain_migration: {anonymous_rollout_basis_points: 2500}})
|
||||
.execute();
|
||||
expect(afterSecond.domain_migration).toMatchObject({
|
||||
config_version: 2,
|
||||
enabled: true,
|
||||
anonymous_rollout_basis_points: 2500,
|
||||
});
|
||||
|
||||
const afterEmpty = await createBuilder<{domain_migration: {config_version: number; enabled: boolean}}>(
|
||||
harness,
|
||||
admin.token,
|
||||
)
|
||||
.patch('/admin/instance/config')
|
||||
.body({domain_migration: {}})
|
||||
.execute();
|
||||
expect(afterEmpty.domain_migration).toMatchObject({config_version: 2, enabled: true});
|
||||
|
||||
const body = await createBuilder<ExperimentAssignmentsResponse>(harness, admin.token).get(ENDPOINT).execute();
|
||||
expect(body.assignments.domain_migration).toEqual({enabled: true});
|
||||
});
|
||||
|
||||
it('leaves the config version alone for an admin update that sets no field', async () => {
|
||||
const admin = await setUserACLs(harness, await createTestAccount(harness), [
|
||||
AdminACLs.AUTHENTICATE,
|
||||
|
||||
@@ -18,6 +18,10 @@ import {InstanceConfigWriteRaceExecutor} from '@app/api/instance/tests/InstanceC
|
||||
import {startDockerContainer} from '@app/api/test/DockerTestContainer';
|
||||
import {InMemoryCassandraQueryExecutor} from '@app/api/test/InMemoryCassandraQueryExecutor';
|
||||
import {MockKVProvider} from '@app/api/test/mocks/MockKVProvider';
|
||||
import {
|
||||
DEFAULT_DOMAIN_MIGRATION_CONFIG,
|
||||
type DomainMigrationConfig,
|
||||
} from '@fluxer/schema/src/domains/admin/DomainMigrationSchemas';
|
||||
import {
|
||||
DEFAULT_VOICE_NOISE_SUPPRESSION_CONFIG,
|
||||
type VoiceNoiseSuppressionConfig,
|
||||
@@ -35,6 +39,7 @@ import {
|
||||
import {afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi} from 'vitest';
|
||||
|
||||
const VOICE_NOISE_SUPPRESSION_CONFIG_KEY = 'voice_noise_suppression_config';
|
||||
const DOMAIN_MIGRATION_CONFIG_KEY = 'domain_migration_config';
|
||||
const EXPERIMENT_DELIVERY_CONFIG_KEY = 'experiment_delivery_config';
|
||||
const APP_PUBLIC_CONFIG_KEY = 'app_public_config';
|
||||
const INSTANCE_POLICY_CONFIG_KEY = 'instance_policy_config';
|
||||
@@ -351,6 +356,92 @@ describe('InstanceConfigRepository', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('returns the default domain migration config when the key is absent', async () => {
|
||||
const executor = new CountingInMemoryCassandraQueryExecutor();
|
||||
setCassandraQueryExecutorForTesting(executor);
|
||||
const kvProvider = new MockKVProvider();
|
||||
const repository = createRepository(kvProvider);
|
||||
|
||||
await expect(repository.getDomainMigrationConfig()).resolves.toEqual(DEFAULT_DOMAIN_MIGRATION_CONFIG);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{name: 'unparseable text', stored: 'not-json'},
|
||||
{name: 'a json array', stored: '[]'},
|
||||
{name: 'out-of-range values', stored: '{"rollout_basis_points":99999}'},
|
||||
{name: 'a non-boolean enabled flag', stored: '{"enabled":"yes"}'},
|
||||
])('falls back to the default domain migration config for $name', async ({stored}) => {
|
||||
const executor = new CountingInMemoryCassandraQueryExecutor();
|
||||
setCassandraQueryExecutorForTesting(executor);
|
||||
const kvProvider = new MockKVProvider();
|
||||
const repository = createRepository(kvProvider);
|
||||
|
||||
await repository.setConfig(DOMAIN_MIGRATION_CONFIG_KEY, stored);
|
||||
|
||||
await expect(repository.getDomainMigrationConfig()).resolves.toEqual(DEFAULT_DOMAIN_MIGRATION_CONFIG);
|
||||
});
|
||||
|
||||
it('round-trips a stored domain migration config', async () => {
|
||||
const executor = new CountingInMemoryCassandraQueryExecutor();
|
||||
setCassandraQueryExecutorForTesting(executor);
|
||||
const kvProvider = new MockKVProvider();
|
||||
const repository = createRepository(kvProvider);
|
||||
|
||||
const config: DomainMigrationConfig = {
|
||||
...DEFAULT_DOMAIN_MIGRATION_CONFIG,
|
||||
enabled: true,
|
||||
config_version: 5,
|
||||
rollout_basis_points: 2500,
|
||||
rollout_salt: 'domain-migration-v2',
|
||||
included_user_ids: ['1400000000000000001'],
|
||||
excluded_user_ids: ['1400000000000000002'],
|
||||
anonymous_rollout_basis_points: 300,
|
||||
standalone_forwarding: true,
|
||||
};
|
||||
await repository.setDomainMigrationConfig(config);
|
||||
|
||||
await expect(repository.getDomainMigrationConfig()).resolves.toEqual(config);
|
||||
});
|
||||
|
||||
it('fills newly added domain migration fields from the schema defaults', async () => {
|
||||
const executor = new CountingInMemoryCassandraQueryExecutor();
|
||||
setCassandraQueryExecutorForTesting(executor);
|
||||
const kvProvider = new MockKVProvider();
|
||||
const repository = createRepository(kvProvider);
|
||||
|
||||
await repository.setConfig(
|
||||
DOMAIN_MIGRATION_CONFIG_KEY,
|
||||
JSON.stringify({enabled: true, config_version: 2, rollout_basis_points: 1000}),
|
||||
);
|
||||
|
||||
await expect(repository.getDomainMigrationConfig()).resolves.toEqual({
|
||||
...DEFAULT_DOMAIN_MIGRATION_CONFIG,
|
||||
enabled: true,
|
||||
config_version: 2,
|
||||
rollout_basis_points: 1000,
|
||||
});
|
||||
});
|
||||
|
||||
it('publishes a refresh so another repository observes the domain migration config', async () => {
|
||||
const executor = new CountingInMemoryCassandraQueryExecutor();
|
||||
setCassandraQueryExecutorForTesting(executor);
|
||||
const kvProvider = new MockKVProvider();
|
||||
const reader = createRepository(kvProvider);
|
||||
const writer = createRepository(kvProvider);
|
||||
|
||||
await expect(reader.getDomainMigrationConfig()).resolves.toEqual(DEFAULT_DOMAIN_MIGRATION_CONFIG);
|
||||
|
||||
await writer.setDomainMigrationConfig({
|
||||
...DEFAULT_DOMAIN_MIGRATION_CONFIG,
|
||||
enabled: true,
|
||||
config_version: 1,
|
||||
});
|
||||
|
||||
await vi.waitFor(async () => {
|
||||
expect(await reader.getDomainMigrationConfig()).toMatchObject({enabled: true, config_version: 1});
|
||||
});
|
||||
});
|
||||
|
||||
it('returns the default experiment delivery config when the key is absent', async () => {
|
||||
const executor = new CountingInMemoryCassandraQueryExecutor();
|
||||
setCassandraQueryExecutorForTesting(executor);
|
||||
|
||||
@@ -28,6 +28,10 @@ import {
|
||||
type PendingRegistrationResponse,
|
||||
type RegistrationUrlResponse,
|
||||
} from '@fluxer/schema/src/domains/admin/AdminSchemas';
|
||||
import {
|
||||
type DomainMigrationConfig,
|
||||
DomainMigrationConfigSchema,
|
||||
} from '@fluxer/schema/src/domains/admin/DomainMigrationSchemas';
|
||||
import {
|
||||
type GatewayRolloutConfig,
|
||||
GatewayRolloutConfigSchema,
|
||||
@@ -63,6 +67,7 @@ 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 DOMAIN_MIGRATION_CONFIG_KEY = 'domain_migration_config';
|
||||
const EXPERIMENT_DELIVERY_CONFIG_KEY = 'experiment_delivery_config';
|
||||
const REGISTRATION_CONFIG_KEY = 'registration_config';
|
||||
const REGISTRATION_URLS_KEY = 'registration_urls';
|
||||
@@ -370,6 +375,7 @@ type StoredConfigSection =
|
||||
| 'gateway rollout'
|
||||
| 'voice noise suppression'
|
||||
| 'push service delivery'
|
||||
| 'domain migration'
|
||||
| 'experiment delivery'
|
||||
| 'instance policy'
|
||||
| 'integrations'
|
||||
@@ -512,6 +518,10 @@ function parseStoredPushServiceDeliveryConfig(raw: string | null): PushServiceDe
|
||||
return parseStoredConfigOrDefault(PushServiceDeliveryConfigSchema, raw, 'push service delivery');
|
||||
}
|
||||
|
||||
function parseStoredDomainMigrationConfig(raw: string | null): DomainMigrationConfig {
|
||||
return parseStoredConfigOrDefault(DomainMigrationConfigSchema, raw, 'domain migration');
|
||||
}
|
||||
|
||||
function parseStoredExperimentDeliveryConfig(raw: string | null): ExperimentDeliveryConfig {
|
||||
return parseStoredConfigOrDefault(ExperimentDeliveryConfigSchema, raw, 'experiment delivery');
|
||||
}
|
||||
@@ -1160,6 +1170,7 @@ export class InstanceConfigRepository {
|
||||
);
|
||||
parseStoredVoiceNoiseSuppressionConfig(snapshot.get(VOICE_NOISE_SUPPRESSION_CONFIG_KEY) ?? null);
|
||||
parseStoredPushServiceDeliveryConfig(snapshot.get(PUSH_SERVICE_DELIVERY_CONFIG_KEY) ?? null);
|
||||
parseStoredDomainMigrationConfig(snapshot.get(DOMAIN_MIGRATION_CONFIG_KEY) ?? null);
|
||||
parseStoredExperimentDeliveryConfig(snapshot.get(EXPERIMENT_DELIVERY_CONFIG_KEY) ?? null);
|
||||
const policy = parseStoredInstancePolicyConfig(snapshot.get(INSTANCE_POLICY_CONFIG_KEY) ?? null);
|
||||
checkStoredConfig('registration', () =>
|
||||
@@ -1273,6 +1284,27 @@ export class InstanceConfigRepository {
|
||||
);
|
||||
}
|
||||
|
||||
async getDomainMigrationConfig(): Promise<DomainMigrationConfig> {
|
||||
const raw = await this.getConfig(DOMAIN_MIGRATION_CONFIG_KEY);
|
||||
return parseStoredDomainMigrationConfig(raw);
|
||||
}
|
||||
|
||||
async setDomainMigrationConfig(config: DomainMigrationConfig): Promise<void> {
|
||||
await this.updateDomainMigrationConfig(() => config);
|
||||
}
|
||||
|
||||
updateDomainMigrationConfig(
|
||||
update: (current: DomainMigrationConfig) => DomainMigrationConfig,
|
||||
): Promise<DomainMigrationConfig> {
|
||||
return this.updateStoredConfig(DOMAIN_MIGRATION_CONFIG_KEY, (raw) =>
|
||||
validateStoredConfig(
|
||||
DomainMigrationConfigSchema,
|
||||
update(parseStoredDomainMigrationConfig(raw)),
|
||||
'domain migration',
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
async getExperimentDeliveryConfig(): Promise<ExperimentDeliveryConfig> {
|
||||
const raw = await this.getConfig(EXPERIMENT_DELIVERY_CONFIG_KEY);
|
||||
return parseStoredExperimentDeliveryConfig(raw);
|
||||
|
||||
@@ -8,6 +8,7 @@ import type {LimitConfigService} from '@app/api/limits/LimitConfigService';
|
||||
import {InMemoryCassandraQueryExecutor} from '@app/api/test/InMemoryCassandraQueryExecutor';
|
||||
import {MockKVProvider} from '@app/api/test/mocks/MockKVProvider';
|
||||
import type {HonoEnv} from '@app/api/types/HonoEnv';
|
||||
import {DEFAULT_DOMAIN_MIGRATION_CONFIG} from '@fluxer/schema/src/domains/admin/DomainMigrationSchemas';
|
||||
import {Hono} from 'hono';
|
||||
import {afterEach, describe, expect, it} from 'vitest';
|
||||
|
||||
@@ -96,4 +97,36 @@ describe('InstanceController discovery captcha', () => {
|
||||
turnstile_site_key: 'turnstile-site-key',
|
||||
});
|
||||
});
|
||||
|
||||
it('publishes the domain migration kill switch and anonymous rollout without the targeting lists', async () => {
|
||||
const repository = createRepository();
|
||||
const app = createApp(repository);
|
||||
|
||||
const initial = await app.request('http://localhost/.well-known/fluxer');
|
||||
expect(((await initial.json()) as {domain_migration: unknown}).domain_migration).toEqual({
|
||||
enabled: false,
|
||||
anonymous_rollout_basis_points: 0,
|
||||
rollout_salt: 'domain-migration-v1',
|
||||
standalone_forwarding: false,
|
||||
});
|
||||
|
||||
await repository.setDomainMigrationConfig({
|
||||
...DEFAULT_DOMAIN_MIGRATION_CONFIG,
|
||||
enabled: true,
|
||||
config_version: 2,
|
||||
rollout_basis_points: 100,
|
||||
anonymous_rollout_basis_points: 1500,
|
||||
included_user_ids: ['1400000000000000001'],
|
||||
standalone_forwarding: true,
|
||||
});
|
||||
|
||||
const updated = await app.request('http://localhost/.well-known/fluxer');
|
||||
expect(updated.headers.get('etag')).not.toBe(initial.headers.get('etag'));
|
||||
expect(((await updated.json()) as {domain_migration: unknown}).domain_migration).toEqual({
|
||||
enabled: true,
|
||||
anonymous_rollout_basis_points: 1500,
|
||||
rollout_salt: 'domain-migration-v1',
|
||||
standalone_forwarding: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -16,6 +16,7 @@ import type {HonoEnv} from '@app/api/types/HonoEnv';
|
||||
import {API_CODE_VERSION} from '@fluxer/constants/src/AppConstants';
|
||||
import {buildDiscoveryResponse, type DiscoveryStaticInput} from '@fluxer/instance_bootstrap/src/BuildDiscovery';
|
||||
import type {InstanceAppPublic} from '@fluxer/instance_bootstrap/src/Types';
|
||||
import {toDomainMigrationDiscovery} from '@fluxer/schema/src/domains/admin/DomainMigrationSchemas';
|
||||
import {WellKnownFluxerResponse} from '@fluxer/schema/src/domains/instance/InstanceSchemas';
|
||||
import type {Hono} from 'hono';
|
||||
|
||||
@@ -102,15 +103,16 @@ export function InstanceController(app: Hono<HonoEnv>) {
|
||||
const limits = ctx.get('limitConfigService').getConfigWireFormat();
|
||||
const sso = await ctx.get('ssoService').getPublicStatus();
|
||||
const instanceConfigRepository = ctx.get('instanceConfigRepository');
|
||||
const [registration, community, services, appPublicConfig, captcha, email] = await Promise.all([
|
||||
const [registration, community, services, appPublicConfig, captcha, email, domainMigration] = await Promise.all([
|
||||
instanceConfigRepository.getRegistrationPublicConfig(),
|
||||
instanceConfigRepository.getInstanceCommunityPublicConfig(),
|
||||
instanceConfigRepository.getResolvedServicesConfig(),
|
||||
instanceConfigRepository.getAppPublicConfig(),
|
||||
instanceConfigRepository.getEffectiveCaptchaConfig(),
|
||||
instanceConfigRepository.getEffectiveEmailConfig(),
|
||||
instanceConfigRepository.getDomainMigrationConfig(),
|
||||
]);
|
||||
const response = buildDiscoveryResponse(
|
||||
const discovery = buildDiscoveryResponse(
|
||||
buildDiscoveryStaticInput(
|
||||
gifService,
|
||||
{
|
||||
@@ -133,6 +135,7 @@ export function InstanceController(app: Hono<HonoEnv>) {
|
||||
limits,
|
||||
},
|
||||
);
|
||||
const response = {...discovery, domain_migration: toDomainMigrationDiscovery(domainMigration)};
|
||||
discoveryValidators = nextDiscoveryValidators(response, discoveryValidators);
|
||||
ctx.header('ETag', discoveryValidators.etag);
|
||||
ctx.header('Last-Modified', discoveryValidators.lastModified.toUTCString());
|
||||
|
||||
@@ -1,11 +1,17 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {Config} from '@app/api/Config';
|
||||
import {InvalidApiOriginError} from '@fluxer/errors/src/domains/core/InvalidApiOriginError';
|
||||
import type {Context, Next} from 'hono';
|
||||
|
||||
const LEGACY_APP_ORIGINS = ['https://web.fluxer.app', 'https://web.canary.fluxer.app'];
|
||||
|
||||
export async function BlockAppOriginMiddleware(ctx: Context, next: Next) {
|
||||
const origin = ctx.req.header('origin');
|
||||
if (origin === 'https://web.fluxer.app' || origin === 'https://web.canary.fluxer.app') {
|
||||
if (
|
||||
origin !== undefined &&
|
||||
(LEGACY_APP_ORIGINS.includes(origin) || Config.endpoints.webAppOrigins.includes(origin))
|
||||
) {
|
||||
throw new InvalidApiOriginError();
|
||||
}
|
||||
await next();
|
||||
|
||||
@@ -948,6 +948,111 @@
|
||||
"security": [{"botToken": []}, {"sessionToken": []}]
|
||||
}
|
||||
},
|
||||
"/auth/origin-handoff": {
|
||||
"post": {
|
||||
"operationId": "create_origin_handoff",
|
||||
"summary": "Create origin handoff",
|
||||
"tags": ["Auth"],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success",
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/OriginHandoffCreateResponse"}}}
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad Request - The request was malformed or contained invalid data",
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
|
||||
},
|
||||
"401": {
|
||||
"description": "Unauthorized - Authentication is required or the token is invalid",
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
|
||||
},
|
||||
"403": {
|
||||
"description": "Forbidden - You do not have permission to perform this action",
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
|
||||
},
|
||||
"429": {
|
||||
"description": "Too Many Requests - You are being rate limited",
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/ThrottledError"}}},
|
||||
"headers": {
|
||||
"Retry-After": {
|
||||
"description": "Number of seconds to wait before retrying (only on 429)",
|
||||
"schema": {"type": "integer"}
|
||||
},
|
||||
"X-RateLimit-Limit": {
|
||||
"description": "The number of requests that can be made in the current window",
|
||||
"schema": {"type": "integer"}
|
||||
},
|
||||
"X-RateLimit-Remaining": {
|
||||
"description": "The number of remaining requests that can be made",
|
||||
"schema": {"type": "integer"}
|
||||
},
|
||||
"X-RateLimit-Reset": {
|
||||
"description": "Unix timestamp when the rate limit resets",
|
||||
"schema": {"type": "integer"}
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal Server Error - An unexpected error occurred",
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
|
||||
}
|
||||
},
|
||||
"description": "Store encrypted client state for up to two minutes so another first-party web origin can redeem it once. The receiving origin must present the nonce whose SHA-256 digest is sent here.",
|
||||
"security": [{"sessionToken": []}],
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/OriginHandoffCreateRequest"}}}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/auth/origin-handoff/redeem": {
|
||||
"post": {
|
||||
"operationId": "redeem_origin_handoff",
|
||||
"summary": "Redeem origin handoff",
|
||||
"tags": ["Auth"],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success",
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/OriginHandoffRedeemResponse"}}}
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad Request - The request was malformed or contained invalid data",
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
|
||||
},
|
||||
"429": {
|
||||
"description": "Too Many Requests - You are being rate limited",
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/ThrottledError"}}},
|
||||
"headers": {
|
||||
"Retry-After": {
|
||||
"description": "Number of seconds to wait before retrying (only on 429)",
|
||||
"schema": {"type": "integer"}
|
||||
},
|
||||
"X-RateLimit-Limit": {
|
||||
"description": "The number of requests that can be made in the current window",
|
||||
"schema": {"type": "integer"}
|
||||
},
|
||||
"X-RateLimit-Remaining": {
|
||||
"description": "The number of remaining requests that can be made",
|
||||
"schema": {"type": "integer"}
|
||||
},
|
||||
"X-RateLimit-Reset": {
|
||||
"description": "Unix timestamp when the rate limit resets",
|
||||
"schema": {"type": "integer"}
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal Server Error - An unexpected error occurred",
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
|
||||
}
|
||||
},
|
||||
"description": "Return the encrypted client state stored by create origin handoff and delete it in the same step. A wrong nonce also consumes the handoff. On the official instance the request must come from a first-party web origin.",
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/OriginHandoffRedeemRequest"}}}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/auth/register": {
|
||||
"post": {
|
||||
"operationId": "register_account",
|
||||
@@ -22495,7 +22600,8 @@
|
||||
"required": ["p256dh", "auth"],
|
||||
"description": "Encryption keys for the push subscription"
|
||||
},
|
||||
"user_agent": {"description": "The user agent string identifying the client", "type": "string"}
|
||||
"user_agent": {"description": "The user agent string identifying the client", "type": "string"},
|
||||
"installed_app": {"description": "Whether the client runs in an installed web app window", "type": "boolean"}
|
||||
},
|
||||
"required": ["endpoint", "keys"]
|
||||
},
|
||||
@@ -22524,7 +22630,8 @@
|
||||
"required": ["p256dh", "auth"],
|
||||
"description": "Encryption keys for the new push subscription"
|
||||
},
|
||||
"user_agent": {"description": "The user agent string identifying the client", "type": "string"}
|
||||
"user_agent": {"description": "The user agent string identifying the client", "type": "string"},
|
||||
"installed_app": {"description": "Whether the client runs in an installed web app window", "type": "boolean"}
|
||||
},
|
||||
"required": ["old_endpoint", "endpoint", "keys"]
|
||||
},
|
||||
@@ -27190,7 +27297,8 @@
|
||||
"assignments": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"voice_noise_suppression": {"$ref": "#/components/schemas/VoiceNoiseSuppressionAssignmentResponse"}
|
||||
"voice_noise_suppression": {"$ref": "#/components/schemas/VoiceNoiseSuppressionAssignmentResponse"},
|
||||
"domain_migration": {"$ref": "#/components/schemas/DomainMigrationAssignmentResponse"}
|
||||
},
|
||||
"additionalProperties": false
|
||||
}
|
||||
@@ -28403,6 +28511,56 @@
|
||||
{"$ref": "#/components/schemas/AuthRegistrationPendingApprovalResponse"}
|
||||
]
|
||||
},
|
||||
"OriginHandoffRedeemRequest": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"handoff_id": {
|
||||
"type": "string",
|
||||
"pattern": "^[A-Za-z0-9_-]{43}$",
|
||||
"description": "Identifier returned when the handoff was created"
|
||||
},
|
||||
"nonce": {
|
||||
"type": "string",
|
||||
"minLength": 16,
|
||||
"maxLength": 256,
|
||||
"pattern": "^[A-Za-z0-9_-]+$",
|
||||
"description": "Nonce whose SHA-256 digest was sent when the handoff was created"
|
||||
}
|
||||
},
|
||||
"required": ["handoff_id", "nonce"]
|
||||
},
|
||||
"OriginHandoffRedeemResponse": {
|
||||
"type": "object",
|
||||
"properties": {"payload": {"type": "string", "description": "Encrypted client state encoded as base64url"}},
|
||||
"required": ["payload"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"OriginHandoffCreateRequest": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"nonce_hash": {
|
||||
"type": "string",
|
||||
"pattern": "^[0-9a-f]{64}$",
|
||||
"description": "Lowercase hex SHA-256 digest of the nonce the receiving origin holds"
|
||||
},
|
||||
"payload": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 8388608,
|
||||
"pattern": "^[A-Za-z0-9_-]+$",
|
||||
"description": "Encrypted client state encoded as base64url"
|
||||
}
|
||||
},
|
||||
"required": ["nonce_hash", "payload"]
|
||||
},
|
||||
"OriginHandoffCreateResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"handoff_id": {"type": "string", "description": "Single-use identifier the receiving origin redeems"}
|
||||
},
|
||||
"required": ["handoff_id"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"MfaTicketRequest": {
|
||||
"type": "object",
|
||||
"properties": {"ticket": {"description": "The MFA ticket from the login response", "type": "string"}},
|
||||
@@ -28766,6 +28924,10 @@
|
||||
},
|
||||
"description": "Public application configuration for client-side features",
|
||||
"$ref": "#/components/schemas/InstanceAppPublicSchema"
|
||||
},
|
||||
"domain_migration": {
|
||||
"description": "Web domain migration switch and anonymous rollout, only acted on by official instance clients",
|
||||
"$ref": "#/components/schemas/DomainMigrationDiscoveryResponse"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
@@ -28784,6 +28946,31 @@
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"DomainMigrationDiscoveryResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"enabled": {"type": "boolean", "description": "Whether the domain migration is switched on"},
|
||||
"anonymous_rollout_basis_points": {
|
||||
"type": "integer",
|
||||
"minimum": 0,
|
||||
"maximum": 10000,
|
||||
"description": "Share of logged-out devices, in basis points, that move to the new domain"
|
||||
},
|
||||
"rollout_salt": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 64,
|
||||
"pattern": "^[\\x20-\\x7e]+$",
|
||||
"description": "Salt used to bucket devices and users"
|
||||
},
|
||||
"standalone_forwarding": {
|
||||
"type": "boolean",
|
||||
"description": "Whether installed desktop web apps forward to the new domain after moving their session"
|
||||
}
|
||||
},
|
||||
"required": ["enabled", "anonymous_rollout_basis_points", "rollout_salt", "standalone_forwarding"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"InstanceAppPublicSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -30694,6 +30881,12 @@
|
||||
"additionalProperties": false
|
||||
},
|
||||
"DonationCurrency": {"type": "string", "enum": ["usd", "eur", "brl", "inr", "pln", "try", "sek", "dkk", "nok"]},
|
||||
"DomainMigrationAssignmentResponse": {
|
||||
"type": "object",
|
||||
"properties": {"enabled": {"type": "boolean"}},
|
||||
"required": ["enabled"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"VoiceNoiseSuppressionAssignmentResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
||||
@@ -132,6 +132,14 @@ export const AuthRateLimitConfigs = {
|
||||
bucket: 'auth:handoff:cancel',
|
||||
config: {limit: 10, windowMs: ms('1 minute')},
|
||||
} as RouteRateLimitConfig,
|
||||
AUTH_ORIGIN_HANDOFF_CREATE: {
|
||||
bucket: 'auth:origin_handoff:create',
|
||||
config: {limit: 3, windowMs: ms('10 minutes')},
|
||||
} as RouteRateLimitConfig,
|
||||
AUTH_ORIGIN_HANDOFF_REDEEM: {
|
||||
bucket: 'auth:origin_handoff:redeem',
|
||||
config: {limit: 10, windowMs: ms('1 minute')},
|
||||
} as RouteRateLimitConfig,
|
||||
SUDO_WEBAUTHN_OPTIONS: {
|
||||
bucket: 'sudo:webauthn:options',
|
||||
config: {limit: 10, windowMs: ms('1 minute')},
|
||||
|
||||
@@ -39,7 +39,6 @@ import type Stripe from 'stripe';
|
||||
|
||||
const PRODUCT_NAME = 'Fluxer';
|
||||
const PREMIUM_TIER_NAME = 'Plutonium';
|
||||
const TERMS_URL = 'https://fluxer.app/terms';
|
||||
export const EU_WITHDRAWAL_WAIVER_TEXT_VERSION = '2026-04-23';
|
||||
|
||||
type CheckoutSessionCreateParams = Stripe.Checkout.SessionCreateParams;
|
||||
@@ -226,7 +225,7 @@ export class StripeCheckoutService {
|
||||
message: getContentMessage('billing.eu_withdrawal_waiver_checkout', user.locale, {
|
||||
product_name: PRODUCT_NAME,
|
||||
premium_tier_name: PREMIUM_TIER_NAME,
|
||||
terms_url: TERMS_URL,
|
||||
terms_url: `${Config.endpoints.marketing}/terms`,
|
||||
}),
|
||||
},
|
||||
},
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import * as AuthSession from '@app/api/auth/AuthSession';
|
||||
import {requireSudoMode} from '@app/api/auth/services/SudoVerificationService';
|
||||
import {createGuildID, createUserID} from '@app/api/BrandedTypes';
|
||||
import {Config} from '@app/api/Config';
|
||||
import {DefaultUserOnly, LoginRequired, LoginRequiredAllowSuspicious} from '@app/api/middleware/AuthMiddleware';
|
||||
import {requireOAuth2ScopeForBearer} from '@app/api/middleware/OAuth2ScopeMiddleware';
|
||||
import {RateLimitMiddleware} from '@app/api/middleware/RateLimitMiddleware';
|
||||
@@ -10,6 +11,7 @@ import {OpenAPI} from '@app/api/middleware/ResponseTypeMiddleware';
|
||||
import {SudoModeMiddleware} from '@app/api/middleware/SudoModeMiddleware';
|
||||
import {RateLimitConfigs} from '@app/api/RateLimitConfig';
|
||||
import type {HonoApp} from '@app/api/types/HonoEnv';
|
||||
import {classifyWebPushOrigin} from '@app/api/user/services/WebPushOriginReplacement';
|
||||
import {getCachedUserPartialResponse} from '@app/api/user/UserCacheHelpers';
|
||||
import {
|
||||
mapUserGuildSettingsToResponse,
|
||||
@@ -854,7 +856,7 @@ export function UserAccountController(app: HonoApp) {
|
||||
'Registers a new push notification subscription for the current user. Takes push endpoint and encryption keys from a Web Push API subscription. Returns subscription ID for future reference.',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const {endpoint, keys, user_agent} = ctx.req.valid('json');
|
||||
const {endpoint, keys, user_agent, installed_app} = ctx.req.valid('json');
|
||||
const authSession = ctx.get('authSession');
|
||||
const subscription = await ctx.get('userService').contentService.registerPushSubscription({
|
||||
userId: ctx.get('user').id,
|
||||
@@ -862,6 +864,8 @@ export function UserAccountController(app: HonoApp) {
|
||||
endpoint,
|
||||
keys,
|
||||
userAgent: user_agent,
|
||||
originKind: classifyWebPushOrigin(ctx.req.header('origin'), Config.instance.selfHosted),
|
||||
installedApp: installed_app,
|
||||
});
|
||||
return ctx.json({subscription_id: subscription.subscriptionId});
|
||||
},
|
||||
@@ -883,7 +887,7 @@ export function UserAccountController(app: HonoApp) {
|
||||
'Replaces an existing push subscription whose endpoint has been rotated by the browser (pushsubscriptionchange). Deletes the row keyed by the old endpoint and inserts a new one for the new endpoint.',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const {old_endpoint, endpoint, keys, user_agent} = ctx.req.valid('json');
|
||||
const {old_endpoint, endpoint, keys, user_agent, installed_app} = ctx.req.valid('json');
|
||||
const authSession = ctx.get('authSession');
|
||||
const subscription = await ctx.get('userService').contentService.rotatePushSubscription({
|
||||
userId: ctx.get('user').id,
|
||||
@@ -892,6 +896,8 @@ export function UserAccountController(app: HonoApp) {
|
||||
endpoint,
|
||||
keys,
|
||||
userAgent: user_agent,
|
||||
originKind: classifyWebPushOrigin(ctx.req.header('origin'), Config.instance.selfHosted),
|
||||
installedApp: installed_app,
|
||||
});
|
||||
return ctx.json({subscription_id: subscription.subscriptionId});
|
||||
},
|
||||
|
||||
@@ -20,12 +20,23 @@ import {resolveLimitSafe} from '@app/api/limits/LimitConfigUtils';
|
||||
import {createLimitMatchContext} from '@app/api/limits/LimitMatchContextBuilder';
|
||||
import type {RequestCache} from '@app/api/middleware/RequestCacheMiddleware';
|
||||
import type {Message} from '@app/api/models/Message';
|
||||
import type {PushSubscription} from '@app/api/models/PushSubscription';
|
||||
import {PushSubscription} from '@app/api/models/PushSubscription';
|
||||
import type {IUserAccountRepository} from '@app/api/user/repositories/IUserAccountRepository';
|
||||
import type {IUserContentRepository} from '@app/api/user/repositories/IUserContentRepository';
|
||||
import {BaseUserUpdatePropagator} from '@app/api/user/services/BaseUserUpdatePropagator';
|
||||
import {verifyHarvestDownloadToken} from '@app/api/user/services/HarvestDownloadToken';
|
||||
import {buildHarvestDownloadUrl} from '@app/api/user/services/HarvestDownloadUrl';
|
||||
import {
|
||||
findInstalledLegacyPushSubscriptionIds,
|
||||
findTargetPushSubscriptionIds,
|
||||
getPushOriginReplacement,
|
||||
getPushSessionPredecessor,
|
||||
markInstalledLegacyPushSubscription,
|
||||
markPushOriginReplaced,
|
||||
markTargetPushSubscription,
|
||||
sameUserAgentFamily,
|
||||
type WebPushOriginKind,
|
||||
} from '@app/api/user/services/WebPushOriginReplacement';
|
||||
import {UserHarvest} from '@app/api/user/UserHarvestModel';
|
||||
import {UserHarvestRepository} from '@app/api/user/UserHarvestRepository';
|
||||
import {serializeSelfMessageFilter} from '@app/api/worker/utils/SelfMessageFilterPayload';
|
||||
@@ -56,6 +67,7 @@ import type {
|
||||
import type {SavedMessageStatus} from '@fluxer/schema/src/domains/user/UserResponseSchemas';
|
||||
import {snowflakeToDate} from '@fluxer/snowflake/src/Snowflake';
|
||||
import {isPubliclyRoutableUrlShape} from '@pkgs/http_client/src/PublicInternetRequestUrlPolicy';
|
||||
import type {IKVProvider} from '@pkgs/kv_client/src/IKVProvider';
|
||||
import type {IWorkerService} from '@pkgs/worker/src/contracts/IWorkerService';
|
||||
import {ms} from 'itty-time';
|
||||
|
||||
@@ -159,6 +171,7 @@ export class UserContentService {
|
||||
private readonly gatewayService: IGatewayService;
|
||||
private readonly workerService: IWorkerService<WorkerTaskName>;
|
||||
private readonly snowflakeService: ISnowflakeService;
|
||||
private readonly kv: IKVProvider;
|
||||
|
||||
constructor(
|
||||
apiContext: ApiContext,
|
||||
@@ -168,11 +181,12 @@ export class UserContentService {
|
||||
private bulkMessageDeletionQueue: KVBulkMessageDeletionQueueService,
|
||||
private limitConfigService: LimitConfigService,
|
||||
) {
|
||||
const {users, gateway, worker, snowflake} = apiContext.services;
|
||||
const {users, gateway, worker, snowflake, kv} = apiContext.services;
|
||||
this.userRepository = users;
|
||||
this.gatewayService = gateway;
|
||||
this.workerService = worker;
|
||||
this.snowflakeService = snowflake;
|
||||
this.kv = kv;
|
||||
this.updatePropagator = new BaseUserUpdatePropagator({
|
||||
userCacheService,
|
||||
gatewayService: this.gatewayService,
|
||||
@@ -359,8 +373,10 @@ export class UserContentService {
|
||||
auth: string;
|
||||
};
|
||||
userAgent?: string;
|
||||
originKind?: WebPushOriginKind | null;
|
||||
installedApp?: boolean;
|
||||
}): Promise<PushSubscription> {
|
||||
const {userId, authSessionIdHash, endpoint, keys, userAgent} = params;
|
||||
const {userId, authSessionIdHash, endpoint, keys, userAgent, originKind, installedApp} = params;
|
||||
assertPublicPushEndpoint(endpoint, 'endpoint');
|
||||
const subscriptionId = createWebPushSubscriptionId(endpoint);
|
||||
const data: PushSubscriptionRow = {
|
||||
@@ -375,11 +391,76 @@ export class UserContentService {
|
||||
app_id: null,
|
||||
provider_environment: null,
|
||||
};
|
||||
const subscription = await this.userRepository.createPushSubscription(data);
|
||||
const subscription = await this.storeWebPushSubscription(data, originKind ?? null, installedApp === true);
|
||||
await this.gatewayService.invalidatePushSubscriptions({userId});
|
||||
return subscription;
|
||||
}
|
||||
|
||||
private async storeWebPushSubscription(
|
||||
data: PushSubscriptionRow,
|
||||
originKind: WebPushOriginKind | null,
|
||||
installedApp: boolean,
|
||||
): Promise<PushSubscription> {
|
||||
if (originKind === 'legacy' && (await this.isLegacyWebPushReplaced(data, installedApp))) {
|
||||
return new PushSubscription(data);
|
||||
}
|
||||
const subscription = await this.userRepository.createPushSubscription(data);
|
||||
if (originKind === 'legacy' && installedApp) {
|
||||
await this.bestEffortPushOriginWrite(() => markInstalledLegacyPushSubscription(this.kv, data.subscription_id));
|
||||
}
|
||||
if (originKind === 'target') {
|
||||
await this.bestEffortPushOriginWrite(() => this.replaceLegacyWebPushSubscriptions(data, installedApp));
|
||||
}
|
||||
return subscription;
|
||||
}
|
||||
|
||||
private async isLegacyWebPushReplaced(data: PushSubscriptionRow, installedApp: boolean): Promise<boolean> {
|
||||
const sessionIdHash = data.auth_session_id_hash;
|
||||
if (!sessionIdHash) return false;
|
||||
try {
|
||||
const replacement = await getPushOriginReplacement(this.kv, sessionIdHash);
|
||||
return replacement === 'installed' || (replacement === 'browser' && !installedApp);
|
||||
} catch (error) {
|
||||
Logger.warn({error}, 'Failed to read the web push origin replacement');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private async bestEffortPushOriginWrite(write: () => Promise<void>): Promise<void> {
|
||||
try {
|
||||
await write();
|
||||
} catch (error) {
|
||||
Logger.warn({error}, 'Failed to apply the web push origin replacement');
|
||||
}
|
||||
}
|
||||
|
||||
private async replaceLegacyWebPushSubscriptions(data: PushSubscriptionRow, installedApp: boolean): Promise<void> {
|
||||
await markTargetPushSubscription(this.kv, data.subscription_id);
|
||||
const sessionIdHash = data.auth_session_id_hash;
|
||||
if (!sessionIdHash) return;
|
||||
await markPushOriginReplaced(this.kv, sessionIdHash, installedApp ? 'installed' : 'browser');
|
||||
const predecessor = await getPushSessionPredecessor(this.kv, sessionIdHash);
|
||||
const candidates = (await this.userRepository.listPushSubscriptions(data.user_id)).filter(
|
||||
(subscription) =>
|
||||
subscription.platform === WEB_PUSH_PLATFORM &&
|
||||
subscription.endpoint !== data.endpoint &&
|
||||
(subscription.authSessionIdHash === sessionIdHash ||
|
||||
(predecessor !== null &&
|
||||
subscription.authSessionIdHash === predecessor &&
|
||||
sameUserAgentFamily(subscription.userAgent, data.user_agent))),
|
||||
);
|
||||
const candidateIds = candidates.map((subscription) => subscription.subscriptionId);
|
||||
const [targetSubscriptionIds, installedLegacySubscriptionIds] = await Promise.all([
|
||||
findTargetPushSubscriptionIds(this.kv, candidateIds),
|
||||
installedApp ? Promise.resolve(new Set<string>()) : findInstalledLegacyPushSubscriptionIds(this.kv, candidateIds),
|
||||
]);
|
||||
for (const subscription of candidates) {
|
||||
if (targetSubscriptionIds.has(subscription.subscriptionId)) continue;
|
||||
if (installedLegacySubscriptionIds.has(subscription.subscriptionId)) continue;
|
||||
await this.userRepository.deletePushSubscription(data.user_id, subscription.subscriptionId);
|
||||
}
|
||||
}
|
||||
|
||||
async listPushSubscriptions(userId: UserID): Promise<Array<PushSubscription>> {
|
||||
const subscriptions = await this.userRepository.listPushSubscriptions(userId);
|
||||
return subscriptions.filter((subscription) => subscription.platform === WEB_PUSH_PLATFORM);
|
||||
@@ -400,8 +481,10 @@ export class UserContentService {
|
||||
auth: string;
|
||||
};
|
||||
userAgent?: string;
|
||||
originKind?: WebPushOriginKind | null;
|
||||
installedApp?: boolean;
|
||||
}): Promise<PushSubscription> {
|
||||
const {userId, authSessionIdHash, oldEndpoint, endpoint, keys, userAgent} = params;
|
||||
const {userId, authSessionIdHash, oldEndpoint, endpoint, keys, userAgent, originKind, installedApp} = params;
|
||||
assertPublicPushEndpoint(endpoint, 'endpoint');
|
||||
const oldSubscriptionId = createWebPushSubscriptionId(oldEndpoint);
|
||||
const newSubscriptionId = createWebPushSubscriptionId(endpoint);
|
||||
@@ -420,7 +503,7 @@ export class UserContentService {
|
||||
app_id: null,
|
||||
provider_environment: null,
|
||||
};
|
||||
const subscription = await this.userRepository.createPushSubscription(data);
|
||||
const subscription = await this.storeWebPushSubscription(data, originKind ?? null, installedApp === true);
|
||||
await this.gatewayService.invalidatePushSubscriptions({userId});
|
||||
return subscription;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import type {IKVProvider} from '@pkgs/kv_client/src/IKVProvider';
|
||||
import {seconds} from 'itty-time';
|
||||
import {uint8ArrayToBase64} from 'uint8array-extras';
|
||||
|
||||
export type WebPushOriginKind = 'legacy' | 'target';
|
||||
|
||||
export type WebPushOriginReplacement = 'installed' | 'browser';
|
||||
|
||||
const WEB_PUSH_ORIGIN_KINDS: ReadonlyMap<string, WebPushOriginKind> = new Map([
|
||||
['https://web.fluxer.app', 'legacy'],
|
||||
['https://web.canary.fluxer.app', 'legacy'],
|
||||
['https://fluxer.com', 'target'],
|
||||
['https://canary.fluxer.com', 'target'],
|
||||
]);
|
||||
|
||||
const PUSH_ORIGIN_REPLACED_PREFIX = 'push_origin_replaced:';
|
||||
const PUSH_SESSION_PREDECESSOR_PREFIX = 'push_session_predecessor:';
|
||||
const PUSH_TARGET_SUBSCRIPTION_PREFIX = 'push_target_subscription:';
|
||||
const PUSH_INSTALLED_LEGACY_SUBSCRIPTION_PREFIX = 'push_installed_legacy_subscription:';
|
||||
const USER_AGENT_VERSION_PATTERN = /\d+(?:[._]\d+)*/g;
|
||||
|
||||
export const WEB_PUSH_ORIGIN_RECORD_TTL_SECONDS = seconds('400 days');
|
||||
|
||||
export function classifyWebPushOrigin(
|
||||
origin: string | null | undefined,
|
||||
selfHosted: boolean,
|
||||
): WebPushOriginKind | null {
|
||||
if (selfHosted || !origin) return null;
|
||||
return WEB_PUSH_ORIGIN_KINDS.get(origin) ?? null;
|
||||
}
|
||||
|
||||
export function encodePushSessionIdHash(sessionIdHash: Uint8Array): string {
|
||||
return uint8ArrayToBase64(sessionIdHash, {urlSafe: true});
|
||||
}
|
||||
|
||||
export function sameUserAgentFamily(a: string | null | undefined, b: string | null | undefined): boolean {
|
||||
if (!a || !b) return false;
|
||||
return a.replace(USER_AGENT_VERSION_PATTERN, '') === b.replace(USER_AGENT_VERSION_PATTERN, '');
|
||||
}
|
||||
|
||||
export async function recordPushSessionPredecessor(
|
||||
kv: IKVProvider,
|
||||
sessionIdHash: string,
|
||||
predecessorSessionIdHash: string,
|
||||
): Promise<void> {
|
||||
if (sessionIdHash === predecessorSessionIdHash) return;
|
||||
await kv.setex(
|
||||
`${PUSH_SESSION_PREDECESSOR_PREFIX}${sessionIdHash}`,
|
||||
WEB_PUSH_ORIGIN_RECORD_TTL_SECONDS,
|
||||
predecessorSessionIdHash,
|
||||
);
|
||||
}
|
||||
|
||||
export async function getPushSessionPredecessor(kv: IKVProvider, sessionIdHash: string): Promise<string | null> {
|
||||
return kv.get(`${PUSH_SESSION_PREDECESSOR_PREFIX}${sessionIdHash}`);
|
||||
}
|
||||
|
||||
export async function markPushOriginReplaced(
|
||||
kv: IKVProvider,
|
||||
sessionIdHash: string,
|
||||
replacement: WebPushOriginReplacement,
|
||||
): Promise<void> {
|
||||
const key = `${PUSH_ORIGIN_REPLACED_PREFIX}${sessionIdHash}`;
|
||||
if (replacement === 'browser' && (await kv.get(key)) === 'installed') return;
|
||||
await kv.setex(key, WEB_PUSH_ORIGIN_RECORD_TTL_SECONDS, replacement);
|
||||
}
|
||||
|
||||
export async function getPushOriginReplacement(
|
||||
kv: IKVProvider,
|
||||
sessionIdHash: string,
|
||||
): Promise<WebPushOriginReplacement | null> {
|
||||
const value = await kv.get(`${PUSH_ORIGIN_REPLACED_PREFIX}${sessionIdHash}`);
|
||||
if (value === null) return null;
|
||||
return value === 'browser' ? 'browser' : 'installed';
|
||||
}
|
||||
|
||||
async function markSubscription(kv: IKVProvider, prefix: string, subscriptionId: string): Promise<void> {
|
||||
await kv.setex(`${prefix}${subscriptionId}`, WEB_PUSH_ORIGIN_RECORD_TTL_SECONDS, '1');
|
||||
}
|
||||
|
||||
async function findMarkedSubscriptionIds(
|
||||
kv: IKVProvider,
|
||||
prefix: string,
|
||||
subscriptionIds: Array<string>,
|
||||
): Promise<Set<string>> {
|
||||
if (subscriptionIds.length === 0) return new Set();
|
||||
const markers = await kv.mget(...subscriptionIds.map((id) => `${prefix}${id}`));
|
||||
return new Set(subscriptionIds.filter((_, index) => markers[index] !== null));
|
||||
}
|
||||
|
||||
export async function markTargetPushSubscription(kv: IKVProvider, subscriptionId: string): Promise<void> {
|
||||
await markSubscription(kv, PUSH_TARGET_SUBSCRIPTION_PREFIX, subscriptionId);
|
||||
}
|
||||
|
||||
export async function findTargetPushSubscriptionIds(
|
||||
kv: IKVProvider,
|
||||
subscriptionIds: Array<string>,
|
||||
): Promise<Set<string>> {
|
||||
return findMarkedSubscriptionIds(kv, PUSH_TARGET_SUBSCRIPTION_PREFIX, subscriptionIds);
|
||||
}
|
||||
|
||||
export async function markInstalledLegacyPushSubscription(kv: IKVProvider, subscriptionId: string): Promise<void> {
|
||||
await markSubscription(kv, PUSH_INSTALLED_LEGACY_SUBSCRIPTION_PREFIX, subscriptionId);
|
||||
}
|
||||
|
||||
export async function findInstalledLegacyPushSubscriptionIds(
|
||||
kv: IKVProvider,
|
||||
subscriptionIds: Array<string>,
|
||||
): Promise<Set<string>> {
|
||||
return findMarkedSubscriptionIds(kv, PUSH_INSTALLED_LEGACY_SUBSCRIPTION_PREFIX, subscriptionIds);
|
||||
}
|
||||
@@ -0,0 +1,383 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {createAuthHarness, createTestAccount, loginAccount} from '@app/api/auth/tests/AuthTestUtils';
|
||||
import {getConfig} from '@app/api/Config';
|
||||
import type {ApiTestHarness} from '@app/api/test/ApiTestHarness';
|
||||
import {createBuilder, createBuilderWithoutAuth} from '@app/api/test/TestRequestBuilder';
|
||||
import {classifyWebPushOrigin, sameUserAgentFamily} from '@app/api/user/services/WebPushOriginReplacement';
|
||||
import {listPushSubscriptions} from '@app/api/user/tests/UserTestUtils';
|
||||
import {afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi} from 'vitest';
|
||||
|
||||
const LEGACY_ORIGIN = 'https://web.fluxer.app';
|
||||
const TARGET_ORIGIN = 'https://fluxer.com';
|
||||
const IPHONE_UA =
|
||||
'Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Mobile/15E148 Safari/604.1';
|
||||
const IPHONE_UPDATED_UA =
|
||||
'Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1 Mobile/15E148 Safari/604.1';
|
||||
const DESKTOP_CHROME_UA =
|
||||
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36';
|
||||
const ANDROID_CHROME_UA =
|
||||
'Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Mobile Safari/537.36';
|
||||
|
||||
interface SubscribeOptions {
|
||||
userAgent?: string;
|
||||
installedApp?: boolean;
|
||||
}
|
||||
|
||||
interface PushSubscribeResponse {
|
||||
subscription_id: string;
|
||||
}
|
||||
|
||||
interface HandoffInitiateResponse {
|
||||
code: string;
|
||||
poll_secret: string;
|
||||
}
|
||||
|
||||
interface HandoffStatusResponse {
|
||||
status: 'pending' | 'completed' | 'expired';
|
||||
token?: string;
|
||||
}
|
||||
|
||||
describe('classifyWebPushOrigin', () => {
|
||||
it.each([
|
||||
{origin: 'https://web.fluxer.app', kind: 'legacy'},
|
||||
{origin: 'https://web.canary.fluxer.app', kind: 'legacy'},
|
||||
{origin: 'https://fluxer.com', kind: 'target'},
|
||||
{origin: 'https://canary.fluxer.com', kind: 'target'},
|
||||
{origin: 'https://fluxer.app', kind: null},
|
||||
{origin: 'https://example.com', kind: null},
|
||||
{origin: undefined, kind: null},
|
||||
{origin: null, kind: null},
|
||||
])('classifies $origin as $kind on the official instance', ({origin, kind}) => {
|
||||
expect(classifyWebPushOrigin(origin, false)).toBe(kind);
|
||||
});
|
||||
|
||||
it('never classifies an origin on a self-hosted instance', () => {
|
||||
expect(classifyWebPushOrigin(LEGACY_ORIGIN, true)).toBeNull();
|
||||
expect(classifyWebPushOrigin(TARGET_ORIGIN, true)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('sameUserAgentFamily', () => {
|
||||
it('matches the same browser across version updates', () => {
|
||||
expect(sameUserAgentFamily(IPHONE_UA, IPHONE_UPDATED_UA)).toBe(true);
|
||||
});
|
||||
|
||||
it('tells devices and browsers apart', () => {
|
||||
expect(sameUserAgentFamily(DESKTOP_CHROME_UA, ANDROID_CHROME_UA)).toBe(false);
|
||||
expect(sameUserAgentFamily(IPHONE_UA, DESKTOP_CHROME_UA)).toBe(false);
|
||||
});
|
||||
|
||||
it('never matches a missing user agent', () => {
|
||||
expect(sameUserAgentFamily(null, null)).toBe(false);
|
||||
expect(sameUserAgentFamily(IPHONE_UA, undefined)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('web push origin replacement', () => {
|
||||
let harness: ApiTestHarness;
|
||||
beforeAll(async () => {
|
||||
harness = await createAuthHarness();
|
||||
});
|
||||
beforeEach(async () => {
|
||||
await harness.reset();
|
||||
});
|
||||
afterAll(async () => {
|
||||
await harness?.shutdown();
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
async function subscribeFrom(
|
||||
token: string,
|
||||
origin: string | null,
|
||||
endpoint: string,
|
||||
options: SubscribeOptions = {},
|
||||
): Promise<string> {
|
||||
const builder = createBuilder<PushSubscribeResponse>(harness, token).post('/users/@me/push/subscribe');
|
||||
if (origin) builder.header('Origin', origin);
|
||||
const response = await builder
|
||||
.body({
|
||||
endpoint,
|
||||
keys: {p256dh: 'test-p256dh-key', auth: 'test-auth-key'},
|
||||
user_agent: options.userAgent,
|
||||
installed_app: options.installedApp,
|
||||
})
|
||||
.execute();
|
||||
return response.subscription_id;
|
||||
}
|
||||
|
||||
async function rotateFrom(
|
||||
token: string,
|
||||
origin: string,
|
||||
oldEndpoint: string,
|
||||
endpoint: string,
|
||||
installedApp?: boolean,
|
||||
): Promise<string> {
|
||||
const response = await createBuilder<PushSubscribeResponse>(harness, token)
|
||||
.post('/users/@me/push/rotate')
|
||||
.header('Origin', origin)
|
||||
.body({
|
||||
old_endpoint: oldEndpoint,
|
||||
endpoint,
|
||||
keys: {p256dh: 'test-p256dh-key', auth: 'test-auth-key'},
|
||||
installed_app: installedApp,
|
||||
})
|
||||
.execute();
|
||||
return response.subscription_id;
|
||||
}
|
||||
|
||||
async function listSubscriptionIds(token: string): Promise<Array<string>> {
|
||||
const result = await listPushSubscriptions(harness, token);
|
||||
return result.subscriptions.map((subscription) => subscription.subscription_id).sort();
|
||||
}
|
||||
|
||||
async function pairNewSession(
|
||||
approverToken: string,
|
||||
approverUserId: string,
|
||||
approverOrigin: string,
|
||||
initiatorOrigin: string | null = TARGET_ORIGIN,
|
||||
) {
|
||||
const initiate = createBuilderWithoutAuth<HandoffInitiateResponse>(harness).post('/auth/handoff/initiate');
|
||||
if (initiatorOrigin) initiate.header('Origin', initiatorOrigin);
|
||||
const initiated = await initiate.body(null).execute();
|
||||
await createBuilderWithoutAuth(harness).get(`/auth/handoff/${initiated.code}/info`).execute();
|
||||
await createBuilderWithoutAuth(harness)
|
||||
.post('/auth/handoff/complete')
|
||||
.header('Origin', approverOrigin)
|
||||
.body({code: initiated.code, token: approverToken, user_id: approverUserId})
|
||||
.expect(204)
|
||||
.execute();
|
||||
const completed = await createBuilderWithoutAuth<HandoffStatusResponse>(harness)
|
||||
.post(`/auth/handoff/${initiated.code}/status`)
|
||||
.body({poll_secret: initiated.poll_secret})
|
||||
.execute();
|
||||
expect(completed.status).toBe('completed');
|
||||
return completed.token!;
|
||||
}
|
||||
|
||||
async function withSelfHosted(callback: () => Promise<void>): Promise<void> {
|
||||
const config = getConfig();
|
||||
const original = config.instance.selfHosted;
|
||||
try {
|
||||
config.instance.selfHosted = true;
|
||||
await callback();
|
||||
} finally {
|
||||
config.instance.selfHosted = original;
|
||||
}
|
||||
}
|
||||
|
||||
it('replaces the legacy subscription of the same session when the new origin subscribes', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
await subscribeFrom(account.token, LEGACY_ORIGIN, 'https://push.example.com/legacy');
|
||||
const target = await subscribeFrom(account.token, TARGET_ORIGIN, 'https://push.example.com/target');
|
||||
expect(await listSubscriptionIds(account.token)).toEqual([target]);
|
||||
});
|
||||
|
||||
it('turns a later legacy subscribe for the replaced session into a no-op', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const target = await subscribeFrom(account.token, TARGET_ORIGIN, 'https://push.example.com/target');
|
||||
const legacy = await subscribeFrom(account.token, LEGACY_ORIGIN, 'https://push.example.com/legacy');
|
||||
expect(legacy).toMatch(/^[a-f0-9]{32}$/);
|
||||
expect(legacy).not.toBe(target);
|
||||
expect(await listSubscriptionIds(account.token)).toEqual([target]);
|
||||
});
|
||||
|
||||
it('does not store a legacy rotation for a replaced session', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
await subscribeFrom(account.token, LEGACY_ORIGIN, 'https://push.example.com/legacy-old');
|
||||
const target = await subscribeFrom(account.token, TARGET_ORIGIN, 'https://push.example.com/target');
|
||||
await rotateFrom(
|
||||
account.token,
|
||||
LEGACY_ORIGIN,
|
||||
'https://push.example.com/legacy-old',
|
||||
'https://push.example.com/legacy-new',
|
||||
);
|
||||
expect(await listSubscriptionIds(account.token)).toEqual([target]);
|
||||
});
|
||||
|
||||
it('keeps legacy subscriptions working until the new origin subscribes', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const first = await subscribeFrom(account.token, LEGACY_ORIGIN, 'https://push.example.com/legacy-a');
|
||||
const second = await subscribeFrom(account.token, LEGACY_ORIGIN, 'https://push.example.com/legacy-b');
|
||||
expect(await listSubscriptionIds(account.token)).toEqual([first, second].sort());
|
||||
});
|
||||
|
||||
it('leaves subscriptions from other sessions alone', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const other = await loginAccount(harness, account);
|
||||
const otherLegacy = await subscribeFrom(other.token, LEGACY_ORIGIN, 'https://push.example.com/other-legacy');
|
||||
const target = await subscribeFrom(account.token, TARGET_ORIGIN, 'https://push.example.com/target');
|
||||
expect(await listSubscriptionIds(account.token)).toEqual([otherLegacy, target].sort());
|
||||
});
|
||||
|
||||
it('never removes another new-origin subscription of the same session', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const first = await subscribeFrom(account.token, TARGET_ORIGIN, 'https://push.example.com/target-a');
|
||||
const second = await subscribeFrom(account.token, 'https://canary.fluxer.com', 'https://push.example.com/target-b');
|
||||
expect(await listSubscriptionIds(account.token)).toEqual([first, second].sort());
|
||||
});
|
||||
|
||||
it('treats unclassified rows as legacy without ever skipping an unclassified subscribe', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const unknown = await subscribeFrom(account.token, null, 'https://push.example.com/no-origin');
|
||||
const target = await subscribeFrom(account.token, TARGET_ORIGIN, 'https://push.example.com/target');
|
||||
expect(await listSubscriptionIds(account.token)).toEqual([target]);
|
||||
const legacyAfter = await subscribeFrom(account.token, null, 'https://push.example.com/no-origin');
|
||||
expect(legacyAfter).toBe(unknown);
|
||||
expect(await listSubscriptionIds(account.token)).toEqual([unknown, target].sort());
|
||||
});
|
||||
|
||||
it('replaces the approving legacy session on the same device once a paired session subscribes', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const approver = await loginAccount(harness, account);
|
||||
const approverLegacy = 'https://push.example.com/approver-legacy';
|
||||
await subscribeFrom(approver.token, LEGACY_ORIGIN, approverLegacy, {userAgent: IPHONE_UA, installedApp: true});
|
||||
const pairedToken = await pairNewSession(approver.token, approver.userId, LEGACY_ORIGIN);
|
||||
const paired = await subscribeFrom(pairedToken, TARGET_ORIGIN, 'https://push.example.com/paired', {
|
||||
userAgent: IPHONE_UPDATED_UA,
|
||||
installedApp: true,
|
||||
});
|
||||
expect(await listSubscriptionIds(approver.token)).toEqual([paired]);
|
||||
});
|
||||
|
||||
it('never silences the approving session for good', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const approver = await loginAccount(harness, account);
|
||||
const approverLegacy = 'https://push.example.com/approver-legacy';
|
||||
await subscribeFrom(approver.token, LEGACY_ORIGIN, approverLegacy, {userAgent: IPHONE_UA});
|
||||
const pairedToken = await pairNewSession(approver.token, approver.userId, LEGACY_ORIGIN);
|
||||
const paired = await subscribeFrom(pairedToken, TARGET_ORIGIN, 'https://push.example.com/paired', {
|
||||
userAgent: IPHONE_UA,
|
||||
});
|
||||
const restored = await subscribeFrom(approver.token, LEGACY_ORIGIN, approverLegacy, {userAgent: IPHONE_UA});
|
||||
expect(await listSubscriptionIds(approver.token)).toEqual([paired, restored].sort());
|
||||
});
|
||||
|
||||
it('leaves the approving session alone when it runs on another device', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const approver = await loginAccount(harness, account);
|
||||
const approverLegacy = await subscribeFrom(approver.token, LEGACY_ORIGIN, 'https://push.example.com/desktop', {
|
||||
userAgent: DESKTOP_CHROME_UA,
|
||||
installedApp: true,
|
||||
});
|
||||
const pairedToken = await pairNewSession(approver.token, approver.userId, LEGACY_ORIGIN);
|
||||
const paired = await subscribeFrom(pairedToken, TARGET_ORIGIN, 'https://push.example.com/phone', {
|
||||
userAgent: ANDROID_CHROME_UA,
|
||||
installedApp: true,
|
||||
});
|
||||
expect(await listSubscriptionIds(approver.token)).toEqual([approverLegacy, paired].sort());
|
||||
const desktopAgain = await subscribeFrom(approver.token, LEGACY_ORIGIN, 'https://push.example.com/desktop', {
|
||||
userAgent: DESKTOP_CHROME_UA,
|
||||
installedApp: true,
|
||||
});
|
||||
expect(desktopAgain).toBe(approverLegacy);
|
||||
expect(await listSubscriptionIds(approver.token)).toEqual([approverLegacy, paired].sort());
|
||||
});
|
||||
|
||||
it.each([
|
||||
{label: 'the approval came from the new origin', approverOrigin: TARGET_ORIGIN, initiatorOrigin: TARGET_ORIGIN},
|
||||
{label: 'the new session did not start on the new origin', approverOrigin: LEGACY_ORIGIN, initiatorOrigin: null},
|
||||
{
|
||||
label: 'the new session started on the old origin',
|
||||
approverOrigin: LEGACY_ORIGIN,
|
||||
initiatorOrigin: LEGACY_ORIGIN,
|
||||
},
|
||||
])('does not link sessions when $label', async ({approverOrigin, initiatorOrigin}) => {
|
||||
const account = await createTestAccount(harness);
|
||||
const approver = await loginAccount(harness, account);
|
||||
const approverSubscription = await subscribeFrom(
|
||||
approver.token,
|
||||
LEGACY_ORIGIN,
|
||||
'https://push.example.com/approver-legacy',
|
||||
{userAgent: IPHONE_UA},
|
||||
);
|
||||
const pairedToken = await pairNewSession(approver.token, approver.userId, approverOrigin, initiatorOrigin);
|
||||
const paired = await subscribeFrom(pairedToken, TARGET_ORIGIN, 'https://push.example.com/paired', {
|
||||
userAgent: IPHONE_UA,
|
||||
});
|
||||
expect(await listSubscriptionIds(approver.token)).toEqual([approverSubscription, paired].sort());
|
||||
});
|
||||
|
||||
it('completes the approval when the predecessor link cannot be written', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const approver = await loginAccount(harness, account);
|
||||
const setex = harness.kvProvider.setex.bind(harness.kvProvider);
|
||||
vi.spyOn(harness.kvProvider, 'setex').mockImplementation(async (key, ttl, value) => {
|
||||
if (key.startsWith('push_session_predecessor:')) throw new Error('kv down');
|
||||
return setex(key, ttl, value);
|
||||
});
|
||||
const pairedToken = await pairNewSession(approver.token, approver.userId, LEGACY_ORIGIN);
|
||||
expect(pairedToken).toBeTruthy();
|
||||
});
|
||||
|
||||
it('stores a subscribe when the replacement marker cannot be read or written', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const get = harness.kvProvider.get.bind(harness.kvProvider);
|
||||
vi.spyOn(harness.kvProvider, 'get').mockImplementation(async (key) => {
|
||||
if (key.startsWith('push_origin_replaced:')) throw new Error('kv down');
|
||||
return get(key);
|
||||
});
|
||||
const target = await subscribeFrom(account.token, TARGET_ORIGIN, 'https://push.example.com/target');
|
||||
const legacy = await subscribeFrom(account.token, LEGACY_ORIGIN, 'https://push.example.com/legacy');
|
||||
expect(await listSubscriptionIds(account.token)).toEqual([legacy, target].sort());
|
||||
});
|
||||
|
||||
it('keeps an installed legacy app subscribed when only a browser tab moved', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const installed = await subscribeFrom(account.token, LEGACY_ORIGIN, 'https://push.example.com/legacy-app', {
|
||||
userAgent: DESKTOP_CHROME_UA,
|
||||
installedApp: true,
|
||||
});
|
||||
const target = await subscribeFrom(account.token, TARGET_ORIGIN, 'https://push.example.com/target-tab', {
|
||||
userAgent: DESKTOP_CHROME_UA,
|
||||
});
|
||||
expect(await listSubscriptionIds(account.token)).toEqual([installed, target].sort());
|
||||
const rotated = await rotateFrom(
|
||||
account.token,
|
||||
LEGACY_ORIGIN,
|
||||
'https://push.example.com/legacy-app',
|
||||
'https://push.example.com/legacy-app-2',
|
||||
true,
|
||||
);
|
||||
expect(await listSubscriptionIds(account.token)).toEqual([rotated, target].sort());
|
||||
await subscribeFrom(account.token, LEGACY_ORIGIN, 'https://push.example.com/legacy-tab', {
|
||||
userAgent: DESKTOP_CHROME_UA,
|
||||
});
|
||||
expect(await listSubscriptionIds(account.token)).toEqual([rotated, target].sort());
|
||||
});
|
||||
|
||||
it('replaces an installed legacy app once the new app is installed', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
await subscribeFrom(account.token, LEGACY_ORIGIN, 'https://push.example.com/legacy-app', {
|
||||
userAgent: DESKTOP_CHROME_UA,
|
||||
installedApp: true,
|
||||
});
|
||||
await subscribeFrom(account.token, TARGET_ORIGIN, 'https://push.example.com/target-tab', {
|
||||
userAgent: DESKTOP_CHROME_UA,
|
||||
});
|
||||
const targetApp = await subscribeFrom(account.token, TARGET_ORIGIN, 'https://push.example.com/target-app', {
|
||||
userAgent: DESKTOP_CHROME_UA,
|
||||
installedApp: true,
|
||||
});
|
||||
const ids = await listSubscriptionIds(account.token);
|
||||
expect(ids).toContain(targetApp);
|
||||
expect(ids).toHaveLength(2);
|
||||
await subscribeFrom(account.token, LEGACY_ORIGIN, 'https://push.example.com/legacy-app', {
|
||||
userAgent: DESKTOP_CHROME_UA,
|
||||
installedApp: true,
|
||||
});
|
||||
expect(await listSubscriptionIds(account.token)).toEqual(ids);
|
||||
});
|
||||
|
||||
it('does nothing new on a self-hosted instance', async () => {
|
||||
await withSelfHosted(async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const legacy = await subscribeFrom(account.token, LEGACY_ORIGIN, 'https://push.example.com/legacy');
|
||||
const target = await subscribeFrom(account.token, TARGET_ORIGIN, 'https://push.example.com/target');
|
||||
const legacyAgain = await subscribeFrom(account.token, LEGACY_ORIGIN, 'https://push.example.com/legacy-2');
|
||||
expect(await listSubscriptionIds(account.token)).toEqual([legacy, target, legacyAgain].sort());
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -10,6 +10,11 @@ function getInviteEndpointBase(): string {
|
||||
return `${url.hostname}${url.pathname.replace(/\/+$/, '')}`;
|
||||
}
|
||||
|
||||
function getWebAppHostsPattern(): string {
|
||||
const hostnames = new Set(Config.endpoints.webAppOrigins.map((origin) => new URL(origin).hostname));
|
||||
return [...hostnames].map((hostname) => RegexUtils.escapeRegex(hostname)).join('|');
|
||||
}
|
||||
|
||||
function getInvitePattern(): RegExp {
|
||||
if (!_invitePattern) {
|
||||
_invitePattern = new RegExp(
|
||||
@@ -18,7 +23,7 @@ function getInvitePattern(): RegExp {
|
||||
'(?:',
|
||||
`${RegexUtils.escapeRegex(getInviteEndpointBase())}(?:\\/#)?\\/(?!invite\\/)([a-zA-Z0-9\\-]{2,32})(?![a-zA-Z0-9\\-])`,
|
||||
'|',
|
||||
`${RegexUtils.escapeRegex(new URL(Config.endpoints.webApp).hostname)}(?:\\/#)?\\/invite\\/([a-zA-Z0-9\\-]{2,32})(?![a-zA-Z0-9\\-])`,
|
||||
`(?:${getWebAppHostsPattern()})(?:\\/#)?\\/invite\\/([a-zA-Z0-9\\-]{2,32})(?![a-zA-Z0-9\\-])`,
|
||||
')',
|
||||
].join(''),
|
||||
'gi',
|
||||
|
||||
@@ -8,7 +8,7 @@ import * as InviteUtils from '@app/api/utils/InviteUtils';
|
||||
import {URL_REGEX} from '@fluxer/constants/src/Core';
|
||||
import * as idna from 'idna-uts46-hx';
|
||||
|
||||
const CLIENT_ROUTE_PATH_PREFIXES = ['/channels/', '/theme/'];
|
||||
const CLIENT_ROUTE_PATH_PREFIXES = ['/channels/', '/theme/', '/invite/', '/gift/', '/oauth2/', '/users/'];
|
||||
|
||||
interface ExcludedLinkBase {
|
||||
hostname: string;
|
||||
@@ -19,12 +19,14 @@ function normalizeHostname(hostname: string | undefined) {
|
||||
return hostname?.trim().toLowerCase() || '';
|
||||
}
|
||||
|
||||
function getWebAppHostname() {
|
||||
try {
|
||||
return new URL(Config.endpoints.webApp).hostname;
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
function getWebAppHostnames(): Array<string> {
|
||||
return Config.endpoints.webAppOrigins.flatMap((origin) => {
|
||||
try {
|
||||
return [new URL(origin).hostname];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function endpointLinkBase(endpoint: string): ExcludedLinkBase | null {
|
||||
@@ -45,7 +47,7 @@ function getExcludedLinkBases(): Array<ExcludedLinkBase> {
|
||||
endpointLinkBase(Config.endpoints.invite),
|
||||
endpointLinkBase(Config.endpoints.gift),
|
||||
];
|
||||
for (const hostname of [getWebAppHostname(), Config.hosts.marketing]) {
|
||||
for (const hostname of [...getWebAppHostnames(), Config.hosts.marketing]) {
|
||||
for (const pathPrefix of CLIENT_ROUTE_PATH_PREFIXES) {
|
||||
bases.push({hostname: normalizeHostname(hostname), pathPrefix});
|
||||
}
|
||||
|
||||
@@ -35,7 +35,8 @@ function generateManifest(staticCdnEndpoint) {
|
||||
short_name: 'Fluxer',
|
||||
description:
|
||||
'Fluxer is a free and open source instant messaging and VoIP platform built for friends, groups, and communities.',
|
||||
start_url: '/',
|
||||
id: '/',
|
||||
start_url: '/app',
|
||||
display: 'standalone',
|
||||
orientation: 'portrait-primary',
|
||||
theme_color: '#4641D9',
|
||||
@@ -43,6 +44,7 @@ function generateManifest(staticCdnEndpoint) {
|
||||
categories: ['social', 'communication'],
|
||||
lang: 'en',
|
||||
scope: '/',
|
||||
scope_extensions: [],
|
||||
icons: [
|
||||
{
|
||||
src: `${cdn}/web/android-chrome-192x192.png`,
|
||||
|
||||
@@ -4,6 +4,7 @@ import {marketingUrl} from '@app/features/messaging/utils/MessagingUrlUtils';
|
||||
|
||||
export const Routes = {
|
||||
HOME: '/',
|
||||
APP: '/app',
|
||||
LOGIN: '/login',
|
||||
REGISTER: '/register',
|
||||
FORGOT_PASSWORD: '/forgot',
|
||||
|
||||
@@ -214,7 +214,7 @@ export const RootComponent: React.FC<{children?: React.ReactNode}> = observer(({
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (location.pathname === Routes.HOME) {
|
||||
if (location.pathname === Routes.HOME || location.pathname === Routes.APP) {
|
||||
return;
|
||||
}
|
||||
hasStartedRestoreRef.current = true;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {Routes} from '@app/app/Routes';
|
||||
import {RootComponent} from '@app/app/router/components/RootComponent';
|
||||
import {NotFoundPage} from '@app/features/app/components/pages/NotFoundPage';
|
||||
import {getDefaultLandingPath} from '@app/features/navigation/utils/DefaultLandingUtils';
|
||||
@@ -22,3 +23,9 @@ export const homeRoute = createRoute({
|
||||
path: '/',
|
||||
onEnter: () => new Redirect(getDefaultLandingPath()),
|
||||
});
|
||||
export const appRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
id: 'app',
|
||||
path: Routes.APP,
|
||||
onEnter: () => new Redirect(getDefaultLandingPath()),
|
||||
});
|
||||
|
||||
@@ -7,11 +7,12 @@ import {
|
||||
premiumCallbackRoute,
|
||||
} from '@app/app/router/routes/AppRoutes';
|
||||
import {authRouteTree} from '@app/app/router/routes/AuthRoutes';
|
||||
import {homeRoute, notFoundRoute, rootRoute} from '@app/app/router/routes/RootRoutes';
|
||||
import {appRoute, homeRoute, notFoundRoute, rootRoute} from '@app/app/router/routes/RootRoutes';
|
||||
import type {RouteConfig} from '@app/features/platform/components/router/RouterTypes';
|
||||
|
||||
const routeTree = rootRoute.addChildren([
|
||||
homeRoute,
|
||||
appRoute,
|
||||
notFoundRoute,
|
||||
premiumCallbackRoute,
|
||||
matureContentCheckCallbackRoute,
|
||||
|
||||
@@ -29,7 +29,7 @@ interface ForwardOriginChannel {
|
||||
interface BuildForwardDefaultDestinationsRequest {
|
||||
readonly frequentIds: ReadonlyArray<string>;
|
||||
readonly history: ReadonlyArray<string>;
|
||||
readonly isValid: (row: ForwardRowIdentity) => boolean;
|
||||
readonly accepts: (row: ForwardRowIdentity) => boolean;
|
||||
readonly mode: ForwardResultType | null;
|
||||
readonly origin: ForwardDestination | null;
|
||||
readonly pinned: ReadonlyArray<ForwardDestination>;
|
||||
@@ -51,7 +51,7 @@ export function resolveForwardOrigin(
|
||||
export function buildForwardDefaultDestinations({
|
||||
frequentIds,
|
||||
history,
|
||||
isValid,
|
||||
accepts,
|
||||
mode,
|
||||
origin,
|
||||
pinned,
|
||||
@@ -64,7 +64,7 @@ export function buildForwardDefaultDestinations({
|
||||
...history.slice(0, HISTORY_LIMIT).map((channelId) => resolveChannel(channelId)),
|
||||
...frequentIds.slice(0, FREQUENT_LIMIT).map((channelId) => resolveChannel(channelId)),
|
||||
];
|
||||
const rows = candidates.filter((row): row is ForwardRowIdentity => row != null && isValid(row));
|
||||
const rows = candidates.filter((row): row is ForwardRowIdentity => row != null && accepts(row));
|
||||
const originSelected =
|
||||
origin != null && selected.some((destination) => destination.type === origin.type && destination.id === origin.id);
|
||||
const hiddenIds = origin == null || originSelected ? [] : [origin.id];
|
||||
@@ -79,10 +79,10 @@ export function buildForwardDefaultDestinations({
|
||||
|
||||
export function filterForwardSearchRows(
|
||||
results: ReadonlyArray<ForwardRowIdentity>,
|
||||
isValid: (row: ForwardRowIdentity) => boolean,
|
||||
accepts: (row: ForwardRowIdentity) => boolean,
|
||||
): ReadonlyArray<ForwardDestinationRow> {
|
||||
return dedupeRows(
|
||||
results.filter((result) => isValid(result)),
|
||||
results.filter((result) => accepts(result)),
|
||||
[],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ export type ForwardResultType = 'user' | 'text_channel' | 'voice_channel' | 'gro
|
||||
export interface ForwardDestinationQuery {
|
||||
readonly mode: ForwardResultType | null;
|
||||
readonly query: string;
|
||||
readonly resultTypes: ReadonlyArray<ForwardResultType>;
|
||||
readonly kinds: ReadonlyArray<ForwardResultType>;
|
||||
}
|
||||
|
||||
const FORWARD_RESULT_TYPES: ReadonlyArray<ForwardResultType> = Object.freeze([
|
||||
@@ -24,10 +24,10 @@ const SIGIL_PATTERN = /^@|#|!|\*|\$/;
|
||||
|
||||
export function parseForwardDestinationQuery(text: string): ForwardDestinationQuery {
|
||||
if (text.startsWith(GLOBAL_USER_SIGIL)) {
|
||||
return Object.freeze({mode: null, query: text.slice(GLOBAL_USER_SIGIL.length), resultTypes: FORWARD_RESULT_TYPES});
|
||||
return Object.freeze({mode: null, query: text.slice(GLOBAL_USER_SIGIL.length), kinds: FORWARD_RESULT_TYPES});
|
||||
}
|
||||
const query = text.replace(SIGIL_PATTERN, '');
|
||||
const mode = SIGIL_RESULT_TYPES.get(text.charAt(0));
|
||||
if (mode === undefined) return Object.freeze({mode: null, query, resultTypes: FORWARD_RESULT_TYPES});
|
||||
return Object.freeze({mode, query, resultTypes: Object.freeze([mode])});
|
||||
if (mode === undefined) return Object.freeze({mode: null, query, kinds: FORWARD_RESULT_TYPES});
|
||||
return Object.freeze({mode, query, kinds: Object.freeze([mode])});
|
||||
}
|
||||
|
||||
+108
-65
@@ -21,11 +21,12 @@ const CHANNEL_CONTEXT_SCORE_CAP = 6;
|
||||
const VOICE_IN_TEXT_SEARCH_PENALTY = 1;
|
||||
const VOICE_IN_TEXT_SEARCH_FLOOR = 0.5;
|
||||
const CHANNEL_FRECENCY_BONUS = 3;
|
||||
const SNOWFLAKE_SCORE = 10;
|
||||
|
||||
type ForwardChannelKind = 'text' | 'voice';
|
||||
export type ForwardChannelKind = 'text' | 'voice';
|
||||
|
||||
export interface ForwardUserCandidate {
|
||||
readonly friendNickname: string | null;
|
||||
readonly friendAlias: string | null;
|
||||
readonly globalName: string | null;
|
||||
readonly id: string;
|
||||
readonly nicknames: ReadonlyArray<string>;
|
||||
@@ -48,69 +49,79 @@ export interface ForwardChannelCandidate {
|
||||
readonly parentName: string | null;
|
||||
}
|
||||
|
||||
export interface ForwardGuildCandidate {
|
||||
readonly id: string;
|
||||
readonly name: string;
|
||||
}
|
||||
|
||||
export interface ForwardFrequentItem {
|
||||
readonly id: string;
|
||||
readonly kind: 'dm' | 'group_dm' | 'text' | 'voice' | 'other';
|
||||
readonly kind: 'dm' | 'group_dm' | 'guild' | 'text' | 'voice' | 'other';
|
||||
readonly recipientId?: string | null;
|
||||
readonly score: number;
|
||||
}
|
||||
|
||||
export interface ForwardSearchBoosters {
|
||||
export interface ForwardSearchWeights {
|
||||
readonly groupDMs: ReadonlyMap<string, number>;
|
||||
readonly textChannels: ReadonlyMap<string, number>;
|
||||
readonly guilds: ReadonlyMap<string, number>;
|
||||
readonly textChannel: ReadonlyMap<string, number>;
|
||||
readonly users: ReadonlyMap<string, number>;
|
||||
readonly voiceChannels: ReadonlyMap<string, number>;
|
||||
readonly voiceChannel: ReadonlyMap<string, number>;
|
||||
}
|
||||
|
||||
export interface ForwardSearchResult {
|
||||
readonly comparator: string;
|
||||
readonly matchedText: string;
|
||||
readonly id: string;
|
||||
readonly score: number;
|
||||
readonly type: ForwardResultType;
|
||||
}
|
||||
|
||||
interface BuildForwardSearchBoostersRequest {
|
||||
interface BuildForwardSearchWeightsRequest {
|
||||
readonly dmUserIds: Iterable<string>;
|
||||
readonly frequent: ReadonlyArray<ForwardFrequentItem>;
|
||||
readonly friendIds: Iterable<string>;
|
||||
}
|
||||
|
||||
interface SearchForwardDestinationsRequest {
|
||||
readonly blacklist: ReadonlySet<string>;
|
||||
readonly boosters: ForwardSearchBoosters;
|
||||
readonly excludedIds: ReadonlySet<string>;
|
||||
readonly weights: ForwardSearchWeights;
|
||||
readonly channels: ReadonlyArray<ForwardChannelCandidate>;
|
||||
readonly confusables: ReadonlyMap<string, string>;
|
||||
readonly groupDMs: ReadonlyArray<ForwardGroupDMCandidate>;
|
||||
readonly limit: number;
|
||||
readonly query: string;
|
||||
readonly resultTypes: ReadonlyArray<ForwardResultType>;
|
||||
readonly kinds: ReadonlyArray<ForwardResultType>;
|
||||
readonly users: ReadonlyArray<ForwardUserCandidate>;
|
||||
}
|
||||
|
||||
export function buildForwardSearchBoosters({
|
||||
export function buildForwardSearchWeights({
|
||||
dmUserIds,
|
||||
frequent,
|
||||
friendIds,
|
||||
}: BuildForwardSearchBoostersRequest): ForwardSearchBoosters {
|
||||
}: BuildForwardSearchWeightsRequest): ForwardSearchWeights {
|
||||
const maxScore = frequent.reduce((max, item) => Math.max(max, item.score), 0);
|
||||
const users = new Map<string, number>();
|
||||
const groupDMs = new Map<string, number>();
|
||||
const textChannels = new Map<string, number>();
|
||||
const voiceChannels = new Map<string, number>();
|
||||
const guilds = new Map<string, number>();
|
||||
const textChannel = new Map<string, number>();
|
||||
const voiceChannel = new Map<string, number>();
|
||||
for (const item of frequent) {
|
||||
const boost = maxScore > 0 ? 1 + item.score / maxScore : 1;
|
||||
const weight = maxScore > 0 ? 1 + item.score / maxScore : 1;
|
||||
switch (item.kind) {
|
||||
case 'dm':
|
||||
if (item.recipientId != null) users.set(item.recipientId, boost);
|
||||
if (item.recipientId != null) users.set(item.recipientId, weight);
|
||||
break;
|
||||
case 'group_dm':
|
||||
groupDMs.set(item.id, boost);
|
||||
groupDMs.set(item.id, weight);
|
||||
break;
|
||||
case 'guild':
|
||||
guilds.set(item.id, weight);
|
||||
break;
|
||||
case 'text':
|
||||
textChannels.set(item.id, boost);
|
||||
textChannel.set(item.id, weight);
|
||||
break;
|
||||
case 'voice':
|
||||
voiceChannels.set(item.id, boost);
|
||||
voiceChannel.set(item.id, weight);
|
||||
break;
|
||||
case 'other':
|
||||
break;
|
||||
@@ -118,31 +129,27 @@ export function buildForwardSearchBoosters({
|
||||
}
|
||||
for (const friendId of friendIds) users.set(friendId, (users.get(friendId) ?? 1) + FRIEND_BOOST);
|
||||
for (const userId of dmUserIds) users.set(userId, (users.get(userId) ?? 1) + OPEN_DM_BOOST);
|
||||
return Object.freeze({groupDMs, textChannels, users, voiceChannels});
|
||||
return Object.freeze({groupDMs, guilds, textChannel, users, voiceChannel});
|
||||
}
|
||||
|
||||
export function searchForwardDestinations(
|
||||
request: SearchForwardDestinationsRequest,
|
||||
): ReadonlyArray<ForwardSearchResult> {
|
||||
const {boosters, channels, confusables, limit, query, resultTypes} = request;
|
||||
const {weights, channels, confusables, limit, query, kinds} = request;
|
||||
if (query.trim() === '') return [];
|
||||
const results = [
|
||||
...(resultTypes.includes('user')
|
||||
? searchUsers(query, request.users, boosters.users, request.blacklist, confusables, limit)
|
||||
...(kinds.includes('user')
|
||||
? searchUsers(query, request.users, weights.users, request.excludedIds, confusables, limit)
|
||||
: []),
|
||||
...(resultTypes.includes('group_dm')
|
||||
? searchGroupDMs(query, request.groupDMs, boosters.groupDMs, confusables, limit)
|
||||
: []),
|
||||
...(resultTypes.includes('text_channel')
|
||||
? searchChannels(query, channels, 'text', boosters.textChannels, limit)
|
||||
: []),
|
||||
...(resultTypes.includes('voice_channel')
|
||||
? searchChannels(query, channels, 'voice', boosters.voiceChannels, limit)
|
||||
...(kinds.includes('group_dm')
|
||||
? searchGroupDMs(query, request.groupDMs, weights.groupDMs, confusables, limit)
|
||||
: []),
|
||||
...(kinds.includes('text_channel') ? searchChannels(query, channels, 'text', weights.textChannel, limit) : []),
|
||||
...(kinds.includes('voice_channel') ? searchChannels(query, channels, 'voice', weights.voiceChannel, limit) : []),
|
||||
];
|
||||
const seenKeys = new Set<string>();
|
||||
const merged = results.filter((result) => {
|
||||
const key = `${result.type}-${result.id}`;
|
||||
const key = `${result.type}|${result.id}`;
|
||||
if (seenKeys.has(key)) return false;
|
||||
seenKeys.add(key);
|
||||
return true;
|
||||
@@ -150,10 +157,10 @@ export function searchForwardDestinations(
|
||||
return merged.sort(compareSearchResults);
|
||||
}
|
||||
|
||||
function compareSearchResults(left: ForwardSearchResult, right: ForwardSearchResult): number {
|
||||
export function compareSearchResults(left: ForwardSearchResult, right: ForwardSearchResult): number {
|
||||
if (left.score === right.score && left.type === 'user') {
|
||||
const leftName = left.comparator.toLocaleLowerCase();
|
||||
const rightName = right.comparator.toLocaleLowerCase();
|
||||
const leftName = left.matchedText.toLocaleLowerCase();
|
||||
const rightName = right.matchedText.toLocaleLowerCase();
|
||||
if (leftName < rightName) return -1;
|
||||
if (leftName > rightName) return 1;
|
||||
}
|
||||
@@ -164,69 +171,69 @@ function sortAndLimit(results: Array<ForwardSearchResult>, limit: number): Array
|
||||
return results.sort(compareSearchResults).slice(0, limit);
|
||||
}
|
||||
|
||||
function searchUsers(
|
||||
export function searchUsers(
|
||||
query: string,
|
||||
users: ReadonlyArray<ForwardUserCandidate>,
|
||||
boosters: ReadonlyMap<string, number>,
|
||||
blacklist: ReadonlySet<string>,
|
||||
weights: ReadonlyMap<string, number>,
|
||||
excludedIds: ReadonlySet<string>,
|
||||
confusables: ReadonlyMap<string, string>,
|
||||
limit: number,
|
||||
): Array<ForwardSearchResult> {
|
||||
const escapedQuery = escapeSearchPattern(query);
|
||||
const prefixQuery = new RegExp(`^${escapedQuery}`, 'i');
|
||||
const containQuery = new RegExp(escapedQuery, 'i');
|
||||
const queryLower = query.toLocaleLowerCase();
|
||||
const querySkeleton = toConfusableSkeleton(queryLower, confusables);
|
||||
const substringPattern = new RegExp(escapedQuery, 'i');
|
||||
const loweredText = query.toLocaleLowerCase();
|
||||
const querySkeleton = toConfusableSkeleton(loweredText, confusables);
|
||||
const scoreField = (field: string): number => {
|
||||
if (prefixQuery.test(field)) return 10;
|
||||
if (containQuery.test(field)) return 5;
|
||||
if (substringPattern.test(field)) return 5;
|
||||
const stripped = stripCombiningMarks(field.toLocaleLowerCase());
|
||||
if (fuzzySearch(queryLower, stripped)) return 1;
|
||||
if (fuzzySearch(loweredText, stripped)) return 1;
|
||||
if (fuzzySearch(querySkeleton, toConfusableSkeleton(stripped, confusables))) return 1;
|
||||
return 0;
|
||||
};
|
||||
const matches: Array<ForwardSearchResult> = [];
|
||||
for (const user of users) {
|
||||
if (blacklist.has(user.id)) continue;
|
||||
const booster = boosters.get(user.id) ?? 1;
|
||||
if (excludedIds.has(user.id)) continue;
|
||||
const booster = weights.get(user.id) ?? 1;
|
||||
if (user.id === query) {
|
||||
matches.push({comparator: user.id, id: user.id, score: 10 * booster, type: 'user'});
|
||||
matches.push({matchedText: user.id, id: user.id, score: 10 * booster, type: 'user'});
|
||||
continue;
|
||||
}
|
||||
let best: ForwardSearchResult | null = null;
|
||||
for (const field of [user.username, user.friendNickname, user.globalName, ...user.nicknames]) {
|
||||
for (const field of [user.username, user.friendAlias, user.globalName, ...user.nicknames]) {
|
||||
if (field == null) continue;
|
||||
const score = scoreField(field) * booster;
|
||||
if (score === 0 || (best != null && best.score >= score)) continue;
|
||||
best = {comparator: field, id: user.id, score, type: 'user'};
|
||||
best = {matchedText: field, id: user.id, score, type: 'user'};
|
||||
}
|
||||
if (best != null) matches.push(best);
|
||||
}
|
||||
return sortAndLimit(matches, limit).map((match) => Object.freeze({...match, score: SCORE_SCALE * match.score}));
|
||||
}
|
||||
|
||||
function searchGroupDMs(
|
||||
export function searchGroupDMs(
|
||||
query: string,
|
||||
groupDMs: ReadonlyArray<ForwardGroupDMCandidate>,
|
||||
boosters: ReadonlyMap<string, number>,
|
||||
weights: ReadonlyMap<string, number>,
|
||||
confusables: ReadonlyMap<string, string>,
|
||||
limit: number,
|
||||
): Array<ForwardSearchResult> {
|
||||
const normalize = (text: string): string =>
|
||||
const foldText = (text: string): string =>
|
||||
stripCombiningMarks(toConfusableSkeleton(text.toLocaleLowerCase(), confusables));
|
||||
const term = createSearchTerm(normalize(query));
|
||||
const term = createSearchTerm(foldText(query));
|
||||
const results: Array<ForwardSearchResult> = [];
|
||||
for (const groupDM of groupDMs) {
|
||||
let score = scoreSearchTerm(normalize(groupDM.name), term);
|
||||
let score = scoreSearchTerm(foldText(groupDM.name), term);
|
||||
for (const field of groupDM.memberFields) {
|
||||
score = Math.max(score, Math.min(GROUP_DM_MEMBER_SCORE_CAP, scoreSearchTerm(normalize(field), term)));
|
||||
score = Math.max(score, Math.min(GROUP_DM_MEMBER_SCORE_CAP, scoreSearchTerm(foldText(field), term)));
|
||||
}
|
||||
if (score === 0) continue;
|
||||
results.push(
|
||||
Object.freeze({
|
||||
comparator: groupDM.name,
|
||||
matchedText: groupDM.name,
|
||||
id: groupDM.id,
|
||||
score: SCORE_SCALE * score * (boosters.get(groupDM.id) ?? 1),
|
||||
score: SCORE_SCALE * score * (weights.get(groupDM.id) ?? 1),
|
||||
type: 'group_dm',
|
||||
}),
|
||||
);
|
||||
@@ -234,12 +241,13 @@ function searchGroupDMs(
|
||||
return sortAndLimit(results, limit);
|
||||
}
|
||||
|
||||
function searchChannels(
|
||||
export function searchChannels(
|
||||
query: string,
|
||||
channels: ReadonlyArray<ForwardChannelCandidate>,
|
||||
searchKind: ForwardChannelKind,
|
||||
boosters: ReadonlyMap<string, number>,
|
||||
weights: ReadonlyMap<string, number>,
|
||||
limit: number,
|
||||
matchExactIds = false,
|
||||
): Array<ForwardSearchResult> {
|
||||
const terms = buildChannelSearchTerms(query);
|
||||
const results: Array<ForwardSearchResult> = [];
|
||||
@@ -247,29 +255,64 @@ function searchChannels(
|
||||
if (searchKind === 'voice' && channel.kind !== 'voice') continue;
|
||||
if (!channel.canAccess) continue;
|
||||
const remainingTerms = [...terms];
|
||||
let score = consumeBestSearchTerm(channel.name.toLocaleLowerCase(), remainingTerms, true);
|
||||
const isSnowflakeMatch = matchExactIds && channel.id === query;
|
||||
let score = isSnowflakeMatch
|
||||
? SNOWFLAKE_SCORE
|
||||
: consumeBestSearchTerm(channel.name.toLocaleLowerCase(), remainingTerms, true);
|
||||
if (score === 0) continue;
|
||||
if (remainingTerms.length > 0) {
|
||||
if (!isSnowflakeMatch && remainingTerms.length > 0) {
|
||||
for (const context of [channel.guildName, channel.parentName]) {
|
||||
if (context == null || context === '') continue;
|
||||
score += CHANNEL_CONTEXT_WEIGHT * consumeBestSearchTerm(context.toLocaleLowerCase(), remainingTerms, false);
|
||||
}
|
||||
score = Math.min(CHANNEL_CONTEXT_SCORE_CAP, score);
|
||||
}
|
||||
if (remainingTerms.length > 1) continue;
|
||||
if (remainingTerms.length === 1 && !remainingTerms[0].isFullMatch) continue;
|
||||
if (!isSnowflakeMatch && remainingTerms.length > 1) continue;
|
||||
if (!isSnowflakeMatch && remainingTerms.length === 1 && !remainingTerms[0].spansWholeQuery) continue;
|
||||
if (searchKind === 'text' && channel.kind === 'voice') {
|
||||
score = Math.max(score - VOICE_IN_TEXT_SEARCH_PENALTY, VOICE_IN_TEXT_SEARCH_FLOOR);
|
||||
}
|
||||
score = Math.min(score + (channel.hasFrecency ? CHANNEL_FRECENCY_BONUS : 0), score >= 7 ? 10 : 7);
|
||||
results.push(
|
||||
Object.freeze({
|
||||
comparator: channel.name,
|
||||
matchedText: channel.name,
|
||||
id: channel.id,
|
||||
score: SCORE_SCALE * score * (boosters.get(channel.id) ?? 1),
|
||||
score: SCORE_SCALE * score * (weights.get(channel.id) ?? 1),
|
||||
type: channel.kind === 'voice' ? 'voice_channel' : 'text_channel',
|
||||
}),
|
||||
);
|
||||
}
|
||||
return sortAndLimit(results, limit);
|
||||
}
|
||||
|
||||
export interface GuildSearchResult {
|
||||
readonly matchedText: string;
|
||||
readonly id: string;
|
||||
readonly score: number;
|
||||
}
|
||||
|
||||
export function searchGuilds(
|
||||
query: string,
|
||||
guilds: ReadonlyArray<ForwardGuildCandidate>,
|
||||
weights: ReadonlyMap<string, number>,
|
||||
excludedIds: ReadonlySet<string>,
|
||||
limit: number,
|
||||
matchExactIds = false,
|
||||
): Array<GuildSearchResult> {
|
||||
const term = createSearchTerm(query.toLocaleLowerCase());
|
||||
const results: Array<GuildSearchResult> = [];
|
||||
for (const guild of guilds) {
|
||||
if (excludedIds.has(guild.id)) continue;
|
||||
const score =
|
||||
matchExactIds && guild.id === query ? SNOWFLAKE_SCORE : scoreSearchTerm(guild.name.toLocaleLowerCase(), term);
|
||||
if (score === 0) continue;
|
||||
results.push(
|
||||
Object.freeze({
|
||||
matchedText: guild.name,
|
||||
id: guild.id,
|
||||
score: SCORE_SCALE * score * (weights.get(guild.id) ?? 1),
|
||||
}),
|
||||
);
|
||||
}
|
||||
return results.sort((left, right) => right.score - left.score).slice(0, limit);
|
||||
}
|
||||
|
||||
+1
-1
@@ -10,7 +10,7 @@ export interface ForwardPinnedDestinations {
|
||||
}
|
||||
|
||||
export function forwardDestinationKey(destination: ForwardDestination): string {
|
||||
return `${destination.type}:${destination.id}`;
|
||||
return `${destination.type}/${destination.id}`;
|
||||
}
|
||||
|
||||
export function toggleForwardDestination(
|
||||
|
||||
@@ -9,13 +9,7 @@ import {
|
||||
} from '@app/features/app/components/dialogs/shared/ForwardDefaultDestinations';
|
||||
import {parseForwardDestinationQuery} from '@app/features/app/components/dialogs/shared/ForwardDestinationQuery';
|
||||
import {
|
||||
buildForwardSearchBoosters,
|
||||
type ForwardChannelCandidate,
|
||||
type ForwardFrequentItem,
|
||||
type ForwardGroupDMCandidate,
|
||||
type ForwardSearchBoosters,
|
||||
type ForwardSearchResult,
|
||||
type ForwardUserCandidate,
|
||||
searchForwardDestinations,
|
||||
} from '@app/features/app/components/dialogs/shared/ForwardDestinationSearch';
|
||||
import {
|
||||
@@ -33,7 +27,6 @@ import * as ChannelUtils from '@app/features/channel/utils/ChannelUtils';
|
||||
import DeveloperOptions from '@app/features/devtools/state/DeveloperOptions';
|
||||
import type {Guild} from '@app/features/guild/models/Guild';
|
||||
import Guilds from '@app/features/guild/state/Guilds';
|
||||
import {PERSONAL_NOTES_DESCRIPTOR} from '@app/features/i18n/utils/CommonMessageDescriptors';
|
||||
import GuildMembers from '@app/features/member/state/GuildMembers';
|
||||
import type {ForwardMediaSelection} from '@app/features/messaging/commands/MessageCommands';
|
||||
import type {Message} from '@app/features/messaging/models/MessagingMessage';
|
||||
@@ -41,7 +34,7 @@ import SelectedChannel from '@app/features/navigation/state/SelectedChannel';
|
||||
import Permission from '@app/features/permissions/state/Permission';
|
||||
import {formatPermissionLabel} from '@app/features/permissions/utils/PermissionUtils';
|
||||
import {Logger} from '@app/features/platform/utils/AppLogger';
|
||||
import Relationships from '@app/features/relationship/state/Relationships';
|
||||
import {createForwardSearchCandidates} from '@app/features/search/utils/DestinationSearchSources';
|
||||
import {getLoadedUnicodeConfusables, loadUnicodeConfusables} from '@app/features/search/utils/SearchTextMatching';
|
||||
import Slowmode from '@app/features/slowmode/state/Slowmode';
|
||||
import {useNow} from '@app/features/ui/state/Tick';
|
||||
@@ -51,13 +44,11 @@ import * as NicknameUtils from '@app/features/user/utils/NicknameUtils';
|
||||
import {ChannelTypes, Permissions} from '@fluxer/constants/src/ChannelConstants';
|
||||
import {GuildNSFWLevel, GuildOperations} from '@fluxer/constants/src/GuildConstants';
|
||||
import {CHANNEL_RATE_LIMIT_PER_USER_MAX} from '@fluxer/constants/src/LimitConstants';
|
||||
import {RelationshipTypes} from '@fluxer/constants/src/UserConstants';
|
||||
import type {MessageEmbed} from '@fluxer/schema/src/domains/message/EmbedSchemas';
|
||||
import type {MessageAttachment} from '@fluxer/schema/src/domains/message/MessageResponseSchemas';
|
||||
import type {I18n} from '@lingui/core';
|
||||
import {msg} from '@lingui/core/macro';
|
||||
import {useLingui} from '@lingui/react/macro';
|
||||
import {compareStructural, computed, type IComputedValue} from 'mobx';
|
||||
import {useEffect, useMemo, useState} from 'react';
|
||||
|
||||
const GUILD_MESSAGES_DISABLED_DESCRIPTOR = msg({
|
||||
@@ -110,13 +101,6 @@ interface ForwardMediaNeeds {
|
||||
readonly hasEmbeds: boolean;
|
||||
}
|
||||
|
||||
interface ForwardSearchCandidates {
|
||||
readonly boosters: ForwardSearchBoosters;
|
||||
readonly channels: ReadonlyArray<ForwardChannelCandidate>;
|
||||
readonly groupDMs: ReadonlyArray<ForwardGroupDMCandidate>;
|
||||
readonly users: ReadonlyArray<ForwardUserCandidate>;
|
||||
}
|
||||
|
||||
export interface ForwardDestinationOption {
|
||||
readonly channel: Channel | null;
|
||||
readonly destination: ForwardDestination;
|
||||
@@ -137,10 +121,10 @@ interface UseForwardDestinationsOptions {
|
||||
interface ForwardDestinationsState {
|
||||
readonly composerChannel: Channel | null;
|
||||
readonly options: ReadonlyArray<ForwardDestinationOption>;
|
||||
readonly searchQuery: string;
|
||||
readonly filterText: string;
|
||||
readonly selected: ReadonlyArray<ForwardDestination>;
|
||||
readonly selectedKeys: ReadonlySet<string>;
|
||||
readonly setSearchQuery: (query: string) => void;
|
||||
readonly setFilterText: (query: string) => void;
|
||||
readonly slowmodeActiveSelectedOptions: ReadonlyArray<ForwardDestinationOption>;
|
||||
readonly slowmodeEnabledSelectedOptions: ReadonlyArray<ForwardDestinationOption>;
|
||||
readonly toggleDestination: (destination: ForwardDestination) => void;
|
||||
@@ -197,144 +181,6 @@ function isForwardRowValid(row: ForwardRowIdentity): boolean {
|
||||
}
|
||||
}
|
||||
|
||||
function collectGuildNicknames(): ReadonlyMap<string, Array<string>> {
|
||||
const nicknames = new Map<string, Array<string>>();
|
||||
for (const guild of Guilds.getGuilds()) {
|
||||
for (const member of GuildMembers.getMembers(guild.id)) {
|
||||
if (member.nick == null) continue;
|
||||
const userNicknames = nicknames.get(member.user.id);
|
||||
if (userNicknames === undefined) {
|
||||
nicknames.set(member.user.id, [member.nick]);
|
||||
} else {
|
||||
userNicknames.push(member.nick);
|
||||
}
|
||||
}
|
||||
}
|
||||
return nicknames;
|
||||
}
|
||||
|
||||
function buildForwardUserCandidates(): ReadonlyArray<ForwardUserCandidate> {
|
||||
const nicknames = collectGuildNicknames();
|
||||
const candidates: Array<ForwardUserCandidate> = [];
|
||||
for (const user of Users.getUsers()) {
|
||||
const relationship = Relationships.getRelationship(user.id);
|
||||
if (relationship?.type === RelationshipTypes.BLOCKED) continue;
|
||||
let friendNickname: string | null = null;
|
||||
if (relationship?.type === RelationshipTypes.FRIEND) friendNickname = relationship.nickname;
|
||||
candidates.push(
|
||||
Object.freeze({
|
||||
friendNickname,
|
||||
globalName: user.globalName,
|
||||
id: user.id,
|
||||
nicknames: nicknames.get(user.id) ?? NO_STRINGS,
|
||||
username: user.discriminator === '0' ? user.username : `${user.username}#${user.discriminator}`,
|
||||
}),
|
||||
);
|
||||
}
|
||||
return Object.freeze(candidates);
|
||||
}
|
||||
|
||||
function collectRecipientSearchFields(recipientIds: ReadonlyArray<string>): ReadonlyArray<string> {
|
||||
const fields: Array<string> = [];
|
||||
for (const recipientId of recipientIds) {
|
||||
const recipient = Users.getUser(recipientId);
|
||||
if (recipient == null) continue;
|
||||
fields.push(recipient.username);
|
||||
if (recipient.globalName != null) fields.push(recipient.globalName);
|
||||
const relationshipNickname = Relationships.getRelationship(recipientId)?.nickname;
|
||||
if (relationshipNickname != null) fields.push(relationshipNickname);
|
||||
}
|
||||
return Object.freeze(fields);
|
||||
}
|
||||
|
||||
function buildForwardGroupDMCandidates(i18n: I18n): ReadonlyArray<ForwardGroupDMCandidate> {
|
||||
const candidates: Array<ForwardGroupDMCandidate> = [];
|
||||
for (const channel of Channels.getPrivateChannels()) {
|
||||
if (channel.type !== ChannelTypes.GROUP_DM) continue;
|
||||
candidates.push(
|
||||
Object.freeze({
|
||||
id: channel.id,
|
||||
memberFields: collectRecipientSearchFields(channel.recipientIds),
|
||||
name: ChannelUtils.getDMDisplayName(channel),
|
||||
}),
|
||||
);
|
||||
}
|
||||
const currentUserId = Users.currentUserId;
|
||||
const personalNotes = currentUserId == null ? undefined : Channels.getChannel(currentUserId);
|
||||
if (personalNotes?.type === ChannelTypes.DM_PERSONAL_NOTES) {
|
||||
candidates.push(
|
||||
Object.freeze({id: personalNotes.id, memberFields: NO_STRINGS, name: i18n._(PERSONAL_NOTES_DESCRIPTOR)}),
|
||||
);
|
||||
}
|
||||
return Object.freeze(candidates);
|
||||
}
|
||||
|
||||
function buildForwardChannelCandidates(): ReadonlyArray<ForwardChannelCandidate> {
|
||||
const candidates: Array<ForwardChannelCandidate> = [];
|
||||
for (const channel of Channels.allChannels) {
|
||||
if (channel.type !== ChannelTypes.GUILD_TEXT && channel.type !== ChannelTypes.GUILD_VOICE) continue;
|
||||
const isVoice = channel.type === ChannelTypes.GUILD_VOICE;
|
||||
const accessPermissions = isVoice ? Permissions.VIEW_CHANNEL | Permissions.CONNECT : Permissions.VIEW_CHANNEL;
|
||||
candidates.push(
|
||||
Object.freeze({
|
||||
canAccess: Permission.can(accessPermissions, channel),
|
||||
guildName: channel.guildId == null ? null : (Guilds.getGuild(channel.guildId)?.name ?? null),
|
||||
hasFrecency: ChannelFrecency.getScore(channel.id) > 0,
|
||||
id: channel.id,
|
||||
kind: isVoice ? 'voice' : 'text',
|
||||
name: channel.name ?? '',
|
||||
parentName: channel.parentId == null ? null : (Channels.getChannel(channel.parentId)?.name ?? null),
|
||||
}),
|
||||
);
|
||||
}
|
||||
return Object.freeze(candidates);
|
||||
}
|
||||
|
||||
function resolveForwardFrequentItem(id: string): ForwardFrequentItem {
|
||||
const score = ChannelFrecency.getScore(id);
|
||||
const channel = Guilds.getGuild(id) == null ? Channels.getChannel(id) : undefined;
|
||||
switch (channel?.type) {
|
||||
case ChannelTypes.DM:
|
||||
return {id, kind: 'dm', recipientId: channel.recipientIds.length > 0 ? channel.recipientIds[0] : null, score};
|
||||
case ChannelTypes.GROUP_DM:
|
||||
case ChannelTypes.DM_PERSONAL_NOTES:
|
||||
return {id, kind: 'group_dm', score};
|
||||
case ChannelTypes.GUILD_TEXT:
|
||||
return {id, kind: 'text', score};
|
||||
case ChannelTypes.GUILD_VOICE:
|
||||
return {id, kind: 'voice', score};
|
||||
default:
|
||||
return {id, kind: 'other', score};
|
||||
}
|
||||
}
|
||||
|
||||
function buildForwardSearchBoostersFromStores(): ForwardSearchBoosters {
|
||||
const friendIds: Array<string> = [];
|
||||
for (const relationship of Relationships.getRelationships()) {
|
||||
if (relationship.type === RelationshipTypes.FRIEND) friendIds.push(relationship.userId);
|
||||
}
|
||||
const dmUserIds: Array<string> = [];
|
||||
for (const channel of Channels.getPrivateChannels()) {
|
||||
if (channel.type === ChannelTypes.DM && channel.recipientIds.length > 0) dmUserIds.push(channel.recipientIds[0]);
|
||||
}
|
||||
return buildForwardSearchBoosters({
|
||||
dmUserIds,
|
||||
frequent: ChannelFrecency.frequentIds.map(resolveForwardFrequentItem),
|
||||
friendIds,
|
||||
});
|
||||
}
|
||||
|
||||
function createForwardSearchCandidates(i18n: I18n): IComputedValue<ForwardSearchCandidates> {
|
||||
const options = {equals: compareStructural};
|
||||
const users = computed(buildForwardUserCandidates, options);
|
||||
const groupDMs = computed(() => buildForwardGroupDMCandidates(i18n), options);
|
||||
const channels = computed(buildForwardChannelCandidates, options);
|
||||
const boosters = computed(buildForwardSearchBoostersFromStores, options);
|
||||
return computed(() =>
|
||||
Object.freeze({boosters: boosters.get(), channels: channels.get(), groupDMs: groupDMs.get(), users: users.get()}),
|
||||
);
|
||||
}
|
||||
|
||||
function selectForwardedAttachments(
|
||||
message: Message,
|
||||
mediaSelection: ForwardMediaSelection | undefined,
|
||||
@@ -418,6 +264,16 @@ function formatGuildChannelDetail(guild: Guild | undefined, channel: Channel): s
|
||||
return detail === '' ? null : detail;
|
||||
}
|
||||
|
||||
function formatGroupDMDetail(channel: Channel): string | null {
|
||||
if (channel.type !== ChannelTypes.GROUP_DM || (channel.name?.trim() ?? '') === '') return null;
|
||||
const names: Array<string> = [];
|
||||
for (const recipientId of channel.recipientIds) {
|
||||
const recipient = Users.getUser(recipientId);
|
||||
if (recipient != null) names.push(NicknameUtils.getNickname(recipient, null, channel.id));
|
||||
}
|
||||
return names.length === 0 ? null : names.join(', ');
|
||||
}
|
||||
|
||||
function resolveGuildChannelDisableReason(
|
||||
channel: Channel,
|
||||
guild: Guild | undefined,
|
||||
@@ -469,7 +325,7 @@ function resolveForwardDestinationOption(
|
||||
return Object.freeze({
|
||||
channel,
|
||||
destination,
|
||||
detail: null,
|
||||
detail: formatGroupDMDetail(channel),
|
||||
disableReason: resolveAgeRestrictedDisableReason(channel, mediaNeeds, i18n),
|
||||
displayName: ChannelUtils.getDMDisplayName(channel),
|
||||
key,
|
||||
@@ -521,16 +377,16 @@ export function useForwardDestinations({
|
||||
message,
|
||||
}: UseForwardDestinationsOptions): ForwardDestinationsState {
|
||||
const {i18n} = useLingui();
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [filterText, setFilterText] = useState('');
|
||||
const [selected, setSelected] = useState(NO_DESTINATIONS);
|
||||
const [pinnedDestinations, setPinnedDestinations] = useState(INITIAL_PINNED_DESTINATIONS);
|
||||
const [stickyPicks, setStickyPicks] = useState(INITIAL_PINNED_DESTINATIONS);
|
||||
const [confusables, setConfusables] = useState(() => getLoadedUnicodeConfusables() ?? NO_CONFUSABLES);
|
||||
const searchCandidates = useMemo(() => createForwardSearchCandidates(i18n), [i18n, i18n.locale]);
|
||||
const mediaNeeds = useMemo(() => resolveForwardMediaNeeds(message, mediaSelection), [message, mediaSelection]);
|
||||
const parsedQuery = useMemo(() => parseForwardDestinationQuery(searchQuery), [searchQuery]);
|
||||
const parsedQuery = useMemo(() => parseForwardDestinationQuery(filterText), [filterText]);
|
||||
const engineQuery = parsedQuery.query.trim() === '' ? '' : parsedQuery.query;
|
||||
const currentPinned = pinForwardDestinations(pinnedDestinations, engineQuery, selected);
|
||||
if (currentPinned !== pinnedDestinations) setPinnedDestinations(currentPinned);
|
||||
const currentPinned = pinForwardDestinations(stickyPicks, engineQuery, selected);
|
||||
if (currentPinned !== stickyPicks) setStickyPicks(currentPinned);
|
||||
useEffect(() => {
|
||||
let isMounted = true;
|
||||
loadUnicodeConfusables().then(
|
||||
@@ -549,11 +405,11 @@ export function useForwardDestinations({
|
||||
if (candidates == null) return NO_SEARCH_RESULTS;
|
||||
return searchForwardDestinations({
|
||||
...candidates,
|
||||
blacklist: new Set(currentUserId == null ? NO_STRINGS : [currentUserId]),
|
||||
excludedIds: new Set(currentUserId == null ? NO_STRINGS : [currentUserId]),
|
||||
confusables,
|
||||
limit: parsedQuery.resultTypes.length === 1 ? SINGLE_TYPE_SEARCH_LIMIT : SEARCH_LIMIT,
|
||||
limit: parsedQuery.kinds.length === 1 ? SINGLE_TYPE_SEARCH_LIMIT : SEARCH_LIMIT,
|
||||
query: engineQuery,
|
||||
resultTypes: parsedQuery.resultTypes,
|
||||
kinds: parsedQuery.kinds,
|
||||
});
|
||||
}, [candidates, confusables, currentUserId, engineQuery, parsedQuery]);
|
||||
const rows =
|
||||
@@ -561,7 +417,7 @@ export function useForwardDestinations({
|
||||
? buildForwardDefaultDestinations({
|
||||
frequentIds: ChannelFrecency.frequentIds,
|
||||
history: [...new Set(SelectedChannel.sortedRecentVisits.map((visit) => visit.channelId))],
|
||||
isValid: isForwardRowValid,
|
||||
accepts: isForwardRowValid,
|
||||
mode: parsedQuery.mode,
|
||||
origin: resolveForwardOrigin(message.channelId, Channels.getChannel(message.channelId)),
|
||||
pinned: currentPinned.pinned,
|
||||
@@ -580,16 +436,16 @@ export function useForwardDestinations({
|
||||
const toggleDestination = (destination: ForwardDestination) => {
|
||||
const next = toggleForwardDestination(selected, destination);
|
||||
if (next === selected) return;
|
||||
if (next.length > selected.length) setSearchQuery('');
|
||||
if (next.length > selected.length) setFilterText('');
|
||||
setSelected(next);
|
||||
};
|
||||
return {
|
||||
composerChannel: resolveForwardComposerChannel(selected),
|
||||
options,
|
||||
searchQuery,
|
||||
filterText,
|
||||
selected,
|
||||
selectedKeys: new Set(selected.map(forwardDestinationKey)),
|
||||
setSearchQuery,
|
||||
setFilterText,
|
||||
slowmodeActiveSelectedOptions: selectedOptions.filter(isSlowmodeActive),
|
||||
slowmodeEnabledSelectedOptions: selectedOptions.filter((option) => option.slowmodeEnabled),
|
||||
toggleDestination,
|
||||
|
||||
@@ -1287,6 +1287,7 @@ const SKELETON_NAGBAR_ROW_SHAPES: Record<NagbarType, SkeletonNagbarRowShape> = {
|
||||
[NagbarType.LINUX_INPUT_ACCESS]: {tone: SkeletonNagbarTone.BRAND, hasActions: true},
|
||||
[NagbarType.SOFTWARE_ENCODER]: {tone: SkeletonNagbarTone.ENCODER, hasActions: true},
|
||||
[NagbarType.STREAMER_MODE]: {tone: SkeletonNagbarTone.STREAMER, hasActions: true},
|
||||
[NagbarType.DOMAIN_MOVED]: {tone: SkeletonNagbarTone.BRAND, hasActions: true},
|
||||
};
|
||||
|
||||
const CONNECTION_SKELETON_NAGBAR_TONES: Record<ConnectionNoticeTone, SkeletonNagbarTone> = {
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
} from '@app/features/app/components/layout/app_layout/AppLayoutTypes';
|
||||
import {isScheduledMaintenanceNagbarDismissed} from '@app/features/app/components/layout/app_layout/ScheduledMaintenanceDismissal';
|
||||
import Config from '@app/features/app/config/Config';
|
||||
import DomainMovedNotice from '@app/features/app/domain_migration/DomainMovedNotice';
|
||||
import {isClientReconnecting} from '@app/features/app/state/ClientReadiness';
|
||||
import Initialization from '@app/features/app/state/Initialization';
|
||||
import RuntimeConfig from '@app/features/app/state/RuntimeConfig';
|
||||
@@ -255,6 +256,11 @@ export const useNagbarConditions = (): NagbarConditions => {
|
||||
const canShowSoftwareEncoder = SoftwareEncoderWarning.showWarning;
|
||||
const canShowStreamerMode = StreamerMode.shouldShowNagbar;
|
||||
const canShowDesktopUpdateReady = Updater.shouldShowUpdateReadyNagbar;
|
||||
const canShowDomainMoved = nagbarState.forceHideDomainMoved
|
||||
? false
|
||||
: nagbarState.forceDomainMoved
|
||||
? true
|
||||
: DomainMovedNotice.shouldShow(Date.now());
|
||||
const canShowBuildEnvironment =
|
||||
!BUILD_ENVIRONMENT_HIDDEN_RELEASE_CHANNELS.has(Config.PUBLIC_RELEASE_CHANNEL) &&
|
||||
!nagbarState.buildEnvironmentDismissedThisSession;
|
||||
@@ -316,6 +322,7 @@ export const useNagbarConditions = (): NagbarConditions => {
|
||||
canShowSoftwareEncoder,
|
||||
canShowStreamerMode,
|
||||
canShowDesktopUpdateReady,
|
||||
canShowDomainMoved,
|
||||
};
|
||||
};
|
||||
export const useActiveNagbars = (conditions: NagbarConditions): Array<NagbarState> => {
|
||||
@@ -453,6 +460,12 @@ export const useActiveNagbars = (conditions: NagbarConditions): Array<NagbarStat
|
||||
visible: conditions.canShowDesktopUpdateReady,
|
||||
dismissible: true,
|
||||
},
|
||||
{
|
||||
type: NagbarType.DOMAIN_MOVED,
|
||||
priority: 3,
|
||||
visible: conditions.canShowDomainMoved,
|
||||
dismissible: true,
|
||||
},
|
||||
];
|
||||
return selectVisibleNagbars(nagbars);
|
||||
}, [conditions]);
|
||||
|
||||
@@ -25,6 +25,7 @@ export const NagbarType = {
|
||||
LINUX_INPUT_ACCESS: 'linux-input-access',
|
||||
SOFTWARE_ENCODER: 'software-encoder',
|
||||
STREAMER_MODE: 'streamer-mode',
|
||||
DOMAIN_MOVED: 'domain-moved',
|
||||
} as const;
|
||||
|
||||
export type NagbarType = ValueOf<typeof NagbarType>;
|
||||
@@ -63,4 +64,5 @@ export interface NagbarConditions {
|
||||
canShowLinuxInputAccess: boolean;
|
||||
canShowSoftwareEncoder: boolean;
|
||||
canShowStreamerMode: boolean;
|
||||
canShowDomainMoved: boolean;
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import {CorruptedInstallationNagbar} from '@app/features/app/components/layout/a
|
||||
import {DesktopDownloadNagbar} from '@app/features/app/components/layout/app_layout/nagbars/DesktopDownloadNagbar';
|
||||
import {DesktopNotificationNagbar} from '@app/features/app/components/layout/app_layout/nagbars/DesktopNotificationNagbar';
|
||||
import {DesktopUpdateReadyNagbar} from '@app/features/app/components/layout/app_layout/nagbars/DesktopUpdateReadyNagbar';
|
||||
import {DomainMovedNagbar} from '@app/features/app/components/layout/app_layout/nagbars/DomainMovedNagbar';
|
||||
import {EmailVerificationNagbar} from '@app/features/app/components/layout/app_layout/nagbars/EmailVerificationNagbar';
|
||||
import {GiftInventoryNagbar} from '@app/features/app/components/layout/app_layout/nagbars/GiftInventoryNagbar';
|
||||
import {GuildMembershipCtaNagbar} from '@app/features/app/components/layout/app_layout/nagbars/GuildMembershipCtaNagbar';
|
||||
@@ -230,6 +231,14 @@ export const NagbarContainer: React.FC<NagbarContainerProps> = observer(({nagbar
|
||||
data-flx="app.app-layout.nagbar-container.streamer-mode-nagbar"
|
||||
/>
|
||||
);
|
||||
case NagbarType.DOMAIN_MOVED:
|
||||
return (
|
||||
<DomainMovedNagbar
|
||||
key={nagbar.type}
|
||||
isMobile={mobileLayout.enabled}
|
||||
data-flx="app.app-layout.nagbar-container.domain-moved-nagbar"
|
||||
/>
|
||||
);
|
||||
default:
|
||||
throw new UnexpectedNagbarTypeError(nagbar.type);
|
||||
}
|
||||
|
||||
+176
@@ -0,0 +1,176 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {Routes} from '@app/app/Routes';
|
||||
import {Nagbar} from '@app/features/app/components/layout/Nagbar';
|
||||
import {NagbarButton} from '@app/features/app/components/layout/NagbarButton';
|
||||
import {NagbarContent} from '@app/features/app/components/layout/NagbarContent';
|
||||
import {NAGBAR_TONES, NagbarToneKind} from '@app/features/app/components/layout/NagbarTones';
|
||||
import {PRODUCT_NAME} from '@app/features/app/config/I18nDisplayConstants';
|
||||
import {
|
||||
installDomainMovedApp,
|
||||
openDomainMovedBrowserMigration,
|
||||
} from '@app/features/app/domain_migration/DomainMigrationBrowser';
|
||||
import type {DomainMigrationInstallKind} from '@app/features/app/domain_migration/DomainMigrationCore';
|
||||
import DomainMovedNotice from '@app/features/app/domain_migration/DomainMovedNotice';
|
||||
import {
|
||||
type DomainMovedStepsPlatform,
|
||||
showDomainMovedStepsModal,
|
||||
} from '@app/features/app/domain_migration/DomainMovedStepsModal';
|
||||
import * as RouterUtils from '@app/features/navigation/utils/RouterUtils';
|
||||
import {isIOSMobileOrTabletUserAgent} from '@app/features/platform/notifications/NotificationAlertOptions';
|
||||
import {msg} from '@lingui/core/macro';
|
||||
import {useLingui} from '@lingui/react/macro';
|
||||
import {observer} from 'mobx-react-lite';
|
||||
import {useCallback} from 'react';
|
||||
|
||||
type DomainMovedPresentation = 'install' | 'browser' | 'apple' | 'generic';
|
||||
|
||||
const INSTALL_MESSAGE_DESCRIPTOR = msg({
|
||||
message: '{productName} has moved to {host}. Install the new app and you will already be signed in.',
|
||||
comment:
|
||||
'Banner in an installed desktop web app after the account moved to the new domain. productName is the app name. host is the new domain, for example fluxer.com.',
|
||||
});
|
||||
const BROWSER_MESSAGE_DESCRIPTOR = msg({
|
||||
message: '{productName} has moved to {host}. Open it in your browser to install the new app.',
|
||||
comment:
|
||||
'Banner in an installed Android web app. productName is the app name. host is the new domain, for example fluxer.com.',
|
||||
});
|
||||
const APPLE_MESSAGE_DESCRIPTOR = msg({
|
||||
message: '{productName} has moved to {host}. Add it to your Home Screen or Dock, then sign in with this app.',
|
||||
comment:
|
||||
'Banner in a web app installed on iPhone, iPad or Mac. productName is the app name. host is the new domain, for example fluxer.com. Home Screen and Dock are Apple names.',
|
||||
});
|
||||
const GENERIC_MESSAGE_DESCRIPTOR = msg({
|
||||
message: '{productName} has moved to {host}. Install it from your browser, then sign in with this app.',
|
||||
comment:
|
||||
'Banner in an installed web app on other browsers. productName is the app name. host is the new domain, for example fluxer.com.',
|
||||
});
|
||||
const INSTALL_NEW_APP_DESCRIPTOR = msg({
|
||||
message: 'Install the new app',
|
||||
comment: 'Button on the domain moved banner that installs the app from the new domain.',
|
||||
});
|
||||
const OPEN_IN_BROWSER_DESCRIPTOR = msg({
|
||||
message: 'Open {productName} in your browser',
|
||||
comment: 'Button on the domain moved banner that opens the new domain in the browser. productName is the app name.',
|
||||
});
|
||||
const SHOW_ME_HOW_DESCRIPTOR = msg({
|
||||
message: 'Show me how',
|
||||
comment: 'Button on the domain moved banner that opens the install steps.',
|
||||
});
|
||||
const LINK_NEW_DEVICE_DESCRIPTOR = msg({
|
||||
message: 'Link a new device',
|
||||
comment:
|
||||
'Button on the domain moved banner that opens the code entry used to sign in a new app. Must match the translation used in the "Sign in with your old {productName} app" instructions.',
|
||||
});
|
||||
|
||||
const MESSAGE_DESCRIPTORS = {
|
||||
install: INSTALL_MESSAGE_DESCRIPTOR,
|
||||
browser: BROWSER_MESSAGE_DESCRIPTOR,
|
||||
apple: APPLE_MESSAGE_DESCRIPTOR,
|
||||
generic: GENERIC_MESSAGE_DESCRIPTOR,
|
||||
} as const;
|
||||
|
||||
function presentationFor(installKind: DomainMigrationInstallKind): DomainMovedPresentation {
|
||||
switch (installKind) {
|
||||
case 'chromium-desktop':
|
||||
return 'install';
|
||||
case 'chromium-android':
|
||||
return 'browser';
|
||||
case 'webkit':
|
||||
case 'none':
|
||||
return 'apple';
|
||||
case 'firefox':
|
||||
case 'other':
|
||||
return 'generic';
|
||||
}
|
||||
}
|
||||
|
||||
function stepsPlatform(presentation: DomainMovedPresentation): DomainMovedStepsPlatform {
|
||||
if (presentation !== 'apple') {
|
||||
return 'generic';
|
||||
}
|
||||
return isIOSMobileOrTabletUserAgent(navigator.userAgent, navigator.maxTouchPoints) ? 'ios' : 'mac';
|
||||
}
|
||||
|
||||
export const DomainMovedNagbar = observer(({isMobile}: {isMobile: boolean}) => {
|
||||
const {i18n} = useLingui();
|
||||
const target = DomainMovedNotice.target;
|
||||
const presentation = presentationFor(DomainMovedNotice.installKind);
|
||||
const handleDismiss = useCallback(() => {
|
||||
DomainMovedNotice.dismiss(Date.now());
|
||||
}, []);
|
||||
const handleInstall = useCallback(() => {
|
||||
installDomainMovedApp(target, () => showDomainMovedStepsModal(target, 'install'));
|
||||
}, [target]);
|
||||
const handleOpenInBrowser = useCallback(() => {
|
||||
openDomainMovedBrowserMigration(target);
|
||||
}, [target]);
|
||||
const handleShowSteps = useCallback(() => {
|
||||
showDomainMovedStepsModal(target, stepsPlatform(presentation));
|
||||
}, [presentation, target]);
|
||||
const handleLinkDevice = useCallback(() => {
|
||||
RouterUtils.transitionTo(`${Routes.LOGIN}?handoff=1`);
|
||||
}, []);
|
||||
const tone = NAGBAR_TONES[NagbarToneKind.BRAND];
|
||||
const values = {productName: PRODUCT_NAME, host: DomainMovedNotice.targetHost};
|
||||
const linkDeviceButton = (
|
||||
<NagbarButton
|
||||
isMobile={isMobile}
|
||||
variant="inverted-outline"
|
||||
onClick={handleLinkDevice}
|
||||
data-flx="app.app-layout.nagbars.domain-moved-nagbar.nagbar-button.link-device"
|
||||
>
|
||||
{i18n._(LINK_NEW_DEVICE_DESCRIPTOR)}
|
||||
</NagbarButton>
|
||||
);
|
||||
return (
|
||||
<Nagbar
|
||||
isMobile={isMobile}
|
||||
backgroundColor={tone.backgroundColor}
|
||||
textColor={tone.textColor}
|
||||
dismissible
|
||||
onDismiss={handleDismiss}
|
||||
data-flx="app.app-layout.nagbars.domain-moved-nagbar.nagbar"
|
||||
>
|
||||
<NagbarContent
|
||||
isMobile={isMobile}
|
||||
onDismiss={handleDismiss}
|
||||
message={i18n._(MESSAGE_DESCRIPTORS[presentation], values)}
|
||||
actions={
|
||||
presentation === 'install' ? (
|
||||
<NagbarButton
|
||||
isMobile={isMobile}
|
||||
onClick={handleInstall}
|
||||
data-flx="app.app-layout.nagbars.domain-moved-nagbar.nagbar-button.install"
|
||||
>
|
||||
{i18n._(INSTALL_NEW_APP_DESCRIPTOR)}
|
||||
</NagbarButton>
|
||||
) : presentation === 'browser' ? (
|
||||
<>
|
||||
{linkDeviceButton}
|
||||
<NagbarButton
|
||||
isMobile={isMobile}
|
||||
onClick={handleOpenInBrowser}
|
||||
data-flx="app.app-layout.nagbars.domain-moved-nagbar.nagbar-button.open-in-browser"
|
||||
>
|
||||
{i18n._(OPEN_IN_BROWSER_DESCRIPTOR, values)}
|
||||
</NagbarButton>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{linkDeviceButton}
|
||||
<NagbarButton
|
||||
isMobile={isMobile}
|
||||
onClick={handleShowSteps}
|
||||
data-flx="app.app-layout.nagbars.domain-moved-nagbar.nagbar-button.show-steps"
|
||||
>
|
||||
{i18n._(SHOW_ME_HOW_DESCRIPTOR)}
|
||||
</NagbarButton>
|
||||
</>
|
||||
)
|
||||
}
|
||||
data-flx="app.app-layout.nagbars.domain-moved-nagbar.nagbar-content"
|
||||
/>
|
||||
</Nagbar>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,818 @@
|
||||
// @vitest-environment happy-dom
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {
|
||||
detectDomainMigrationInstallKind,
|
||||
installDomainMovedApp,
|
||||
} from '@app/features/app/domain_migration/DomainMigrationBrowser';
|
||||
import * as core from '@app/features/app/domain_migration/DomainMigrationCore';
|
||||
import {
|
||||
decryptDomainMigrationPayload,
|
||||
encryptDomainMigrationPayload,
|
||||
} from '@app/features/app/domain_migration/DomainMigrationCrypto';
|
||||
import {runDomainMigrationPreMount} from '@app/features/app/domain_migration/DomainMigrationPreMount';
|
||||
import type {RuntimeConfigSnapshot} from '@app/features/app/state/RuntimeConfig';
|
||||
import type {StoredAccount} from '@app/features/auth/state/AccountStorage';
|
||||
import type {DomainMigrationDiscoveryResponse} from '@fluxer/schema/src/domains/admin/DomainMigrationSchemas';
|
||||
import {experimentBucket} from '@fluxer/schema/src/domains/experiment/ExperimentBucket';
|
||||
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
|
||||
|
||||
vi.mock('@app/features/platform/utils/AppLogger', () => ({
|
||||
Logger: class {
|
||||
debug = vi.fn();
|
||||
info = vi.fn();
|
||||
warn = vi.fn();
|
||||
error = vi.fn();
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@app/features/auth/state/AccountStorage', () => ({
|
||||
default: {getAllAccounts: async () => []},
|
||||
}));
|
||||
|
||||
const ENABLED_DISCOVERY: DomainMigrationDiscoveryResponse = {
|
||||
enabled: true,
|
||||
anonymous_rollout_basis_points: 0,
|
||||
rollout_salt: 'domain-migration-v1',
|
||||
standalone_forwarding: false,
|
||||
};
|
||||
|
||||
const NOW = 1_800_000_000_000;
|
||||
|
||||
function memoryStorage(initial: Record<string, string> = {}): core.StorageLike {
|
||||
const entries = new Map(Object.entries(initial));
|
||||
return {
|
||||
getItem: (key) => entries.get(key) ?? null,
|
||||
setItem: (key, value) => {
|
||||
entries.set(key, value);
|
||||
},
|
||||
removeItem: (key) => {
|
||||
entries.delete(key);
|
||||
},
|
||||
key: (index) => [...entries.keys()][index] ?? null,
|
||||
get length() {
|
||||
return entries.size;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function environment(
|
||||
installKind: core.DomainMigrationInstallKind,
|
||||
overrides: Partial<core.DomainMigrationEnvironment> = {},
|
||||
): core.DomainMigrationEnvironment {
|
||||
return {installKind, electron: false, electronMigrationVersion: null, ...overrides};
|
||||
}
|
||||
|
||||
function gateInput(overrides: Partial<core.DomainMigrationGateInput> = {}): core.DomainMigrationGateInput {
|
||||
return {
|
||||
environment: environment('none'),
|
||||
assignmentEnabled: true,
|
||||
discovery: ENABLED_DISCOVERY,
|
||||
marker: null,
|
||||
now: NOW,
|
||||
relatedOriginsSupported: true,
|
||||
voiceActive: false,
|
||||
oneShotRoute: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('sanitizeNextPath', () => {
|
||||
it.each([
|
||||
['/channels/123/456?x=1#y', '/channels/123/456?x=1#y'],
|
||||
['/reset#token=abc', '/reset#token=abc'],
|
||||
['/', '/app'],
|
||||
['/?a=1', '/app?a=1'],
|
||||
['//evil.example', '/channels/@me'],
|
||||
['/\\evil.example', '/channels/@me'],
|
||||
['/migrate/begin', '/channels/@me'],
|
||||
['/migrate', '/channels/@me'],
|
||||
['/\t/evil.example', '/channels/@me'],
|
||||
['/\n/evil.example', '/channels/@me'],
|
||||
['/\r/evil.example', '/channels/@me'],
|
||||
['/\tmigrate/done', '/channels/@me'],
|
||||
['/channels/\u0000', '/channels/@me'],
|
||||
['/a/../migrate/done', '/channels/@me'],
|
||||
['/%09/evil.example', '/%09/evil.example'],
|
||||
['https://evil.example/', '/channels/@me'],
|
||||
['channels/@me', '/channels/@me'],
|
||||
[null, '/channels/@me'],
|
||||
[42, '/channels/@me'],
|
||||
])('maps %j to %j', (input, expected) => {
|
||||
expect(core.sanitizeNextPath(input)).toBe(expected);
|
||||
});
|
||||
|
||||
it('never leaves the origin it is resolved against', () => {
|
||||
for (const input of ['/\t/evil.example', '/\n/evil.example', '/%09/evil.example', '/@evil.example']) {
|
||||
expect(new URL(core.sanitizeNextPath(input), 'https://web.fluxer.app/x').origin).toBe('https://web.fluxer.app');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveDomainMigrationSide', () => {
|
||||
it('recognises only the four official origins', () => {
|
||||
expect(core.resolveDomainMigrationSide('https://web.fluxer.app')).toEqual({
|
||||
role: 'source',
|
||||
source: 'https://web.fluxer.app',
|
||||
target: 'https://fluxer.com',
|
||||
});
|
||||
expect(core.resolveDomainMigrationSide('https://canary.fluxer.com')).toEqual({
|
||||
role: 'target',
|
||||
source: 'https://web.canary.fluxer.app',
|
||||
target: 'https://canary.fluxer.com',
|
||||
});
|
||||
for (const origin of [
|
||||
'http://localhost:3000',
|
||||
'https://chat.example.com',
|
||||
'http://web.fluxer.app',
|
||||
'https://fluxer.app',
|
||||
'https://web.fluxer.com',
|
||||
]) {
|
||||
expect(core.resolveDomainMigrationSide(origin)).toBeNull();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('payload encryption', () => {
|
||||
it('round trips through encrypt and decrypt', async () => {
|
||||
const payload = {
|
||||
version: 1,
|
||||
source_origin: 'https://web.fluxer.app',
|
||||
local_storage: {token: 'abc', big: 'x'.repeat(200_000)},
|
||||
};
|
||||
const sealed = await encryptDomainMigrationPayload(payload);
|
||||
expect(sealed.payload).toMatch(/^[A-Za-z0-9_-]+$/u);
|
||||
expect(sealed.key).toMatch(/^[A-Za-z0-9_-]{43}$/u);
|
||||
expect(sealed.payload.length).toBeLessThan(200_000);
|
||||
expect(await decryptDomainMigrationPayload(sealed.payload, sealed.key)).toEqual(payload);
|
||||
});
|
||||
|
||||
it('rejects a payload opened with another key', async () => {
|
||||
const sealed = await encryptDomainMigrationPayload({version: 1});
|
||||
const other = await encryptDomainMigrationPayload({version: 1});
|
||||
await expect(decryptDomainMigrationPayload(sealed.payload, other.key)).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('rejects payloads from another source or version', () => {
|
||||
const payload = {
|
||||
version: 1,
|
||||
source_origin: 'https://web.fluxer.app',
|
||||
exported_at: NOW,
|
||||
local_storage: {},
|
||||
accounts: [],
|
||||
notification_permission: 'granted',
|
||||
};
|
||||
expect(core.parseDomainMigrationPayload(payload, 'https://web.fluxer.app')).not.toBeNull();
|
||||
expect(core.parseDomainMigrationPayload(payload, 'https://web.canary.fluxer.app')).toBeNull();
|
||||
expect(core.parseDomainMigrationPayload({...payload, version: 2}, 'https://web.fluxer.app')).toBeNull();
|
||||
expect(
|
||||
core.parseDomainMigrationPayload({...payload, accounts: [{userId: 1}]}, 'https://web.fluxer.app'),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('accepts a theme library and rejects a malformed one', () => {
|
||||
const payload = {
|
||||
version: 1,
|
||||
source_origin: 'https://web.fluxer.app',
|
||||
exported_at: NOW,
|
||||
local_storage: {},
|
||||
accounts: [],
|
||||
notification_permission: 'default',
|
||||
};
|
||||
const themeLibrary: core.DomainMigrationThemeLibrary = {
|
||||
themes: [{id: 'quick-css', css: 'body{}'}],
|
||||
assets: [
|
||||
{
|
||||
id: 'asset',
|
||||
name: 'bg.png',
|
||||
mime_type: 'image/png',
|
||||
size: 3,
|
||||
data: 'AQID',
|
||||
created_at: NOW,
|
||||
updated_at: NOW,
|
||||
},
|
||||
],
|
||||
local_files: [],
|
||||
enabled_theme_ids: ['quick-css'],
|
||||
};
|
||||
expect(
|
||||
core.parseDomainMigrationPayload({...payload, theme_library: themeLibrary}, 'https://web.fluxer.app'),
|
||||
).not.toBeNull();
|
||||
expect(
|
||||
core.parseDomainMigrationPayload(
|
||||
{...payload, theme_library: {...themeLibrary, enabled_theme_ids: [1]}},
|
||||
'https://web.fluxer.app',
|
||||
),
|
||||
).toBeNull();
|
||||
const trimmed = core.withoutOptionalPayloadData({
|
||||
...payload,
|
||||
version: 1,
|
||||
custom_sounds: [],
|
||||
theme_library: themeLibrary,
|
||||
});
|
||||
expect(trimmed.custom_sounds).toBeUndefined();
|
||||
expect(trimmed.theme_library).toEqual({...themeLibrary, assets: []});
|
||||
});
|
||||
});
|
||||
|
||||
describe('rewriteImportedAccount', () => {
|
||||
it('points the account at the current instance and drops runtimeConfig', () => {
|
||||
const current = {apiEndpoint: 'https://fluxer.com/api'} as RuntimeConfigSnapshot;
|
||||
const record: StoredAccount = {
|
||||
userId: '1',
|
||||
token: 'token',
|
||||
localStorageData: {runtimeConfig: '{}', token: 'token'},
|
||||
managedStorageData: {runtimeConfig: '{}', token: 'token', 'fluxer.theme': 'dark'},
|
||||
lastActive: NOW,
|
||||
instance: {apiEndpoint: 'https://web.fluxer.app/api'} as RuntimeConfigSnapshot,
|
||||
};
|
||||
const rewritten = core.rewriteImportedAccount(record, current);
|
||||
expect(rewritten.instance).toBe(current);
|
||||
expect(rewritten.managedStorageData).toEqual({token: 'token', 'fluxer.theme': 'dark'});
|
||||
expect(rewritten.localStorageData).toEqual({token: 'token', 'fluxer.theme': 'dark'});
|
||||
expect(rewritten.token).toBe('token');
|
||||
});
|
||||
});
|
||||
|
||||
describe('local storage export', () => {
|
||||
it('skips push, test, runtime config and migration keys', () => {
|
||||
const storage = memoryStorage({
|
||||
token: 't',
|
||||
'fluxer.lastPushEndpoint': 'https://push',
|
||||
'fluxer.pushSubscription': '{}',
|
||||
__test__: '1',
|
||||
runtimeConfig: '{}',
|
||||
[core.DOMAIN_MIGRATION_MARKER_KEY]: '{}',
|
||||
[core.DOMAIN_MIGRATION_DEVICE_KEY]: 'device',
|
||||
'mobx-persist:Theme': '{}',
|
||||
});
|
||||
expect(core.collectExportableLocalStorage(storage)).toEqual({token: 't', 'mobx-persist:Theme': '{}'});
|
||||
});
|
||||
});
|
||||
|
||||
describe('migration gate', () => {
|
||||
it('passes when every condition holds', () => {
|
||||
expect(core.shouldStartDomainMigration(gateInput())).toBe(true);
|
||||
expect(
|
||||
core.shouldStartDomainMigration(
|
||||
gateInput({environment: environment('none', {electron: true, electronMigrationVersion: 1})}),
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it.each<[string, Partial<core.DomainMigrationGateInput>]>([
|
||||
['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}}],
|
||||
['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')}],
|
||||
['Apple web app', {environment: environment('webkit')}],
|
||||
['Firefox web app', {environment: environment('firefox')}],
|
||||
['other web app', {environment: environment('other')}],
|
||||
['old desktop', {environment: environment('none', {electron: true})}],
|
||||
['no related origins', {relatedOriginsSupported: false}],
|
||||
['in a voice call', {voiceActive: true}],
|
||||
['on a one-shot token route', {oneShotRoute: true}],
|
||||
])('blocks when %s', (_label, overrides) => {
|
||||
expect(core.shouldStartDomainMigration(gateInput(overrides))).toBe(false);
|
||||
});
|
||||
|
||||
it('runs the handoff inside a Chromium desktop web app', () => {
|
||||
expect(core.shouldStartDomainMigration(gateInput({environment: environment('chromium-desktop')}))).toBe(true);
|
||||
});
|
||||
|
||||
it('retries a failure after a day', () => {
|
||||
const marker: core.DomainMigrationMarker = {state: 'failed', at: NOW - 25 * 60 * 60 * 1000, attempts: 2};
|
||||
expect(core.shouldStartDomainMigration(gateInput({marker}))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('marker state', () => {
|
||||
it('counts failures and records completion', () => {
|
||||
const storage = memoryStorage();
|
||||
expect(core.readDomainMigrationMarker(storage)).toBeNull();
|
||||
core.markDomainMigrationFailed(storage, NOW);
|
||||
core.markDomainMigrationFailed(storage, NOW + 1);
|
||||
expect(core.readDomainMigrationMarker(storage)).toEqual({state: 'failed', at: NOW + 1, attempts: 2});
|
||||
core.markDomainMigrationCompleted(storage, 'https://fluxer.com', NOW + 2);
|
||||
expect(core.readDomainMigrationMarker(storage)).toEqual({
|
||||
state: 'completed',
|
||||
target: 'https://fluxer.com',
|
||||
at: NOW + 2,
|
||||
});
|
||||
core.markDomainMigrationFailed(storage, NOW + 3);
|
||||
expect(core.readDomainMigrationMarker(storage)).toEqual({state: 'failed', at: NOW + 3, attempts: 1});
|
||||
});
|
||||
|
||||
it('ignores malformed markers', () => {
|
||||
expect(core.parseDomainMigrationMarker('not json')).toBeNull();
|
||||
expect(core.parseDomainMigrationMarker('{"state":"completed","at":1}')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('migration intent', () => {
|
||||
it('expires and ties completion to the exported handoff', () => {
|
||||
const storage = memoryStorage();
|
||||
expect(core.readDomainMigrationIntent(storage, NOW)).toBeNull();
|
||||
core.writeDomainMigrationIntent(storage, {at: NOW});
|
||||
expect(core.readDomainMigrationIntent(storage, NOW + 1000)).toEqual({at: NOW});
|
||||
expect(core.readDomainMigrationIntent(storage, NOW + core.DOMAIN_MIGRATION_PENDING_MAX_AGE_MS + 1)).toBeNull();
|
||||
expect(core.intentConfirmsCompletion(null, null)).toBe(false);
|
||||
expect(core.intentConfirmsCompletion({at: NOW}, null)).toBe(true);
|
||||
expect(core.intentConfirmsCompletion({at: NOW, handoff_id: 'abc'}, 'abc')).toBe(true);
|
||||
expect(core.intentConfirmsCompletion({at: NOW, handoff_id: 'abc'}, 'xyz')).toBe(false);
|
||||
expect(core.intentConfirmsCompletion({at: NOW, handoff_id: 'abc'}, null)).toBe(false);
|
||||
core.clearDomainMigrationIntent(storage);
|
||||
expect(core.readDomainMigrationIntent(storage, NOW)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('forwarding', () => {
|
||||
it('keeps the path, query and hash', () => {
|
||||
expect(core.buildTargetUrl('https://fluxer.com', '/reset', '?a=1', '#token=abc')).toBe(
|
||||
'https://fluxer.com/reset?a=1#token=abc',
|
||||
);
|
||||
expect(core.buildTargetUrl('https://fluxer.com', '/', '', '')).toBe('https://fluxer.com/app');
|
||||
});
|
||||
|
||||
it('honours the kill switch', () => {
|
||||
const completed: core.DomainMigrationMarker = {state: 'completed', target: 'https://fluxer.com', at: NOW};
|
||||
const browser = environment('none');
|
||||
expect(core.shouldForwardCompletedSource(ENABLED_DISCOVERY, completed, browser)).toBe(true);
|
||||
expect(core.shouldForwardCompletedSource({...ENABLED_DISCOVERY, enabled: false}, completed, browser)).toBe(false);
|
||||
expect(core.shouldForwardCompletedSource(null, completed, browser)).toBe(false);
|
||||
expect(core.shouldForwardCompletedSource(ENABLED_DISCOVERY, null, browser)).toBe(false);
|
||||
});
|
||||
|
||||
it('forwards installed apps only when standalone forwarding is on', () => {
|
||||
const completed: core.DomainMigrationMarker = {state: 'completed', target: 'https://fluxer.com', at: NOW};
|
||||
const forwarding = {...ENABLED_DISCOVERY, standalone_forwarding: true};
|
||||
const desktop = environment('chromium-desktop');
|
||||
expect(core.shouldForwardCompletedSource(ENABLED_DISCOVERY, completed, desktop)).toBe(false);
|
||||
expect(core.shouldForwardCompletedSource(forwarding, completed, desktop)).toBe(true);
|
||||
const legacyDiscovery = {...ENABLED_DISCOVERY} as Partial<DomainMigrationDiscoveryResponse>;
|
||||
delete legacyDiscovery.standalone_forwarding;
|
||||
expect(
|
||||
core.shouldForwardCompletedSource(legacyDiscovery as DomainMigrationDiscoveryResponse, completed, desktop),
|
||||
).toBe(false);
|
||||
for (const kind of ['chromium-android', 'webkit', 'firefox', 'other'] as const) {
|
||||
expect(core.shouldForwardCompletedSource(forwarding, completed, environment(kind))).toBe(false);
|
||||
expect(core.environmentMayForward(environment(kind), forwarding)).toBe(false);
|
||||
}
|
||||
expect(core.environmentMayForward(environment('none'), ENABLED_DISCOVERY)).toBe(true);
|
||||
expect(core.environmentMayForward(environment('none', {electron: true}), ENABLED_DISCOVERY)).toBe(false);
|
||||
});
|
||||
|
||||
it('buckets anonymous devices by basis points', () => {
|
||||
expect(core.anonymousRolloutIsOpen(ENABLED_DISCOVERY)).toBe(false);
|
||||
expect(
|
||||
core.anonymousRolloutIsOpen({...ENABLED_DISCOVERY, enabled: false, anonymous_rollout_basis_points: 10000}),
|
||||
).toBe(false);
|
||||
const all = {...ENABLED_DISCOVERY, anonymous_rollout_basis_points: 10000};
|
||||
expect(core.anonymousRolloutIsOpen(all)).toBe(true);
|
||||
expect(core.deviceIsInAnonymousRollout(all, 'device-a')).toBe(true);
|
||||
const half = {...ENABLED_DISCOVERY, anonymous_rollout_basis_points: 5000};
|
||||
for (const deviceId of ['device-a', 'device-b', 'device-c', 'device-d']) {
|
||||
expect(core.deviceIsInAnonymousRollout(half, deviceId)).toBe(
|
||||
experimentBucket(deviceId, half.rollout_salt) < 5000,
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const CHROME_DESKTOP_UA =
|
||||
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36';
|
||||
const EDGE_DESKTOP_UA = `${CHROME_DESKTOP_UA} Edg/140.0.0.0`;
|
||||
const CHROME_ANDROID_UA =
|
||||
'Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Mobile Safari/537.36';
|
||||
const CHROME_ANDROID_TABLET_UA =
|
||||
'Mozilla/5.0 (Linux; Android 14; SM-X910) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36';
|
||||
const SAMSUNG_UA =
|
||||
'Mozilla/5.0 (Linux; Android 14; SM-S918B) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/27.0 Chrome/125.0.0.0 Mobile Safari/537.36';
|
||||
const IPHONE_UA =
|
||||
'Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Mobile/15E148 Safari/604.1';
|
||||
const IPAD_DESKTOP_UA =
|
||||
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Safari/605.1.15';
|
||||
const MAC_SAFARI_UA = IPAD_DESKTOP_UA;
|
||||
const MAC_CHROME_UA =
|
||||
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36';
|
||||
const FIREFOX_DESKTOP_UA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:143.0) Gecko/20100101 Firefox/143.0';
|
||||
const FIREFOX_ANDROID_UA = 'Mozilla/5.0 (Android 14; Mobile; rv:143.0) Gecko/143.0 Firefox/143.0';
|
||||
const UNKNOWN_UA = 'SomeBrowser/1.0';
|
||||
const CHROMIUM_BRANDS = [{brand: 'Chromium'}, {brand: 'Google Chrome'}, {brand: 'Not=A?Brand'}];
|
||||
|
||||
function signals(overrides: Partial<core.DomainMigrationInstallSignals>): core.DomainMigrationInstallSignals {
|
||||
return {
|
||||
displayMode: 'standalone',
|
||||
navigatorStandalone: false,
|
||||
userAgent: CHROME_DESKTOP_UA,
|
||||
userAgentData: null,
|
||||
maxTouchPoints: 0,
|
||||
electron: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('classifyDomainMigrationInstallKind', () => {
|
||||
it.each<[string, Partial<core.DomainMigrationInstallSignals>, core.DomainMigrationInstallKind]>([
|
||||
['a browser tab', {displayMode: 'browser'}, 'none'],
|
||||
['a Firefox taskbar tab', {displayMode: 'minimal-ui', userAgent: FIREFOX_DESKTOP_UA}, 'none'],
|
||||
['Electron', {electron: true}, 'none'],
|
||||
['Safari on iPhone in a tab', {displayMode: 'browser', userAgent: IPHONE_UA}, 'none'],
|
||||
['Chrome desktop by brand', {userAgentData: {brands: CHROMIUM_BRANDS, mobile: false}}, 'chromium-desktop'],
|
||||
['Chrome desktop by user agent', {}, 'chromium-desktop'],
|
||||
['Edge desktop', {userAgent: EDGE_DESKTOP_UA}, 'chromium-desktop'],
|
||||
['Chrome on a Mac', {userAgent: MAC_CHROME_UA}, 'chromium-desktop'],
|
||||
['window controls overlay', {displayMode: 'window-controls-overlay'}, 'chromium-desktop'],
|
||||
[
|
||||
'Chrome Android by brand',
|
||||
{userAgent: CHROME_ANDROID_UA, userAgentData: {brands: CHROMIUM_BRANDS, mobile: true}},
|
||||
'chromium-android',
|
||||
],
|
||||
['Chrome Android by user agent', {userAgent: CHROME_ANDROID_UA}, 'chromium-android'],
|
||||
[
|
||||
'Chrome on an Android tablet by brand',
|
||||
{
|
||||
userAgent: CHROME_ANDROID_TABLET_UA,
|
||||
userAgentData: {brands: CHROMIUM_BRANDS, mobile: false, platform: 'Android'},
|
||||
},
|
||||
'chromium-android',
|
||||
],
|
||||
['Chrome on an Android tablet by user agent', {userAgent: CHROME_ANDROID_TABLET_UA}, 'chromium-android'],
|
||||
['Samsung Internet', {userAgent: SAMSUNG_UA}, 'chromium-android'],
|
||||
['an iPhone home screen app', {displayMode: 'browser', navigatorStandalone: true, userAgent: IPHONE_UA}, 'webkit'],
|
||||
['an iPad home screen app', {userAgent: IPAD_DESKTOP_UA, maxTouchPoints: 5}, 'webkit'],
|
||||
['a Safari Dock app', {userAgent: MAC_SAFARI_UA}, 'webkit'],
|
||||
['a Firefox desktop app', {userAgent: FIREFOX_DESKTOP_UA}, 'firefox'],
|
||||
['a Firefox Android app', {userAgent: FIREFOX_ANDROID_UA}, 'firefox'],
|
||||
['an unknown browser', {userAgent: UNKNOWN_UA}, 'other'],
|
||||
])('classifies %s', (_label, overrides, expected) => {
|
||||
expect(core.classifyDomainMigrationInstallKind(signals(overrides))).toBe(expected);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it.each([CHROME_DESKTOP_UA, FIREFOX_DESKTOP_UA, MAC_SAFARI_UA])(
|
||||
'treats a full screen browser window as a tab',
|
||||
(userAgent) => {
|
||||
vi.stubGlobal('matchMedia', (query: string) => ({matches: query === '(display-mode: fullscreen)'}));
|
||||
vi.stubGlobal('navigator', {userAgent, maxTouchPoints: 0});
|
||||
expect(detectDomainMigrationInstallKind()).toBe('none');
|
||||
},
|
||||
);
|
||||
|
||||
it('reads an installed app window from the display mode', () => {
|
||||
vi.stubGlobal('matchMedia', (query: string) => ({matches: query === '(display-mode: standalone)'}));
|
||||
vi.stubGlobal('navigator', {userAgent: CHROME_DESKTOP_UA, maxTouchPoints: 0});
|
||||
expect(detectDomainMigrationInstallKind()).toBe('chromium-desktop');
|
||||
});
|
||||
});
|
||||
|
||||
describe('shouldShowDomainMovedNotice', () => {
|
||||
const source = core.resolveDomainMigrationSide('https://web.fluxer.app');
|
||||
const completed: core.DomainMigrationMarker = {state: 'completed', target: 'https://fluxer.com', at: NOW};
|
||||
|
||||
function notice(overrides: Partial<core.DomainMovedNoticeInput>): core.DomainMovedNoticeInput {
|
||||
return {
|
||||
side: source,
|
||||
installKind: 'webkit',
|
||||
discovery: ENABLED_DISCOVERY,
|
||||
assignmentEnabled: true,
|
||||
marker: null,
|
||||
dismissedAt: null,
|
||||
now: NOW,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
it('shows in installed apps on the source once the user is in the rollout', () => {
|
||||
for (const installKind of ['chromium-android', 'webkit', 'firefox', 'other'] as const) {
|
||||
expect(core.shouldShowDomainMovedNotice(notice({installKind}))).toBe(true);
|
||||
expect(core.shouldShowDomainMovedNotice(notice({installKind, assignmentEnabled: false}))).toBe(false);
|
||||
expect(core.shouldShowDomainMovedNotice(notice({installKind, assignmentEnabled: false, marker: completed}))).toBe(
|
||||
true,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('waits for the handoff in Chromium desktop apps', () => {
|
||||
expect(core.shouldShowDomainMovedNotice(notice({installKind: 'chromium-desktop'}))).toBe(false);
|
||||
expect(core.shouldShowDomainMovedNotice(notice({installKind: 'chromium-desktop', marker: completed}))).toBe(true);
|
||||
});
|
||||
|
||||
it.each<[string, Partial<core.DomainMovedNoticeInput>]>([
|
||||
['a browser tab', {installKind: 'none', marker: completed}],
|
||||
['the target', {side: core.resolveDomainMigrationSide('https://fluxer.com')}],
|
||||
['a self-hosted origin', {side: core.resolveDomainMigrationSide('https://chat.example.com')}],
|
||||
['the kill switch', {discovery: {...ENABLED_DISCOVERY, enabled: false}}],
|
||||
['missing discovery', {discovery: null}],
|
||||
])('stays hidden for %s', (_label, overrides) => {
|
||||
expect(core.shouldShowDomainMovedNotice(notice(overrides))).toBe(false);
|
||||
});
|
||||
|
||||
it('comes back seven days after a dismissal', () => {
|
||||
expect(core.shouldShowDomainMovedNotice(notice({dismissedAt: NOW - 1000}))).toBe(false);
|
||||
expect(
|
||||
core.shouldShowDomainMovedNotice(notice({dismissedAt: NOW - core.DOMAIN_MIGRATION_MOVED_DISMISS_MS + 1})),
|
||||
).toBe(false);
|
||||
expect(core.shouldShowDomainMovedNotice(notice({dismissedAt: NOW - core.DOMAIN_MIGRATION_MOVED_DISMISS_MS}))).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it('builds the new app links from the side target', () => {
|
||||
expect(core.domainMovedInstallUrl('https://canary.fluxer.com')).toBe('https://canary.fluxer.com/app');
|
||||
expect(core.domainMovedManifestId('https://fluxer.com')).toBe('https://fluxer.com/');
|
||||
expect(core.domainMovedBrowserMigrationUrl('https://fluxer.com')).toBe(
|
||||
'https://fluxer.com/migrate/begin?start=1&next=%2Fapp',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('installDomainMovedApp', () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('opens the new app in the browser straight from the click without the install API', () => {
|
||||
const open = vi.spyOn(window, 'open').mockReturnValue(null);
|
||||
const onUnavailable = vi.fn();
|
||||
vi.stubGlobal('navigator', {userAgent: CHROME_DESKTOP_UA});
|
||||
installDomainMovedApp('https://fluxer.com', onUnavailable);
|
||||
expect(open).toHaveBeenCalledWith('https://fluxer.com/app', '_blank', 'noopener');
|
||||
expect(onUnavailable).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('shows the fallback instead of a late popup when the install fails', async () => {
|
||||
const open = vi.spyOn(window, 'open').mockReturnValue(null);
|
||||
const onUnavailable = vi.fn();
|
||||
const install = vi.fn().mockRejectedValue(new DOMException('denied', 'NotAllowedError'));
|
||||
vi.stubGlobal('navigator', {userAgent: CHROME_DESKTOP_UA, install});
|
||||
installDomainMovedApp('https://fluxer.com', onUnavailable);
|
||||
await vi.waitFor(() => expect(onUnavailable).toHaveBeenCalledOnce());
|
||||
expect(install).toHaveBeenCalledWith('https://fluxer.com/app', 'https://fluxer.com/');
|
||||
expect(open).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does nothing more when the user cancels the install', async () => {
|
||||
const onUnavailable = vi.fn();
|
||||
const install = vi.fn().mockRejectedValue(new DOMException('cancelled', 'AbortError'));
|
||||
vi.stubGlobal('navigator', {userAgent: CHROME_DESKTOP_UA, install});
|
||||
installDomainMovedApp('https://fluxer.com', onUnavailable);
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
expect(onUnavailable).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('runDomainMigrationPreMount', () => {
|
||||
let replace: ReturnType<typeof vi.fn>;
|
||||
|
||||
function visit(url: string, discovery: DomainMigrationDiscoveryResponse | undefined): void {
|
||||
const parsed = new URL(url);
|
||||
replace = vi.fn();
|
||||
vi.stubGlobal('location', {
|
||||
origin: parsed.origin,
|
||||
pathname: parsed.pathname,
|
||||
search: parsed.search,
|
||||
hash: parsed.hash,
|
||||
replace,
|
||||
});
|
||||
(window as unknown as Record<string, unknown>).__FLUXER_BOOTSTRAP__ = {instance: {domain_migration: discovery}};
|
||||
}
|
||||
|
||||
function readMarker(): core.DomainMigrationMarker | null {
|
||||
return core.parseDomainMigrationMarker(window.localStorage.getItem(core.DOMAIN_MIGRATION_MARKER_KEY));
|
||||
}
|
||||
|
||||
function writeIntent(intent: core.DomainMigrationIntent): void {
|
||||
window.sessionStorage.setItem(core.DOMAIN_MIGRATION_INTENT_KEY, JSON.stringify(intent));
|
||||
}
|
||||
|
||||
function writeCompletedMarker(): void {
|
||||
window.localStorage.setItem(
|
||||
core.DOMAIN_MIGRATION_MARKER_KEY,
|
||||
JSON.stringify({state: 'completed', target: 'https://fluxer.com', at: NOW}),
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
window.localStorage.clear();
|
||||
window.sessionStorage.clear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('does nothing on a self-hosted origin', async () => {
|
||||
visit('https://chat.example.com/migrate/done?next=/channels/@me', ENABLED_DISCOVERY);
|
||||
writeCompletedMarker();
|
||||
expect(await runDomainMigrationPreMount()).toBe(false);
|
||||
expect(replace).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('forwards a migrated source tab with its hash', async () => {
|
||||
visit('https://web.fluxer.app/reset#token=abc', ENABLED_DISCOVERY);
|
||||
writeCompletedMarker();
|
||||
expect(await runDomainMigrationPreMount()).toBe(true);
|
||||
expect(replace).toHaveBeenCalledWith('https://fluxer.com/reset#token=abc');
|
||||
});
|
||||
|
||||
it('stays put when the kill switch is off', async () => {
|
||||
visit('https://web.fluxer.app/reset#token=abc', {...ENABLED_DISCOVERY, enabled: false});
|
||||
writeCompletedMarker();
|
||||
expect(await runDomainMigrationPreMount()).toBe(false);
|
||||
expect(replace).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('marks the source completed when the intent matches the handoff', async () => {
|
||||
writeIntent({at: Date.now(), handoff_id: 'handoff'});
|
||||
visit('https://web.fluxer.app/migrate/done?h=handoff&next=%2Fchannels%2F1%2F2', ENABLED_DISCOVERY);
|
||||
expect(await runDomainMigrationPreMount()).toBe(true);
|
||||
expect(replace).toHaveBeenCalledWith('https://fluxer.com/channels/1/2');
|
||||
expect(readMarker()).toMatchObject({state: 'completed', target: 'https://fluxer.com'});
|
||||
expect(window.sessionStorage.getItem(core.DOMAIN_MIGRATION_INTENT_KEY)).toBeNull();
|
||||
});
|
||||
|
||||
it('ignores a done link without a matching intent', async () => {
|
||||
visit('https://web.fluxer.app/migrate/done?next=%2Fchannels%2F1%2F2', ENABLED_DISCOVERY);
|
||||
expect(await runDomainMigrationPreMount()).toBe(true);
|
||||
expect(replace).toHaveBeenCalledWith('https://web.fluxer.app/channels/1/2');
|
||||
expect(readMarker()).toBeNull();
|
||||
|
||||
writeIntent({at: Date.now(), handoff_id: 'handoff'});
|
||||
visit('https://web.fluxer.app/migrate/done?h=other&next=%2Fchannels%2F1%2F2', ENABLED_DISCOVERY);
|
||||
expect(await runDomainMigrationPreMount()).toBe(true);
|
||||
expect(replace).toHaveBeenCalledWith('https://web.fluxer.app/channels/1/2');
|
||||
expect(readMarker()).toBeNull();
|
||||
});
|
||||
|
||||
it('ignores migrate links while the kill switch is off', async () => {
|
||||
writeIntent({at: Date.now()});
|
||||
visit('https://web.fluxer.app/migrate/done?next=%2Fchannels%2F1%2F2', {...ENABLED_DISCOVERY, enabled: false});
|
||||
expect(await runDomainMigrationPreMount()).toBe(true);
|
||||
expect(replace).toHaveBeenCalledWith('https://web.fluxer.app/channels/1/2');
|
||||
expect(readMarker()).toBeNull();
|
||||
});
|
||||
|
||||
it('refuses to export without an intent from the source trigger', async () => {
|
||||
const fetchSpy = vi.fn();
|
||||
vi.stubGlobal('fetch', fetchSpy);
|
||||
visit(`https://web.fluxer.app/migrate/export?n=${'a'.repeat(43)}`, ENABLED_DISCOVERY);
|
||||
expect(await runDomainMigrationPreMount()).toBe(true);
|
||||
expect(replace).toHaveBeenCalledWith('https://web.fluxer.app/channels/@me');
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('leaves the marker alone on a failed link and refuses an open redirect', async () => {
|
||||
visit('https://web.fluxer.app/migrate/failed?reason=nonce_mismatch&next=%2F%09%2Fevil.example', ENABLED_DISCOVERY);
|
||||
expect(await runDomainMigrationPreMount()).toBe(true);
|
||||
expect(replace).toHaveBeenCalledWith('https://web.fluxer.app/channels/@me');
|
||||
expect(readMarker()).toBeNull();
|
||||
});
|
||||
|
||||
it('reopens a completed source when a resumed migration fails', async () => {
|
||||
writeCompletedMarker();
|
||||
writeIntent({at: Date.now()});
|
||||
visit('https://web.fluxer.app/migrate/failed?reason=redeem_failed&next=%2Fchannels%2F%40me', ENABLED_DISCOVERY);
|
||||
expect(await runDomainMigrationPreMount()).toBe(true);
|
||||
expect(replace).toHaveBeenCalledWith('https://web.fluxer.app/channels/@me');
|
||||
expect(readMarker()).toMatchObject({state: 'failed', attempts: 1});
|
||||
});
|
||||
|
||||
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);
|
||||
expect(await runDomainMigrationPreMount()).toBe(true);
|
||||
expect(replace).toHaveBeenCalledWith(
|
||||
`https://fluxer.com/migrate/begin?resume=1&next=${encodeURIComponent('/reset#token=abc')}`,
|
||||
);
|
||||
expect(core.readDomainMigrationIntent(window.sessionStorage, Date.now())).not.toBeNull();
|
||||
});
|
||||
|
||||
function installApp(userAgent: string): void {
|
||||
vi.stubGlobal('matchMedia', (query: string) => ({matches: query === '(display-mode: standalone)'}));
|
||||
vi.stubGlobal('navigator', {userAgent, maxTouchPoints: 0});
|
||||
}
|
||||
|
||||
it('returns a Chromium desktop app to the source after the handoff', async () => {
|
||||
installApp(CHROME_DESKTOP_UA);
|
||||
writeIntent({at: Date.now(), handoff_id: 'handoff'});
|
||||
visit('https://web.fluxer.app/migrate/done?h=handoff&next=%2Fchannels%2F1%2F2', ENABLED_DISCOVERY);
|
||||
expect(await runDomainMigrationPreMount()).toBe(true);
|
||||
expect(replace).toHaveBeenCalledWith('https://web.fluxer.app/channels/1/2');
|
||||
expect(readMarker()).toMatchObject({state: 'completed', target: 'https://fluxer.com'});
|
||||
});
|
||||
|
||||
it('forwards a Chromium desktop app when standalone forwarding is on', async () => {
|
||||
installApp(CHROME_DESKTOP_UA);
|
||||
writeIntent({at: Date.now(), handoff_id: 'handoff'});
|
||||
visit('https://web.fluxer.app/migrate/done?h=handoff&next=%2Fchannels%2F1%2F2', {
|
||||
...ENABLED_DISCOVERY,
|
||||
standalone_forwarding: true,
|
||||
});
|
||||
expect(await runDomainMigrationPreMount()).toBe(true);
|
||||
expect(replace).toHaveBeenCalledWith('https://fluxer.com/channels/1/2');
|
||||
});
|
||||
|
||||
it('keeps a migrated Chromium desktop app on the source', async () => {
|
||||
installApp(CHROME_DESKTOP_UA);
|
||||
writeCompletedMarker();
|
||||
visit('https://web.fluxer.app/channels/1/2', {...ENABLED_DISCOVERY, anonymous_rollout_basis_points: 10000});
|
||||
expect(await runDomainMigrationPreMount()).toBe(false);
|
||||
expect(replace).not.toHaveBeenCalled();
|
||||
|
||||
visit('https://web.fluxer.app/channels/1/2', {...ENABLED_DISCOVERY, standalone_forwarding: true});
|
||||
expect(await runDomainMigrationPreMount()).toBe(true);
|
||||
expect(replace).toHaveBeenCalledWith('https://fluxer.com/channels/1/2');
|
||||
});
|
||||
|
||||
it('does not forward logged-out installed apps in the anonymous rollout', async () => {
|
||||
installApp(CHROME_DESKTOP_UA);
|
||||
visit('https://web.fluxer.app/login', {...ENABLED_DISCOVERY, anonymous_rollout_basis_points: 10000});
|
||||
expect(await runDomainMigrationPreMount()).toBe(false);
|
||||
expect(replace).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('never runs the handoff or forwards in an Apple web app', async () => {
|
||||
installApp(IPHONE_UA);
|
||||
writeCompletedMarker();
|
||||
visit('https://web.fluxer.app/channels/1/2', {...ENABLED_DISCOVERY, standalone_forwarding: true});
|
||||
expect(await runDomainMigrationPreMount()).toBe(false);
|
||||
expect(replace).not.toHaveBeenCalled();
|
||||
|
||||
window.localStorage.clear();
|
||||
writeIntent({at: Date.now(), handoff_id: 'handoff'});
|
||||
visit('https://web.fluxer.app/migrate/done?h=handoff&next=%2Fchannels%2F1%2F2', ENABLED_DISCOVERY);
|
||||
expect(await runDomainMigrationPreMount()).toBe(true);
|
||||
expect(replace).toHaveBeenCalledWith('https://web.fluxer.app/channels/1/2');
|
||||
expect(readMarker()).toBeNull();
|
||||
});
|
||||
|
||||
it('never runs the handoff in an Android web app', async () => {
|
||||
installApp(CHROME_ANDROID_UA);
|
||||
writeIntent({at: Date.now()});
|
||||
visit('https://web.fluxer.app/migrate/done?next=%2Fchannels%2F1%2F2', ENABLED_DISCOVERY);
|
||||
expect(await runDomainMigrationPreMount()).toBe(true);
|
||||
expect(replace).toHaveBeenCalledWith('https://web.fluxer.app/channels/1/2');
|
||||
expect(readMarker()).toBeNull();
|
||||
});
|
||||
|
||||
it('starts a browser migration opened from an installed Android app on the source', async () => {
|
||||
visit('https://fluxer.com/migrate/begin?start=1&next=%2Fapp', ENABLED_DISCOVERY);
|
||||
expect(await runDomainMigrationPreMount()).toBe(true);
|
||||
expect(replace).toHaveBeenCalledWith('https://web.fluxer.app/migrate/start?next=%2Fapp');
|
||||
expect(window.sessionStorage.getItem(core.DOMAIN_MIGRATION_PENDING_KEY)).toBeNull();
|
||||
|
||||
visit('https://web.fluxer.app/migrate/start?next=%2Fapp', ENABLED_DISCOVERY);
|
||||
expect(await runDomainMigrationPreMount()).toBe(true);
|
||||
expect(replace).toHaveBeenCalledWith('https://fluxer.com/migrate/begin?next=%2Fapp');
|
||||
expect(core.readDomainMigrationIntent(window.sessionStorage, Date.now())).toMatchObject({at: expect.any(Number)});
|
||||
|
||||
visit('https://fluxer.com/migrate/begin?next=%2Fapp', ENABLED_DISCOVERY);
|
||||
expect(await runDomainMigrationPreMount()).toBe(true);
|
||||
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 () => {
|
||||
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');
|
||||
});
|
||||
|
||||
it('does not start a migration inside an installed Android app', async () => {
|
||||
installApp(CHROME_ANDROID_UA);
|
||||
visit('https://web.fluxer.app/migrate/start?next=%2Fapp', ENABLED_DISCOVERY);
|
||||
expect(await runDomainMigrationPreMount()).toBe(true);
|
||||
expect(replace).toHaveBeenCalledWith('https://web.fluxer.app/app');
|
||||
expect(core.readDomainMigrationIntent(window.sessionStorage, Date.now())).toBeNull();
|
||||
});
|
||||
|
||||
it('sends a target completion without a pending nonce back as failed', async () => {
|
||||
visit('https://fluxer.com/migrate/complete#h=abc&k=def', ENABLED_DISCOVERY);
|
||||
vi.stubGlobal('history', {state: null, replaceState: vi.fn()});
|
||||
expect(await runDomainMigrationPreMount()).toBe(true);
|
||||
expect(replace).toHaveBeenCalledWith(
|
||||
'https://web.fluxer.app/migrate/failed?reason=no_pending&next=%2Fchannels%2F%40me',
|
||||
);
|
||||
});
|
||||
|
||||
it('reports back to the source when the target 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');
|
||||
|
||||
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');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,127 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {
|
||||
classifyDomainMigrationInstallKind,
|
||||
type DomainMigrationDisplayMode,
|
||||
type DomainMigrationEnvironment,
|
||||
type DomainMigrationInstallKind,
|
||||
domainMovedBrowserMigrationUrl,
|
||||
domainMovedInstallUrl,
|
||||
domainMovedManifestId,
|
||||
} 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 type {DomainMigrationDiscoveryResponse} from '@fluxer/schema/src/domains/admin/DomainMigrationSchemas';
|
||||
|
||||
interface NavigatorWithStandalone extends Navigator {
|
||||
standalone?: boolean;
|
||||
}
|
||||
|
||||
const DISPLAY_MODES: ReadonlyArray<DomainMigrationDisplayMode> = [
|
||||
'window-controls-overlay',
|
||||
'standalone',
|
||||
'minimal-ui',
|
||||
];
|
||||
|
||||
interface PublicKeyCredentialWithCapabilities {
|
||||
getClientCapabilities?: () => Promise<Record<string, boolean | undefined>>;
|
||||
}
|
||||
|
||||
export function readDomainMigrationDiscovery(): DomainMigrationDiscoveryResponse | null {
|
||||
return window.__FLUXER_BOOTSTRAP__?.instance.domain_migration ?? null;
|
||||
}
|
||||
|
||||
function readDisplayMode(): DomainMigrationDisplayMode {
|
||||
for (const mode of DISPLAY_MODES) {
|
||||
if (window.matchMedia?.(`(display-mode: ${mode})`).matches) {
|
||||
return mode;
|
||||
}
|
||||
}
|
||||
return 'browser';
|
||||
}
|
||||
|
||||
function isElectronEnvironment(): boolean {
|
||||
return isElectron() || hasUnavailableElectronNativeContext();
|
||||
}
|
||||
|
||||
export function detectDomainMigrationInstallKind(): DomainMigrationInstallKind {
|
||||
if (typeof window === 'undefined') {
|
||||
return 'none';
|
||||
}
|
||||
const navigator = window.navigator as NavigatorWithStandalone;
|
||||
return classifyDomainMigrationInstallKind({
|
||||
displayMode: readDisplayMode(),
|
||||
navigatorStandalone: navigator.standalone === true,
|
||||
userAgent: navigator.userAgent,
|
||||
userAgentData: navigator.userAgentData ?? null,
|
||||
maxTouchPoints: navigator.maxTouchPoints ?? 0,
|
||||
electron: isElectronEnvironment(),
|
||||
});
|
||||
}
|
||||
|
||||
export function readDomainMigrationEnvironment(): DomainMigrationEnvironment {
|
||||
return {
|
||||
installKind: detectDomainMigrationInstallKind(),
|
||||
electron: isElectronEnvironment(),
|
||||
electronMigrationVersion: window.electron?.domainMigration?.version ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
export async function browserSupportsRelatedOrigins(): Promise<boolean> {
|
||||
if (typeof PublicKeyCredential === 'undefined') {
|
||||
return false;
|
||||
}
|
||||
const credential = PublicKeyCredential as unknown as PublicKeyCredentialWithCapabilities;
|
||||
if (typeof credential.getClientCapabilities !== 'function') {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
const capabilities = await credential.getClientCapabilities();
|
||||
return capabilities.relatedOrigins === true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function readActiveSessionToken(): string | null {
|
||||
try {
|
||||
return parseStoredSessionValue(getProtectedLocalStorage()?.getItem(AuthSessionStorageKey.Token) ?? null);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function hasStoredAccount(): Promise<boolean> {
|
||||
if (readActiveSessionToken() !== null) {
|
||||
return true;
|
||||
}
|
||||
const {default: accountStorage} = await import('@app/features/auth/state/AccountStorage');
|
||||
const accounts = await accountStorage.getAllAccounts();
|
||||
return accounts.some((account) => Boolean(account.token));
|
||||
}
|
||||
|
||||
function openInBrowser(url: string): void {
|
||||
window.open(url, '_blank', 'noopener');
|
||||
}
|
||||
|
||||
export function installDomainMovedApp(target: string, onUnavailable: () => void): void {
|
||||
const installUrl = domainMovedInstallUrl(target);
|
||||
if (typeof navigator.install !== 'function') {
|
||||
openInBrowser(installUrl);
|
||||
return;
|
||||
}
|
||||
navigator.install(installUrl, domainMovedManifestId(target)).catch((err: unknown) => {
|
||||
if (err instanceof DOMException && err.name === 'AbortError') {
|
||||
return;
|
||||
}
|
||||
onUnavailable();
|
||||
});
|
||||
}
|
||||
|
||||
export function openDomainMovedBrowserMigration(target: string): void {
|
||||
openInBrowser(domainMovedBrowserMigrationUrl(target));
|
||||
}
|
||||
@@ -0,0 +1,523 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import type {RuntimeConfigSnapshot} from '@app/features/app/state/RuntimeConfig';
|
||||
import type {StoredAccount} from '@app/features/auth/state/AccountStorage';
|
||||
import {isIOSMobileOrTabletUserAgent} from '@app/features/platform/notifications/NotificationAlertOptions';
|
||||
import type {DomainMigrationDiscoveryResponse} from '@fluxer/schema/src/domains/admin/DomainMigrationSchemas';
|
||||
import {experimentBucket} from '@fluxer/schema/src/domains/experiment/ExperimentBucket';
|
||||
|
||||
export const DOMAIN_MIGRATION_SOURCE_TO_TARGET: Readonly<Record<string, string>> = {
|
||||
'https://web.fluxer.app': 'https://fluxer.com',
|
||||
'https://web.canary.fluxer.app': 'https://canary.fluxer.com',
|
||||
};
|
||||
|
||||
export const DOMAIN_MIGRATION_TARGET_TO_SOURCE: Readonly<Record<string, string>> = Object.fromEntries(
|
||||
Object.entries(DOMAIN_MIGRATION_SOURCE_TO_TARGET).map(([source, target]) => [target, source]),
|
||||
);
|
||||
|
||||
export const DOMAIN_MIGRATION_MARKER_KEY = 'fluxer:domain-migration';
|
||||
export const DOMAIN_MIGRATION_DEVICE_KEY = 'fluxer:domain-migration:device';
|
||||
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_PAYLOAD_VERSION = 1;
|
||||
export const DOMAIN_MIGRATION_DEFAULT_NEXT_PATH = '/channels/@me';
|
||||
export const DOMAIN_MIGRATION_MAX_FAILED_ATTEMPTS = 3;
|
||||
export const DOMAIN_MIGRATION_FAILED_RETRY_DELAY_MS = 24 * 60 * 60 * 1000;
|
||||
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;
|
||||
|
||||
const NEXT_PATH_BASE = 'https://next.invalid';
|
||||
|
||||
function hasUnsafeNextPathCharacter(value: string): boolean {
|
||||
for (let index = 0; index < value.length; index++) {
|
||||
const code = value.charCodeAt(index);
|
||||
if (code <= 0x1f || code === 0x7f || code === 0x5c) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
const DENIED_LOCAL_STORAGE_KEYS: ReadonlySet<string> = new Set([
|
||||
'fluxer.lastPushEndpoint',
|
||||
'__test__',
|
||||
'runtimeConfig',
|
||||
]);
|
||||
const PUSH_SUBSCRIPTION_KEY_PATTERN = /push[-_.:]?(?:subscription|endpoint)/iu;
|
||||
|
||||
export interface StorageLike {
|
||||
getItem(key: string): string | null;
|
||||
setItem(key: string, value: string): void;
|
||||
removeItem(key: string): void;
|
||||
key(index: number): string | null;
|
||||
readonly length: number;
|
||||
}
|
||||
|
||||
export type DomainMigrationSide =
|
||||
| {role: 'source'; source: string; target: string}
|
||||
| {role: 'target'; source: string; target: string};
|
||||
|
||||
export function resolveDomainMigrationSide(origin: string): DomainMigrationSide | null {
|
||||
const target = DOMAIN_MIGRATION_SOURCE_TO_TARGET[origin];
|
||||
if (target !== undefined) {
|
||||
return {role: 'source', source: origin, target};
|
||||
}
|
||||
const source = DOMAIN_MIGRATION_TARGET_TO_SOURCE[origin];
|
||||
if (source !== undefined) {
|
||||
return {role: 'target', source, target: origin};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function sanitizeNextPath(value: unknown): string {
|
||||
if (typeof value !== 'string' || !value.startsWith('/') || hasUnsafeNextPathCharacter(value)) {
|
||||
return DOMAIN_MIGRATION_DEFAULT_NEXT_PATH;
|
||||
}
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(value, NEXT_PATH_BASE);
|
||||
} catch {
|
||||
return DOMAIN_MIGRATION_DEFAULT_NEXT_PATH;
|
||||
}
|
||||
if (url.origin !== NEXT_PATH_BASE || url.pathname.startsWith('/migrate')) {
|
||||
return DOMAIN_MIGRATION_DEFAULT_NEXT_PATH;
|
||||
}
|
||||
const pathname = url.pathname === '/' ? '/app' : url.pathname;
|
||||
return `${pathname}${url.search}${url.hash}`;
|
||||
}
|
||||
|
||||
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: 'failed'; at: number; attempts: number};
|
||||
|
||||
export function parseDomainMigrationMarker(raw: string | null): DomainMigrationMarker | null {
|
||||
if (!raw) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const value = JSON.parse(raw) as Record<string, unknown>;
|
||||
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 === 'failed') {
|
||||
const attempts = typeof value.attempts === 'number' && value.attempts > 0 ? value.attempts : 1;
|
||||
return {state: 'failed', at: value.at, attempts};
|
||||
}
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function readDomainMigrationMarker(storage: StorageLike | null): DomainMigrationMarker | null {
|
||||
try {
|
||||
return parseDomainMigrationMarker(storage?.getItem(DOMAIN_MIGRATION_MARKER_KEY) ?? null);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function writeDomainMigrationMarker(storage: StorageLike | null, marker: DomainMigrationMarker): void {
|
||||
try {
|
||||
storage?.setItem(DOMAIN_MIGRATION_MARKER_KEY, JSON.stringify(marker));
|
||||
} catch {}
|
||||
}
|
||||
|
||||
export function markDomainMigrationCompleted(storage: StorageLike | null, target: string, now: number): void {
|
||||
writeDomainMigrationMarker(storage, {state: 'completed', target, at: now});
|
||||
}
|
||||
|
||||
export function markDomainMigrationFailed(storage: StorageLike | null, now: number): void {
|
||||
const previous = readDomainMigrationMarker(storage);
|
||||
const attempts = previous?.state === 'failed' ? previous.attempts + 1 : 1;
|
||||
writeDomainMigrationMarker(storage, {state: 'failed', at: now, attempts});
|
||||
}
|
||||
|
||||
export function markerAllowsDomainMigration(marker: DomainMigrationMarker | null, now: number): boolean {
|
||||
if (marker === null) {
|
||||
return true;
|
||||
}
|
||||
if (marker.state === 'completed') {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
marker.attempts < DOMAIN_MIGRATION_MAX_FAILED_ATTEMPTS && now - marker.at >= DOMAIN_MIGRATION_FAILED_RETRY_DELAY_MS
|
||||
);
|
||||
}
|
||||
|
||||
export interface DomainMigrationIntent {
|
||||
at: number;
|
||||
handoff_id?: string;
|
||||
}
|
||||
|
||||
export function readDomainMigrationIntent(storage: StorageLike | null, now: number): DomainMigrationIntent | null {
|
||||
try {
|
||||
const raw = storage?.getItem(DOMAIN_MIGRATION_INTENT_KEY) ?? null;
|
||||
if (!raw) {
|
||||
return null;
|
||||
}
|
||||
const value = JSON.parse(raw) as unknown;
|
||||
if (!isRecord(value) || typeof value.at !== 'number' || now - value.at > DOMAIN_MIGRATION_PENDING_MAX_AGE_MS) {
|
||||
return null;
|
||||
}
|
||||
return typeof value.handoff_id === 'string' ? {at: value.at, handoff_id: value.handoff_id} : {at: value.at};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function writeDomainMigrationIntent(storage: StorageLike | null, intent: DomainMigrationIntent): void {
|
||||
storage?.setItem(DOMAIN_MIGRATION_INTENT_KEY, JSON.stringify(intent));
|
||||
}
|
||||
|
||||
export function clearDomainMigrationIntent(storage: StorageLike | null): void {
|
||||
try {
|
||||
storage?.removeItem(DOMAIN_MIGRATION_INTENT_KEY);
|
||||
} catch {}
|
||||
}
|
||||
|
||||
export function intentConfirmsCompletion(intent: DomainMigrationIntent | null, handoffId: string | null): boolean {
|
||||
if (intent === null) {
|
||||
return false;
|
||||
}
|
||||
return intent.handoff_id === undefined || intent.handoff_id === handoffId;
|
||||
}
|
||||
|
||||
export type DomainMigrationInstallKind =
|
||||
| 'none'
|
||||
| 'chromium-desktop'
|
||||
| 'chromium-android'
|
||||
| 'webkit'
|
||||
| 'firefox'
|
||||
| 'other';
|
||||
|
||||
export type DomainMigrationDisplayMode = 'browser' | 'minimal-ui' | 'standalone' | 'window-controls-overlay';
|
||||
|
||||
export interface DomainMigrationInstallSignals {
|
||||
displayMode: DomainMigrationDisplayMode;
|
||||
navigatorStandalone: boolean;
|
||||
userAgent: string;
|
||||
userAgentData: {brands?: ReadonlyArray<{brand: string}>; mobile?: boolean; platform?: string} | null;
|
||||
maxTouchPoints: number;
|
||||
electron: boolean;
|
||||
}
|
||||
|
||||
const INSTALLED_DISPLAY_MODES: ReadonlySet<DomainMigrationDisplayMode> = new Set([
|
||||
'standalone',
|
||||
'window-controls-overlay',
|
||||
]);
|
||||
const CHROMIUM_USER_AGENT_PATTERN = /\b(?:Chrome|Chromium|CriOS|EdgA|Edg|OPR|SamsungBrowser)\//u;
|
||||
const ANDROID_USER_AGENT_PATTERN = /\bAndroid\b/u;
|
||||
|
||||
export function classifyDomainMigrationInstallKind(signals: DomainMigrationInstallSignals): DomainMigrationInstallKind {
|
||||
if (signals.electron) {
|
||||
return 'none';
|
||||
}
|
||||
const installed = signals.navigatorStandalone || INSTALLED_DISPLAY_MODES.has(signals.displayMode);
|
||||
if (!installed) {
|
||||
return 'none';
|
||||
}
|
||||
const {userAgent} = signals;
|
||||
if (isIOSMobileOrTabletUserAgent(userAgent, signals.maxTouchPoints)) {
|
||||
return 'webkit';
|
||||
}
|
||||
const android =
|
||||
signals.userAgentData?.platform === 'Android' ||
|
||||
signals.userAgentData?.mobile === true ||
|
||||
ANDROID_USER_AGENT_PATTERN.test(userAgent);
|
||||
if (signals.userAgentData?.brands?.some((entry) => entry.brand === 'Chromium')) {
|
||||
return android ? 'chromium-android' : 'chromium-desktop';
|
||||
}
|
||||
if (/\bFirefox\//u.test(userAgent)) {
|
||||
return 'firefox';
|
||||
}
|
||||
if (CHROMIUM_USER_AGENT_PATTERN.test(userAgent)) {
|
||||
return android ? 'chromium-android' : 'chromium-desktop';
|
||||
}
|
||||
if (/\bMacintosh\b/u.test(userAgent) && /\bSafari\//u.test(userAgent)) {
|
||||
return 'webkit';
|
||||
}
|
||||
return 'other';
|
||||
}
|
||||
|
||||
export interface DomainMigrationEnvironment {
|
||||
installKind: DomainMigrationInstallKind;
|
||||
electron: boolean;
|
||||
electronMigrationVersion: number | null;
|
||||
}
|
||||
|
||||
export function environmentAllowsDomainMigration(environment: DomainMigrationEnvironment): boolean {
|
||||
if (environment.installKind !== 'none' && environment.installKind !== 'chromium-desktop') {
|
||||
return false;
|
||||
}
|
||||
if (environment.electron) {
|
||||
return environment.electronMigrationVersion !== null && environment.electronMigrationVersion >= 1;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export function environmentMayForward(
|
||||
environment: DomainMigrationEnvironment,
|
||||
discovery: DomainMigrationDiscoveryResponse | null,
|
||||
): boolean {
|
||||
if (!environmentAllowsDomainMigration(environment)) {
|
||||
return false;
|
||||
}
|
||||
return environment.installKind === 'none' || discovery?.standalone_forwarding === true;
|
||||
}
|
||||
|
||||
export interface DomainMigrationGateInput {
|
||||
environment: DomainMigrationEnvironment;
|
||||
assignmentEnabled: boolean;
|
||||
discovery: DomainMigrationDiscoveryResponse | null;
|
||||
marker: DomainMigrationMarker | null;
|
||||
now: number;
|
||||
relatedOriginsSupported: boolean;
|
||||
voiceActive: boolean;
|
||||
oneShotRoute: boolean;
|
||||
}
|
||||
|
||||
export function shouldStartDomainMigration(input: DomainMigrationGateInput): boolean {
|
||||
return (
|
||||
input.assignmentEnabled &&
|
||||
input.discovery?.enabled === true &&
|
||||
markerAllowsDomainMigration(input.marker, input.now) &&
|
||||
environmentAllowsDomainMigration(input.environment) &&
|
||||
input.relatedOriginsSupported &&
|
||||
!input.voiceActive &&
|
||||
!input.oneShotRoute
|
||||
);
|
||||
}
|
||||
|
||||
export function shouldForwardCompletedSource(
|
||||
discovery: DomainMigrationDiscoveryResponse | null,
|
||||
marker: DomainMigrationMarker | null,
|
||||
environment: DomainMigrationEnvironment,
|
||||
): boolean {
|
||||
return discovery?.enabled === true && marker?.state === 'completed' && environmentMayForward(environment, discovery);
|
||||
}
|
||||
|
||||
export interface DomainMovedNoticeInput {
|
||||
side: DomainMigrationSide | null;
|
||||
installKind: DomainMigrationInstallKind;
|
||||
discovery: DomainMigrationDiscoveryResponse | null;
|
||||
assignmentEnabled: boolean;
|
||||
marker: DomainMigrationMarker | null;
|
||||
dismissedAt: number | null;
|
||||
now: number;
|
||||
}
|
||||
|
||||
export function shouldShowDomainMovedNotice(input: DomainMovedNoticeInput): boolean {
|
||||
if (input.side?.role !== 'source' || input.installKind === 'none' || input.discovery?.enabled !== true) {
|
||||
return false;
|
||||
}
|
||||
if (input.dismissedAt !== null && input.now - input.dismissedAt < DOMAIN_MIGRATION_MOVED_DISMISS_MS) {
|
||||
return false;
|
||||
}
|
||||
const completed = input.marker?.state === 'completed';
|
||||
if (input.installKind === 'chromium-desktop') {
|
||||
return completed;
|
||||
}
|
||||
return completed || input.assignmentEnabled;
|
||||
}
|
||||
|
||||
export function domainMovedInstallUrl(target: string): string {
|
||||
return `${target}/app`;
|
||||
}
|
||||
|
||||
export function domainMovedManifestId(target: string): string {
|
||||
return `${target}/`;
|
||||
}
|
||||
|
||||
export function domainMovedBrowserMigrationUrl(target: string): string {
|
||||
return `${target}/migrate/begin?start=1&next=${encodeURIComponent('/app')}`;
|
||||
}
|
||||
|
||||
export function anonymousRolloutIsOpen(discovery: DomainMigrationDiscoveryResponse | null): boolean {
|
||||
return discovery?.enabled === true && discovery.anonymous_rollout_basis_points > 0;
|
||||
}
|
||||
|
||||
export function deviceIsInAnonymousRollout(discovery: DomainMigrationDiscoveryResponse, deviceId: string): boolean {
|
||||
return experimentBucket(deviceId, discovery.rollout_salt) < discovery.anonymous_rollout_basis_points;
|
||||
}
|
||||
|
||||
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)
|
||||
);
|
||||
}
|
||||
|
||||
export function collectExportableLocalStorage(storage: StorageLike | null): Record<string, string> {
|
||||
const entries: Record<string, string> = {};
|
||||
if (!storage) {
|
||||
return entries;
|
||||
}
|
||||
for (let index = 0; index < storage.length; index++) {
|
||||
const key = storage.key(index);
|
||||
if (key === null || !isExportableLocalStorageKey(key)) {
|
||||
continue;
|
||||
}
|
||||
const value = storage.getItem(key);
|
||||
if (value !== null) {
|
||||
entries[key] = value;
|
||||
}
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
export interface DomainMigrationCustomSound {
|
||||
sound_type: string;
|
||||
file_name: string;
|
||||
mime_type: string;
|
||||
data: string;
|
||||
}
|
||||
|
||||
export interface DomainMigrationThemeAsset {
|
||||
id: string;
|
||||
name: string;
|
||||
mime_type: string;
|
||||
size: number;
|
||||
data?: string;
|
||||
desktop_path?: string;
|
||||
created_at: number;
|
||||
updated_at: number;
|
||||
}
|
||||
|
||||
export interface DomainMigrationThemeLibrary {
|
||||
themes: Array<Record<string, unknown>>;
|
||||
assets: Array<DomainMigrationThemeAsset>;
|
||||
local_files: Array<Record<string, unknown>>;
|
||||
enabled_theme_ids: Array<string>;
|
||||
}
|
||||
|
||||
export interface DomainMigrationPayload {
|
||||
version: typeof DOMAIN_MIGRATION_PAYLOAD_VERSION;
|
||||
source_origin: string;
|
||||
exported_at: number;
|
||||
local_storage: Record<string, string>;
|
||||
accounts: Array<StoredAccount>;
|
||||
custom_sounds?: Array<DomainMigrationCustomSound>;
|
||||
theme_library?: DomainMigrationThemeLibrary;
|
||||
notification_permission: string;
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function isStringRecord(value: unknown): value is Record<string, string> {
|
||||
return isRecord(value) && Object.values(value).every((entry) => typeof entry === 'string');
|
||||
}
|
||||
|
||||
function isStoredAccount(value: unknown): value is StoredAccount {
|
||||
return (
|
||||
isRecord(value) &&
|
||||
typeof value.userId === 'string' &&
|
||||
value.userId.length > 0 &&
|
||||
(typeof value.token === 'string' || value.token === null) &&
|
||||
typeof value.lastActive === 'number'
|
||||
);
|
||||
}
|
||||
|
||||
function isCustomSound(value: unknown): value is DomainMigrationCustomSound {
|
||||
return (
|
||||
isRecord(value) &&
|
||||
typeof value.sound_type === 'string' &&
|
||||
typeof value.file_name === 'string' &&
|
||||
typeof value.mime_type === 'string' &&
|
||||
typeof value.data === 'string'
|
||||
);
|
||||
}
|
||||
|
||||
function isIdentifiedRecord(value: unknown): value is Record<string, unknown> {
|
||||
return isRecord(value) && typeof value.id === 'string';
|
||||
}
|
||||
|
||||
function isThemeAsset(value: unknown): value is DomainMigrationThemeAsset {
|
||||
return (
|
||||
isIdentifiedRecord(value) &&
|
||||
typeof value.name === 'string' &&
|
||||
typeof value.mime_type === 'string' &&
|
||||
typeof value.size === 'number' &&
|
||||
(value.data === undefined || typeof value.data === 'string') &&
|
||||
(value.desktop_path === undefined || typeof value.desktop_path === 'string') &&
|
||||
typeof value.created_at === 'number' &&
|
||||
typeof value.updated_at === 'number'
|
||||
);
|
||||
}
|
||||
|
||||
function isThemeLibrary(value: unknown): value is DomainMigrationThemeLibrary {
|
||||
return (
|
||||
isRecord(value) &&
|
||||
Array.isArray(value.themes) &&
|
||||
value.themes.every(isIdentifiedRecord) &&
|
||||
Array.isArray(value.assets) &&
|
||||
value.assets.every(isThemeAsset) &&
|
||||
Array.isArray(value.local_files) &&
|
||||
value.local_files.every(isIdentifiedRecord) &&
|
||||
Array.isArray(value.enabled_theme_ids) &&
|
||||
value.enabled_theme_ids.every((id) => typeof id === 'string')
|
||||
);
|
||||
}
|
||||
|
||||
export function withoutOptionalPayloadData(payload: DomainMigrationPayload): DomainMigrationPayload {
|
||||
return {
|
||||
...payload,
|
||||
custom_sounds: undefined,
|
||||
theme_library: payload.theme_library && {...payload.theme_library, assets: []},
|
||||
};
|
||||
}
|
||||
|
||||
export function parseDomainMigrationPayload(value: unknown, expectedSource: string): DomainMigrationPayload | null {
|
||||
if (
|
||||
!isRecord(value) ||
|
||||
value.version !== DOMAIN_MIGRATION_PAYLOAD_VERSION ||
|
||||
value.source_origin !== expectedSource ||
|
||||
typeof value.exported_at !== 'number' ||
|
||||
!isStringRecord(value.local_storage) ||
|
||||
!Array.isArray(value.accounts) ||
|
||||
!value.accounts.every(isStoredAccount) ||
|
||||
typeof value.notification_permission !== 'string'
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
if (
|
||||
value.custom_sounds !== undefined &&
|
||||
!(Array.isArray(value.custom_sounds) && value.custom_sounds.every(isCustomSound))
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
if (value.theme_library !== undefined && !isThemeLibrary(value.theme_library)) {
|
||||
return null;
|
||||
}
|
||||
return value as unknown as DomainMigrationPayload;
|
||||
}
|
||||
|
||||
function withoutRuntimeConfig(snapshot: Record<string, string> | undefined): Record<string, string> {
|
||||
const {runtimeConfig: _runtimeConfig, ...rest} = snapshot ?? {};
|
||||
return rest;
|
||||
}
|
||||
|
||||
export function rewriteImportedAccount(record: StoredAccount, instance: RuntimeConfigSnapshot): StoredAccount {
|
||||
const managed = withoutRuntimeConfig(record.managedStorageData ?? record.localStorageData);
|
||||
return {
|
||||
...record,
|
||||
localStorageData: managed,
|
||||
managedStorageData: managed,
|
||||
instance,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
const IV_BYTES = 12;
|
||||
const KEY_BYTES = 32;
|
||||
const GZIP_MAGIC_FIRST = 0x1f;
|
||||
const GZIP_MAGIC_SECOND = 0x8b;
|
||||
const BASE64_CHUNK = 0x8000;
|
||||
|
||||
export function bytesToBase64Url(bytes: Uint8Array): string {
|
||||
let binary = '';
|
||||
for (let offset = 0; offset < bytes.length; offset += BASE64_CHUNK) {
|
||||
binary += String.fromCharCode(...bytes.subarray(offset, offset + BASE64_CHUNK));
|
||||
}
|
||||
return btoa(binary).replace(/\+/gu, '-').replace(/\//gu, '_').replace(/=+$/u, '');
|
||||
}
|
||||
|
||||
export function base64UrlToBytes(value: string): Uint8Array<ArrayBuffer> {
|
||||
const base64 = value.replace(/-/gu, '+').replace(/_/gu, '/');
|
||||
const binary = atob(base64.padEnd(Math.ceil(base64.length / 4) * 4, '='));
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
for (let index = 0; index < binary.length; index++) {
|
||||
bytes[index] = binary.charCodeAt(index);
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
export function randomBase64Url(byteLength: number): string {
|
||||
return bytesToBase64Url(crypto.getRandomValues(new Uint8Array(byteLength)));
|
||||
}
|
||||
|
||||
export async function sha256Hex(value: string): Promise<string> {
|
||||
const digest = new Uint8Array(await crypto.subtle.digest('SHA-256', new TextEncoder().encode(value)));
|
||||
return Array.from(digest, (byte) => byte.toString(16).padStart(2, '0')).join('');
|
||||
}
|
||||
|
||||
async function pipeBytes(
|
||||
bytes: Uint8Array<ArrayBuffer>,
|
||||
transform: GenericTransformStream,
|
||||
): Promise<Uint8Array<ArrayBuffer>> {
|
||||
const stream = new Blob([bytes]).stream().pipeThrough(transform as TransformStream<Uint8Array, Uint8Array>);
|
||||
return new Uint8Array(await new Response(stream).arrayBuffer());
|
||||
}
|
||||
|
||||
async function compress(bytes: Uint8Array<ArrayBuffer>): Promise<Uint8Array<ArrayBuffer>> {
|
||||
if (typeof CompressionStream === 'undefined') {
|
||||
return bytes;
|
||||
}
|
||||
return pipeBytes(bytes, new CompressionStream('gzip'));
|
||||
}
|
||||
|
||||
async function decompress(bytes: Uint8Array<ArrayBuffer>): Promise<Uint8Array<ArrayBuffer>> {
|
||||
if (bytes[0] !== GZIP_MAGIC_FIRST || bytes[1] !== GZIP_MAGIC_SECOND) {
|
||||
return bytes;
|
||||
}
|
||||
if (typeof DecompressionStream === 'undefined') {
|
||||
throw new Error('DecompressionStream unavailable');
|
||||
}
|
||||
return pipeBytes(bytes, new DecompressionStream('gzip'));
|
||||
}
|
||||
|
||||
export async function encryptDomainMigrationPayload(value: unknown): Promise<{payload: string; key: string}> {
|
||||
const plaintext = await compress(new TextEncoder().encode(JSON.stringify(value)));
|
||||
const rawKey = crypto.getRandomValues(new Uint8Array(KEY_BYTES));
|
||||
const iv = crypto.getRandomValues(new Uint8Array(IV_BYTES));
|
||||
const key = await crypto.subtle.importKey('raw', rawKey, 'AES-GCM', false, ['encrypt']);
|
||||
const ciphertext = new Uint8Array(await crypto.subtle.encrypt({name: 'AES-GCM', iv}, key, plaintext));
|
||||
const sealed = new Uint8Array(IV_BYTES + ciphertext.length);
|
||||
sealed.set(iv, 0);
|
||||
sealed.set(ciphertext, IV_BYTES);
|
||||
return {payload: bytesToBase64Url(sealed), key: bytesToBase64Url(rawKey)};
|
||||
}
|
||||
|
||||
export async function decryptDomainMigrationPayload(payload: string, encodedKey: string): Promise<unknown> {
|
||||
const sealed = base64UrlToBytes(payload);
|
||||
const rawKey = base64UrlToBytes(encodedKey);
|
||||
if (rawKey.length !== KEY_BYTES || sealed.length <= IV_BYTES) {
|
||||
throw new Error('Malformed handoff payload');
|
||||
}
|
||||
const key = await crypto.subtle.importKey('raw', rawKey, 'AES-GCM', false, ['decrypt']);
|
||||
const plaintext = new Uint8Array(
|
||||
await crypto.subtle.decrypt({name: 'AES-GCM', iv: sealed.subarray(0, IV_BYTES)}, key, sealed.subarray(IV_BYTES)),
|
||||
);
|
||||
return JSON.parse(new TextDecoder().decode(await decompress(plaintext)));
|
||||
}
|
||||
@@ -0,0 +1,608 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {
|
||||
hasStoredAccount,
|
||||
readActiveSessionToken,
|
||||
readDomainMigrationDiscovery,
|
||||
readDomainMigrationEnvironment,
|
||||
} from '@app/features/app/domain_migration/DomainMigrationBrowser';
|
||||
import {
|
||||
anonymousRolloutIsOpen,
|
||||
buildTargetUrl,
|
||||
clearDomainMigrationIntent,
|
||||
collectExportableLocalStorage,
|
||||
DOMAIN_MIGRATION_CUSTOM_SOUNDS_MAX_BYTES,
|
||||
DOMAIN_MIGRATION_DEFAULT_NEXT_PATH,
|
||||
DOMAIN_MIGRATION_DEVICE_KEY,
|
||||
DOMAIN_MIGRATION_MARKER_KEY,
|
||||
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 DomainMigrationPayload,
|
||||
type DomainMigrationSide,
|
||||
type DomainMigrationThemeLibrary,
|
||||
deviceIsInAnonymousRollout,
|
||||
environmentAllowsDomainMigration,
|
||||
environmentMayForward,
|
||||
intentConfirmsCompletion,
|
||||
isExportableLocalStorageKey,
|
||||
markDomainMigrationCompleted,
|
||||
markDomainMigrationFailed,
|
||||
parseDomainMigrationMarker,
|
||||
parseDomainMigrationPayload,
|
||||
readDomainMigrationIntent,
|
||||
readDomainMigrationMarker,
|
||||
resolveDomainMigrationSide,
|
||||
rewriteImportedAccount,
|
||||
type StorageLike,
|
||||
sanitizeNextPath,
|
||||
shouldForwardCompletedSource,
|
||||
withoutOptionalPayloadData,
|
||||
writeDomainMigrationIntent,
|
||||
} from '@app/features/app/domain_migration/DomainMigrationCore';
|
||||
import {
|
||||
base64UrlToBytes,
|
||||
bytesToBase64Url,
|
||||
decryptDomainMigrationPayload,
|
||||
encryptDomainMigrationPayload,
|
||||
randomBase64Url,
|
||||
sha256Hex,
|
||||
} from '@app/features/app/domain_migration/DomainMigrationCrypto';
|
||||
import {resolvePasskeyBridgeOpenerOrigin} from '@app/features/auth/utils/PasskeyBridgeProtocol';
|
||||
import type {SoundType} from '@app/features/notification/utils/SoundUtils';
|
||||
import {getProtectedLocalStorage, getProtectedSessionStorage} from '@app/features/platform/state/ProtectedWebStorage';
|
||||
import {Logger} from '@app/features/platform/utils/AppLogger';
|
||||
import type {
|
||||
ThemeLibraryAsset,
|
||||
ThemeLibraryLocalFileReference,
|
||||
ThemeLibraryTheme,
|
||||
} from '@app/features/theme/state/ThemeLibrary';
|
||||
import {when} from 'mobx';
|
||||
|
||||
const logger = new Logger('DomainMigration');
|
||||
|
||||
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;
|
||||
|
||||
type DomainMigrationFailureReason =
|
||||
| 'disabled'
|
||||
| 'no_pending'
|
||||
| 'missing_handoff'
|
||||
| 'handoff_expired'
|
||||
| 'nonce_mismatch'
|
||||
| 'redeem_failed'
|
||||
| 'invalid_payload'
|
||||
| 'import_failed'
|
||||
| 'target_error';
|
||||
|
||||
class DomainMigrationImportError extends Error {
|
||||
constructor(readonly reason: DomainMigrationFailureReason) {
|
||||
super(`Domain migration import failed: ${reason}`);
|
||||
this.name = 'DomainMigrationImportError';
|
||||
}
|
||||
}
|
||||
|
||||
interface PendingHandoff {
|
||||
nonce: string;
|
||||
next: string;
|
||||
at: number;
|
||||
}
|
||||
|
||||
function navigate(url: string): true {
|
||||
window.location.replace(url);
|
||||
return true;
|
||||
}
|
||||
|
||||
function readCurrentPath(): {pathname: string; search: string; hash: string} {
|
||||
return {pathname: window.location.pathname, search: window.location.search, hash: window.location.hash};
|
||||
}
|
||||
|
||||
function readNotificationPermission(): string {
|
||||
return typeof Notification === 'undefined' ? 'unsupported' : Notification.permission;
|
||||
}
|
||||
|
||||
function readOrCreateDeviceId(): string {
|
||||
const storage = getProtectedLocalStorage();
|
||||
const existing = storage?.getItem(DOMAIN_MIGRATION_DEVICE_KEY);
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
const deviceId = randomBase64Url(DEVICE_ID_BYTES);
|
||||
try {
|
||||
storage?.setItem(DOMAIN_MIGRATION_DEVICE_KEY, deviceId);
|
||||
} catch {}
|
||||
return deviceId;
|
||||
}
|
||||
|
||||
async function collectCustomSounds(): Promise<Array<DomainMigrationCustomSound> | undefined> {
|
||||
try {
|
||||
const {getAllCustomSounds} = await import('@app/features/notification/utils/CustomSoundDB');
|
||||
const sounds = await getAllCustomSounds();
|
||||
const totalBytes = sounds.reduce((sum, sound) => sum + sound.blob.size, 0);
|
||||
if (totalBytes > DOMAIN_MIGRATION_CUSTOM_SOUNDS_MAX_BYTES) {
|
||||
return undefined;
|
||||
}
|
||||
return await Promise.all(
|
||||
sounds.map(async (sound) => ({
|
||||
sound_type: sound.soundType,
|
||||
file_name: sound.fileName,
|
||||
mime_type: sound.blob.type,
|
||||
data: bytesToBase64Url(new Uint8Array(await sound.blob.arrayBuffer())),
|
||||
})),
|
||||
);
|
||||
} catch (err) {
|
||||
logger.warn('Skipping custom sounds in the domain migration export:', err);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
async function restoreCustomSounds(sounds: ReadonlyArray<DomainMigrationCustomSound> | undefined): Promise<void> {
|
||||
if (!sounds || sounds.length === 0) {
|
||||
return;
|
||||
}
|
||||
const {saveCustomSound} = await import('@app/features/notification/utils/CustomSoundDB');
|
||||
for (const sound of sounds) {
|
||||
try {
|
||||
const blob = new Blob([base64UrlToBytes(sound.data)], {type: sound.mime_type});
|
||||
await saveCustomSound(sound.sound_type as SoundType, blob, sound.file_name);
|
||||
} catch (err) {
|
||||
logger.warn(`Failed to restore custom sound ${sound.sound_type}:`, err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function collectThemeLibrary(): Promise<DomainMigrationThemeLibrary | undefined> {
|
||||
try {
|
||||
const db = await import('@app/features/theme/utils/ThemeLibraryDb');
|
||||
const [themes, assets, localFiles, enabledThemeIds] = await Promise.all([
|
||||
db.listThemeLibraryThemes(),
|
||||
db.listThemeLibraryAssets(),
|
||||
db.listThemeLibraryLocalFiles(),
|
||||
db.getEnabledThemeIds(),
|
||||
]);
|
||||
const assetBytes = assets.reduce((sum, asset) => sum + (asset.data?.size ?? 0), 0);
|
||||
const portableAssets = assetBytes > DOMAIN_MIGRATION_THEME_ASSETS_MAX_BYTES ? [] : assets;
|
||||
return {
|
||||
themes: themes.map((theme) => ({...theme})),
|
||||
assets: await Promise.all(
|
||||
portableAssets.map(async (asset) => ({
|
||||
id: asset.id,
|
||||
name: asset.name,
|
||||
mime_type: asset.mimeType,
|
||||
size: asset.size,
|
||||
data: asset.data ? bytesToBase64Url(new Uint8Array(await asset.data.arrayBuffer())) : undefined,
|
||||
desktop_path: asset.desktopPath,
|
||||
created_at: asset.createdAt,
|
||||
updated_at: asset.updatedAt,
|
||||
})),
|
||||
),
|
||||
local_files: localFiles.map((file) => ({...file})),
|
||||
enabled_theme_ids: enabledThemeIds,
|
||||
};
|
||||
} catch (err) {
|
||||
logger.warn('Skipping the theme library in the domain migration export:', err);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
async function restoreThemeLibrary(library: DomainMigrationThemeLibrary | undefined): Promise<void> {
|
||||
if (!library) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const db = await import('@app/features/theme/utils/ThemeLibraryDb');
|
||||
for (const theme of library.themes) {
|
||||
await db.saveThemeLibraryTheme(theme as unknown as ThemeLibraryTheme);
|
||||
}
|
||||
for (const asset of library.assets) {
|
||||
const restored: ThemeLibraryAsset = {
|
||||
id: asset.id,
|
||||
name: asset.name,
|
||||
mimeType: asset.mime_type,
|
||||
size: asset.size,
|
||||
data: asset.data === undefined ? undefined : new Blob([base64UrlToBytes(asset.data)], {type: asset.mime_type}),
|
||||
desktopPath: asset.desktop_path,
|
||||
createdAt: asset.created_at,
|
||||
updatedAt: asset.updated_at,
|
||||
};
|
||||
await db.saveThemeLibraryAsset(restored);
|
||||
}
|
||||
for (const file of library.local_files) {
|
||||
await db.saveThemeLibraryLocalFile(file as unknown as ThemeLibraryLocalFileReference);
|
||||
}
|
||||
if (library.enabled_theme_ids.length > 0) {
|
||||
await db.setEnabledThemeIds(library.enabled_theme_ids);
|
||||
}
|
||||
} catch (err) {
|
||||
logger.warn('Failed to restore the theme library:', err);
|
||||
}
|
||||
}
|
||||
|
||||
async function createHandoff(apiEndpoint: string, token: string, nonceHash: string, payload: string): Promise<string> {
|
||||
const response = await fetch(`${apiEndpoint}/v1/auth/origin-handoff`, {
|
||||
method: 'POST',
|
||||
credentials: 'omit',
|
||||
headers: {'Content-Type': 'application/json', Authorization: token},
|
||||
body: JSON.stringify({nonce_hash: nonceHash, payload}),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`Origin handoff was rejected with status ${response.status}`);
|
||||
}
|
||||
const body = (await response.json()) as {handoff_id?: unknown};
|
||||
if (typeof body.handoff_id !== 'string' || !BASE64URL_TOKEN_PATTERN.test(body.handoff_id)) {
|
||||
throw new Error('Origin handoff response is malformed');
|
||||
}
|
||||
return body.handoff_id;
|
||||
}
|
||||
|
||||
async function redeemHandoff(target: string, handoffId: string, nonce: string): Promise<string> {
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(`${target}/api/v1/auth/origin-handoff/redeem`, {
|
||||
method: 'POST',
|
||||
credentials: 'omit',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({handoff_id: handoffId, nonce}),
|
||||
});
|
||||
} catch {
|
||||
throw new DomainMigrationImportError('redeem_failed');
|
||||
}
|
||||
if (response.status === 404) {
|
||||
throw new DomainMigrationImportError('handoff_expired');
|
||||
}
|
||||
if (response.status === 400) {
|
||||
throw new DomainMigrationImportError('nonce_mismatch');
|
||||
}
|
||||
if (!response.ok) {
|
||||
throw new DomainMigrationImportError('redeem_failed');
|
||||
}
|
||||
const body = (await response.json()) as {payload?: unknown};
|
||||
if (typeof body.payload !== 'string') {
|
||||
throw new DomainMigrationImportError('redeem_failed');
|
||||
}
|
||||
return body.payload;
|
||||
}
|
||||
|
||||
function sourceUrl(side: DomainMigrationSide, next: unknown): string {
|
||||
return `${side.source}${sanitizeNextPath(next)}`;
|
||||
}
|
||||
|
||||
function discoveryAllowsMigration(): boolean {
|
||||
return (
|
||||
readDomainMigrationDiscovery()?.enabled === true &&
|
||||
environmentAllowsDomainMigration(readDomainMigrationEnvironment())
|
||||
);
|
||||
}
|
||||
|
||||
function revokeCompletedMarker(storage: StorageLike | null): void {
|
||||
if (readDomainMigrationMarker(storage)?.state === 'completed') {
|
||||
markDomainMigrationFailed(storage, Date.now());
|
||||
}
|
||||
}
|
||||
|
||||
async function exportFromSource(side: DomainMigrationSide, nonce: string | null): Promise<boolean> {
|
||||
const storage = getProtectedLocalStorage();
|
||||
const sessionStorage = getProtectedSessionStorage();
|
||||
const intent = readDomainMigrationIntent(sessionStorage, Date.now());
|
||||
if (intent === null || intent.handoff_id !== undefined) {
|
||||
clearDomainMigrationIntent(sessionStorage);
|
||||
return navigate(sourceUrl(side, DOMAIN_MIGRATION_DEFAULT_NEXT_PATH));
|
||||
}
|
||||
try {
|
||||
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 token = readActiveSessionToken() ?? accounts.find((account) => account.isValid !== false)?.token ?? null;
|
||||
if (!token) {
|
||||
throw new Error('No stored account to export');
|
||||
}
|
||||
const payload: DomainMigrationPayload = {
|
||||
version: DOMAIN_MIGRATION_PAYLOAD_VERSION,
|
||||
source_origin: side.source,
|
||||
exported_at: Date.now(),
|
||||
local_storage: collectExportableLocalStorage(storage),
|
||||
accounts,
|
||||
custom_sounds: await collectCustomSounds(),
|
||||
theme_library: await collectThemeLibrary(),
|
||||
notification_permission: readNotificationPermission(),
|
||||
};
|
||||
let sealed = await encryptDomainMigrationPayload(payload);
|
||||
if (sealed.payload.length > MAX_HANDOFF_PAYLOAD_LENGTH) {
|
||||
sealed = await encryptDomainMigrationPayload(withoutOptionalPayloadData(payload));
|
||||
}
|
||||
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}`);
|
||||
} catch (err) {
|
||||
logger.warn('Domain migration export failed:', err);
|
||||
clearDomainMigrationIntent(sessionStorage);
|
||||
revokeCompletedMarker(storage);
|
||||
return navigate(sourceUrl(side, DOMAIN_MIGRATION_DEFAULT_NEXT_PATH));
|
||||
}
|
||||
}
|
||||
|
||||
async function forwardFromSource(side: DomainMigrationSide): Promise<boolean> {
|
||||
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)}`);
|
||||
}
|
||||
if (
|
||||
discovery !== null &&
|
||||
anonymousRolloutIsOpen(discovery) &&
|
||||
!(await hasStoredAccount()) &&
|
||||
deviceIsInAnonymousRollout(discovery, readOrCreateDeviceId())
|
||||
) {
|
||||
return navigate(buildTargetUrl(side.target, pathname, search, hash));
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
async function forwardWhenIdle(side: DomainMigrationSide): Promise<void> {
|
||||
const {default: MediaEngine} = await import('@app/features/voice/engine/MediaEngineFacade');
|
||||
await when(() => !MediaEngine.connected && !MediaEngine.connecting);
|
||||
if (
|
||||
!shouldForwardCompletedSource(
|
||||
readDomainMigrationDiscovery(),
|
||||
readDomainMigrationMarker(getProtectedLocalStorage()),
|
||||
readDomainMigrationEnvironment(),
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const {pathname, search, hash} = readCurrentPath();
|
||||
navigate(buildTargetUrl(side.target, pathname, search, hash));
|
||||
}
|
||||
|
||||
function installSourceMarkerListener(side: DomainMigrationSide): void {
|
||||
let waiting = false;
|
||||
window.addEventListener('storage', (event) => {
|
||||
if (event.key !== DOMAIN_MIGRATION_MARKER_KEY || waiting) {
|
||||
return;
|
||||
}
|
||||
if (
|
||||
!shouldForwardCompletedSource(
|
||||
readDomainMigrationDiscovery(),
|
||||
parseDomainMigrationMarker(event.newValue),
|
||||
readDomainMigrationEnvironment(),
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
waiting = true;
|
||||
forwardWhenIdle(side)
|
||||
.catch((err) => {
|
||||
logger.warn('Failed to forward a migrated source tab:', err);
|
||||
})
|
||||
.finally(() => {
|
||||
waiting = false;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function completeOnSource(side: DomainMigrationSide, params: URLSearchParams): boolean {
|
||||
const sessionStorage = getProtectedSessionStorage();
|
||||
const intent = readDomainMigrationIntent(sessionStorage, Date.now());
|
||||
clearDomainMigrationIntent(sessionStorage);
|
||||
if (!intentConfirmsCompletion(intent, params.get('h'))) {
|
||||
return navigate(sourceUrl(side, params.get('next')));
|
||||
}
|
||||
markDomainMigrationCompleted(getProtectedLocalStorage(), side.target, Date.now());
|
||||
if (!environmentMayForward(readDomainMigrationEnvironment(), readDomainMigrationDiscovery())) {
|
||||
return navigate(sourceUrl(side, params.get('next')));
|
||||
}
|
||||
return navigate(`${side.target}${sanitizeNextPath(params.get('next'))}`);
|
||||
}
|
||||
|
||||
function startOnSource(side: DomainMigrationSide, params: URLSearchParams): boolean {
|
||||
writeDomainMigrationIntent(getProtectedSessionStorage(), {at: Date.now()});
|
||||
return navigate(`${side.target}/migrate/begin?next=${encodeURIComponent(sanitizeNextPath(params.get('next')))}`);
|
||||
}
|
||||
|
||||
function failOnSource(side: DomainMigrationSide, params: URLSearchParams): boolean {
|
||||
const sessionStorage = getProtectedSessionStorage();
|
||||
const intent = readDomainMigrationIntent(sessionStorage, Date.now());
|
||||
clearDomainMigrationIntent(sessionStorage);
|
||||
if (intent !== null) {
|
||||
logger.warn(`Domain migration failed on ${side.target}: ${params.get('reason') ?? 'unknown'}`);
|
||||
revokeCompletedMarker(getProtectedLocalStorage());
|
||||
}
|
||||
return navigate(sourceUrl(side, params.get('next')));
|
||||
}
|
||||
|
||||
async function handleSource(side: DomainMigrationSide): Promise<boolean> {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const {pathname} = window.location;
|
||||
if (pathname.startsWith('/migrate/') && !discoveryAllowsMigration()) {
|
||||
clearDomainMigrationIntent(getProtectedSessionStorage());
|
||||
return navigate(sourceUrl(side, params.get('next')));
|
||||
}
|
||||
switch (pathname) {
|
||||
case '/migrate/export':
|
||||
return exportFromSource(side, params.get('n'));
|
||||
case '/migrate/start':
|
||||
return startOnSource(side, params);
|
||||
case '/migrate/done':
|
||||
return completeOnSource(side, params);
|
||||
case '/migrate/failed':
|
||||
return failOnSource(side, params);
|
||||
}
|
||||
if (await forwardFromSource(side)) {
|
||||
return true;
|
||||
}
|
||||
installSourceMarkerListener(side);
|
||||
return false;
|
||||
}
|
||||
|
||||
function takePendingHandoff(): PendingHandoff | null {
|
||||
const storage = getProtectedSessionStorage();
|
||||
try {
|
||||
const raw = storage?.getItem(DOMAIN_MIGRATION_PENDING_KEY) ?? null;
|
||||
storage?.removeItem(DOMAIN_MIGRATION_PENDING_KEY);
|
||||
if (!raw) {
|
||||
return null;
|
||||
}
|
||||
const value = JSON.parse(raw) as Partial<PendingHandoff>;
|
||||
if (typeof value.nonce !== 'string' || typeof value.next !== 'string' || typeof value.at !== 'number') {
|
||||
return null;
|
||||
}
|
||||
return {nonce: value.nonce, next: value.next, at: value.at};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function importHandoff(side: DomainMigrationSide, fragment: URLSearchParams, nonce: string): Promise<void> {
|
||||
const handoffId = fragment.get('h');
|
||||
const key = fragment.get('k');
|
||||
if (!handoffId || !key || !BASE64URL_TOKEN_PATTERN.test(handoffId)) {
|
||||
throw new DomainMigrationImportError('missing_handoff');
|
||||
}
|
||||
const sealed = await redeemHandoff(side.target, handoffId, nonce);
|
||||
let payload: DomainMigrationPayload | null;
|
||||
try {
|
||||
payload = parseDomainMigrationPayload(await decryptDomainMigrationPayload(sealed, key), side.source);
|
||||
} catch {
|
||||
payload = null;
|
||||
}
|
||||
if (payload === null) {
|
||||
throw new DomainMigrationImportError('invalid_payload');
|
||||
}
|
||||
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)));
|
||||
} catch (err) {
|
||||
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;
|
||||
}
|
||||
try {
|
||||
storage?.setItem(storageKey, value);
|
||||
} catch (err) {
|
||||
logger.warn(`Failed to import localStorage key ${storageKey}:`, err);
|
||||
}
|
||||
}
|
||||
await restoreCustomSounds(payload.custom_sounds);
|
||||
await restoreThemeLibrary(payload.theme_library);
|
||||
if (payload.notification_permission === 'granted') {
|
||||
try {
|
||||
storage?.setItem(DOMAIN_MIGRATION_NOTIFICATIONS_KEY, 'granted');
|
||||
} catch {}
|
||||
}
|
||||
await persistDesktopAppOrigin();
|
||||
}
|
||||
|
||||
async function persistDesktopAppOrigin(): Promise<void> {
|
||||
try {
|
||||
await window.electron?.domainMigration?.setAppOrigin(window.location.origin);
|
||||
} catch (err) {
|
||||
logger.warn('Failed to persist the desktop app origin:', err);
|
||||
}
|
||||
}
|
||||
|
||||
function doneUrl(side: DomainMigrationSide, next: string, handoffId: string | null): string {
|
||||
const handoff = handoffId === null ? '' : `h=${encodeURIComponent(handoffId)}&`;
|
||||
return `${side.source}/migrate/done?${handoff}next=${encodeURIComponent(next)}`;
|
||||
}
|
||||
|
||||
function failedUrl(side: DomainMigrationSide, reason: DomainMigrationFailureReason, next: string): string {
|
||||
return `${side.source}/migrate/failed?reason=${reason}&next=${encodeURIComponent(next)}`;
|
||||
}
|
||||
|
||||
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 started = params.get('start') === '1';
|
||||
if (await hasStoredAccount()) {
|
||||
await persistDesktopAppOrigin();
|
||||
return navigate(params.get('resume') === '1' || started ? `${side.target}${next}` : doneUrl(side, next, null));
|
||||
}
|
||||
if (started) {
|
||||
return navigate(`${side.source}/migrate/start?next=${encodeURIComponent(next)}`);
|
||||
}
|
||||
const nonce = randomBase64Url(NONCE_BYTES);
|
||||
const pending: PendingHandoff = {nonce, next, at: Date.now()};
|
||||
getProtectedSessionStorage()?.setItem(DOMAIN_MIGRATION_PENDING_KEY, JSON.stringify(pending));
|
||||
return navigate(`${side.source}/migrate/export?n=${nonce}`);
|
||||
}
|
||||
|
||||
async function completeOnTarget(side: DomainMigrationSide): Promise<boolean> {
|
||||
const fragment = new URLSearchParams(window.location.hash.slice(1));
|
||||
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) {
|
||||
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);
|
||||
return navigate(failedUrl(side, reason, next));
|
||||
}
|
||||
return navigate(doneUrl(side, next, handoffId));
|
||||
}
|
||||
|
||||
async function handleTarget(side: DomainMigrationSide): Promise<boolean> {
|
||||
switch (window.location.pathname) {
|
||||
case '/migrate/begin':
|
||||
return beginOnTarget(side, new URLSearchParams(window.location.search));
|
||||
case '/migrate/complete':
|
||||
return completeOnTarget(side);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export async function runDomainMigrationPreMount(): Promise<boolean> {
|
||||
if (typeof window === 'undefined') {
|
||||
return false;
|
||||
}
|
||||
const side = resolveDomainMigrationSide(window.location.origin);
|
||||
if (side === null || resolvePasskeyBridgeOpenerOrigin(window.location.origin, window.location.pathname) !== null) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
return side.role === 'source' ? await handleSource(side) : await handleTarget(side);
|
||||
} catch (err) {
|
||||
logger.error('Domain migration pre-mount step failed:', err);
|
||||
if (side.role === 'target' && window.location.pathname.startsWith('/migrate/')) {
|
||||
return navigate(failedUrl(side, 'target_error', DOMAIN_MIGRATION_DEFAULT_NEXT_PATH));
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import ExperimentAssignments from '@app/features/experiment/state/ExperimentAssignments';
|
||||
import {Logger} from '@app/features/platform/utils/AppLogger';
|
||||
import {readDomainMigrationAssignment} from '@fluxer/schema/src/domains/experiment/ExperimentSchemas';
|
||||
|
||||
const logger = new Logger('DomainMigrationRollout');
|
||||
|
||||
class DomainMigrationRolloutSelector {
|
||||
get enabled(): boolean {
|
||||
try {
|
||||
return readDomainMigrationAssignment(ExperimentAssignments.response).enabled;
|
||||
} catch (err) {
|
||||
logger.warn('Failed to resolve domain migration assignment:', err);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const DomainMigrationRollout = new DomainMigrationRolloutSelector();
|
||||
|
||||
export default DomainMigrationRollout;
|
||||
@@ -0,0 +1,120 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {Routes} from '@app/app/Routes';
|
||||
import {
|
||||
browserSupportsRelatedOrigins,
|
||||
readDomainMigrationDiscovery,
|
||||
readDomainMigrationEnvironment,
|
||||
} from '@app/features/app/domain_migration/DomainMigrationBrowser';
|
||||
import {
|
||||
DOMAIN_MIGRATION_NOTIFICATIONS_KEY,
|
||||
type DomainMigrationGateInput,
|
||||
type DomainMigrationSide,
|
||||
markDomainMigrationFailed,
|
||||
readDomainMigrationMarker,
|
||||
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 {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';
|
||||
|
||||
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;
|
||||
|
||||
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 readGateInput(assignmentEnabled: boolean, relatedOriginsSupported: boolean): DomainMigrationGateInput {
|
||||
return {
|
||||
environment: readDomainMigrationEnvironment(),
|
||||
assignmentEnabled,
|
||||
discovery: readDomainMigrationDiscovery(),
|
||||
marker: readDomainMigrationMarker(getProtectedLocalStorage()),
|
||||
now: Date.now(),
|
||||
relatedOriginsSupported,
|
||||
voiceActive: isVoiceActive(),
|
||||
oneShotRoute: isOneShotRoute(window.location.pathname),
|
||||
};
|
||||
}
|
||||
|
||||
async function evaluateSource(side: DomainMigrationSide, assignmentEnabled: boolean): Promise<void> {
|
||||
if (navigating || !shouldStartDomainMigration(readGateInput(assignmentEnabled, true))) {
|
||||
return;
|
||||
}
|
||||
const relatedOriginsSupported = await browserSupportsRelatedOrigins();
|
||||
if (
|
||||
navigating ||
|
||||
!shouldStartDomainMigration(readGateInput(DomainMigrationRollout.enabled, relatedOriginsSupported))
|
||||
) {
|
||||
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)}`);
|
||||
}
|
||||
|
||||
function offerNotificationReenable(): void {
|
||||
const storage = getProtectedLocalStorage();
|
||||
try {
|
||||
if (storage?.getItem(DOMAIN_MIGRATION_NOTIFICATIONS_KEY) !== 'granted') {
|
||||
return;
|
||||
}
|
||||
storage.removeItem(DOMAIN_MIGRATION_NOTIFICATIONS_KEY);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
if (typeof Notification !== 'undefined' && Notification.permission === 'default') {
|
||||
NagbarCommands.resetNagbar('desktopNotificationDismissed');
|
||||
}
|
||||
}
|
||||
|
||||
export function startDomainMigrationTrigger(): void {
|
||||
if (started || typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
const side = resolveDomainMigrationSide(window.location.origin);
|
||||
if (side === null) {
|
||||
return;
|
||||
}
|
||||
started = true;
|
||||
if (side.role === 'target') {
|
||||
setTimeout(offerNotificationReenable, 0);
|
||||
return;
|
||||
}
|
||||
when(
|
||||
() => ExperimentAssignments.response !== INERT_EXPERIMENT_ASSIGNMENTS_RESPONSE,
|
||||
() => {
|
||||
evaluateSource(side, DomainMigrationRollout.enabled).catch((err) => {
|
||||
logger.warn('Domain migration trigger failed:', err);
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {
|
||||
detectDomainMigrationInstallKind,
|
||||
readDomainMigrationDiscovery,
|
||||
} from '@app/features/app/domain_migration/DomainMigrationBrowser';
|
||||
import {
|
||||
DOMAIN_MIGRATION_MARKER_KEY,
|
||||
DOMAIN_MIGRATION_MOVED_DISMISSED_KEY,
|
||||
DOMAIN_MIGRATION_SOURCE_TO_TARGET,
|
||||
type DomainMigrationInstallKind,
|
||||
type DomainMigrationMarker,
|
||||
type DomainMigrationSide,
|
||||
readDomainMigrationMarker,
|
||||
resolveDomainMigrationSide,
|
||||
shouldShowDomainMovedNotice,
|
||||
} from '@app/features/app/domain_migration/DomainMigrationCore';
|
||||
import DomainMigrationRollout from '@app/features/app/domain_migration/DomainMigrationRollout';
|
||||
import {getProtectedLocalStorage} from '@app/features/platform/state/ProtectedWebStorage';
|
||||
import {makeAutoObservable} from 'mobx';
|
||||
|
||||
const FALLBACK_TARGET = DOMAIN_MIGRATION_SOURCE_TO_TARGET['https://web.fluxer.app'];
|
||||
|
||||
function readDismissedAt(): number | null {
|
||||
try {
|
||||
const value = Number(getProtectedLocalStorage()?.getItem(DOMAIN_MIGRATION_MOVED_DISMISSED_KEY) ?? Number.NaN);
|
||||
return Number.isFinite(value) ? value : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
class DomainMovedNotice {
|
||||
readonly side: DomainMigrationSide | null;
|
||||
readonly installKind: DomainMigrationInstallKind;
|
||||
marker: DomainMigrationMarker | null;
|
||||
dismissedAt: number | null;
|
||||
|
||||
constructor() {
|
||||
const origin = typeof window === 'undefined' ? '' : window.location.origin;
|
||||
this.side = resolveDomainMigrationSide(origin);
|
||||
this.installKind = detectDomainMigrationInstallKind();
|
||||
this.marker = readDomainMigrationMarker(getProtectedLocalStorage());
|
||||
this.dismissedAt = readDismissedAt();
|
||||
makeAutoObservable(this, {side: false, installKind: false}, {autoBind: true});
|
||||
if (this.side?.role === 'source') {
|
||||
window.addEventListener('storage', this.handleStorage);
|
||||
}
|
||||
}
|
||||
|
||||
get target(): string {
|
||||
return this.side?.target ?? FALLBACK_TARGET;
|
||||
}
|
||||
|
||||
get targetHost(): string {
|
||||
return new URL(this.target).host;
|
||||
}
|
||||
|
||||
shouldShow(now: number): boolean {
|
||||
return shouldShowDomainMovedNotice({
|
||||
side: this.side,
|
||||
installKind: this.installKind,
|
||||
discovery: readDomainMigrationDiscovery(),
|
||||
assignmentEnabled: DomainMigrationRollout.enabled,
|
||||
marker: this.marker,
|
||||
dismissedAt: this.dismissedAt,
|
||||
now,
|
||||
});
|
||||
}
|
||||
|
||||
dismiss(now: number): void {
|
||||
this.dismissedAt = now;
|
||||
try {
|
||||
getProtectedLocalStorage()?.setItem(DOMAIN_MIGRATION_MOVED_DISMISSED_KEY, String(now));
|
||||
} catch {}
|
||||
}
|
||||
|
||||
resetDismissal(): void {
|
||||
this.dismissedAt = null;
|
||||
try {
|
||||
getProtectedLocalStorage()?.removeItem(DOMAIN_MIGRATION_MOVED_DISMISSED_KEY);
|
||||
} catch {}
|
||||
}
|
||||
|
||||
private handleStorage(event: StorageEvent): void {
|
||||
if (event.key === DOMAIN_MIGRATION_MARKER_KEY || event.key === null) {
|
||||
this.marker = readDomainMigrationMarker(getProtectedLocalStorage());
|
||||
}
|
||||
if (event.key === DOMAIN_MIGRATION_MOVED_DISMISSED_KEY || event.key === null) {
|
||||
this.dismissedAt = readDismissedAt();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default new DomainMovedNotice();
|
||||
@@ -0,0 +1,20 @@
|
||||
/* SPDX-License-Identifier: AGPL-3.0-or-later */
|
||||
|
||||
.content {
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.steps {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
margin: 0;
|
||||
padding-left: 1.25rem;
|
||||
color: var(--text-primary);
|
||||
list-style: decimal;
|
||||
}
|
||||
|
||||
.step {
|
||||
padding-left: 0.25rem;
|
||||
line-height: 1.4;
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import * as Modal from '@app/features/app/components/dialogs/Modal';
|
||||
import {PRODUCT_NAME} from '@app/features/app/config/I18nDisplayConstants';
|
||||
import styles from '@app/features/app/domain_migration/DomainMovedStepsModal.module.css';
|
||||
import {CLOSE_DESCRIPTOR, COPY_LINK_DESCRIPTOR} from '@app/features/i18n/utils/CommonMessageDescriptors';
|
||||
import {Button} from '@app/features/ui/button/Button';
|
||||
import * as ModalCommands from '@app/features/ui/commands/ModalCommands';
|
||||
import {modal} from '@app/features/ui/commands/ModalCommands';
|
||||
import * as TextCopyCommands from '@app/features/ui/commands/TextCopyCommands';
|
||||
import type {MessageDescriptor} from '@lingui/core';
|
||||
import {msg} from '@lingui/core/macro';
|
||||
import {useLingui} from '@lingui/react/macro';
|
||||
import {useCallback} from 'react';
|
||||
|
||||
export type DomainMovedStepsPlatform = 'ios' | 'mac' | 'install' | 'generic';
|
||||
|
||||
const TITLE_DESCRIPTOR = msg({
|
||||
message: 'Get the new {productName} app',
|
||||
comment: 'Title of the modal that explains how to install the app from its new domain. productName is the app name.',
|
||||
});
|
||||
const INTRO_DESCRIPTOR = msg({
|
||||
message: '{productName} now lives at {host}. Install it from there, then sign in with this app.',
|
||||
comment:
|
||||
'Intro in the modal that explains how to install the app from its new domain. productName is the app name. host is the new domain, for example fluxer.com.',
|
||||
});
|
||||
const INSTALL_INTRO_DESCRIPTOR = msg({
|
||||
message: '{productName} now lives at {host}. Install it from there.',
|
||||
comment:
|
||||
'Intro in the modal that explains how to install the app from its new domain when the account already moved. productName is the app name. host is the new domain, for example fluxer.com.',
|
||||
});
|
||||
const OPEN_IN_SAFARI_STEP_DESCRIPTOR = msg({
|
||||
message: 'Open {host} in Safari.',
|
||||
comment:
|
||||
'First install step on Apple devices. host is the new domain, for example fluxer.com. Safari is the browser.',
|
||||
});
|
||||
const OPEN_IN_BROWSER_STEP_DESCRIPTOR = msg({
|
||||
message: 'Open {host} in your browser.',
|
||||
comment: 'First install step on other devices. host is the new domain, for example fluxer.com.',
|
||||
});
|
||||
const ADD_TO_HOME_SCREEN_STEP_DESCRIPTOR = msg({
|
||||
message: 'Tap Share, then Add to Home Screen.',
|
||||
comment:
|
||||
'Second install step on iPhone and iPad. Share and Add to Home Screen are the names of the Safari menu items, use the names iOS shows in this language.',
|
||||
});
|
||||
const ADD_TO_DOCK_STEP_DESCRIPTOR = msg({
|
||||
message: 'Choose File, then Add to Dock.',
|
||||
comment:
|
||||
'Second install step on a Mac. File and Add to Dock are the names of the Safari menu items, use the names macOS shows in this language.',
|
||||
});
|
||||
const INSTALL_FROM_BROWSER_STEP_DESCRIPTOR = msg({
|
||||
message: 'Install it from your browser menu.',
|
||||
comment: 'Second install step on other devices. Refers to the install option in the browser menu.',
|
||||
});
|
||||
const SIGN_IN_STEP_DESCRIPTOR = msg({
|
||||
message:
|
||||
'Open the new app and choose Sign in with your old {productName} app. Then choose Link a new device here and enter the code it shows.',
|
||||
comment:
|
||||
'Third install step. "Sign in with your old {productName} app" and "Link a new device" are button labels and must match their translations. productName is the app name.',
|
||||
});
|
||||
|
||||
const OPEN_SIGNED_IN_STEP_DESCRIPTOR = msg({
|
||||
message: 'Open the new app. You are already signed in.',
|
||||
comment: 'Third install step in a desktop browser that already moved the account to the new domain.',
|
||||
});
|
||||
|
||||
type DomainMovedSteps = readonly [MessageDescriptor, MessageDescriptor, MessageDescriptor];
|
||||
|
||||
const PLATFORM_STEPS: Record<DomainMovedStepsPlatform, DomainMovedSteps> = {
|
||||
ios: [OPEN_IN_SAFARI_STEP_DESCRIPTOR, ADD_TO_HOME_SCREEN_STEP_DESCRIPTOR, SIGN_IN_STEP_DESCRIPTOR],
|
||||
mac: [OPEN_IN_SAFARI_STEP_DESCRIPTOR, ADD_TO_DOCK_STEP_DESCRIPTOR, SIGN_IN_STEP_DESCRIPTOR],
|
||||
install: [OPEN_IN_BROWSER_STEP_DESCRIPTOR, INSTALL_FROM_BROWSER_STEP_DESCRIPTOR, OPEN_SIGNED_IN_STEP_DESCRIPTOR],
|
||||
generic: [OPEN_IN_BROWSER_STEP_DESCRIPTOR, INSTALL_FROM_BROWSER_STEP_DESCRIPTOR, SIGN_IN_STEP_DESCRIPTOR],
|
||||
};
|
||||
|
||||
interface DomainMovedStepsModalProps {
|
||||
target: string;
|
||||
platform: DomainMovedStepsPlatform;
|
||||
}
|
||||
|
||||
function DomainMovedStepsModal({target, platform}: DomainMovedStepsModalProps) {
|
||||
const {i18n} = useLingui();
|
||||
const host = new URL(target).host;
|
||||
const values = {host, productName: PRODUCT_NAME};
|
||||
const [first, second, third] = PLATFORM_STEPS[platform];
|
||||
const handleCopy = useCallback(() => {
|
||||
void TextCopyCommands.copy(i18n, `${target}/`);
|
||||
}, [i18n, target]);
|
||||
return (
|
||||
<Modal.Root size="small" centered onClose={ModalCommands.pop} data-flx="app.domain-moved-steps-modal.modal-root">
|
||||
<Modal.Header title={i18n._(TITLE_DESCRIPTOR, values)} data-flx="app.domain-moved-steps-modal.modal-header" />
|
||||
<Modal.Content data-flx="app.domain-moved-steps-modal.modal-content">
|
||||
<Modal.ContentLayout className={styles.content} data-flx="app.domain-moved-steps-modal.content">
|
||||
<Modal.Description data-flx="app.domain-moved-steps-modal.description">
|
||||
{i18n._(platform === 'install' ? INSTALL_INTRO_DESCRIPTOR : INTRO_DESCRIPTOR, values)}
|
||||
</Modal.Description>
|
||||
<ol className={styles.steps} data-flx="app.domain-moved-steps-modal.steps">
|
||||
<li className={styles.step} data-flx="app.domain-moved-steps-modal.step-open">
|
||||
{i18n._(first, values)}
|
||||
</li>
|
||||
<li className={styles.step} data-flx="app.domain-moved-steps-modal.step-install">
|
||||
{i18n._(second, values)}
|
||||
</li>
|
||||
<li className={styles.step} data-flx="app.domain-moved-steps-modal.step-sign-in">
|
||||
{i18n._(third, values)}
|
||||
</li>
|
||||
</ol>
|
||||
</Modal.ContentLayout>
|
||||
</Modal.Content>
|
||||
<Modal.Footer data-flx="app.domain-moved-steps-modal.modal-footer">
|
||||
<Button variant="secondary" onClick={handleCopy} data-flx="app.domain-moved-steps-modal.button.copy-link">
|
||||
{i18n._(COPY_LINK_DESCRIPTOR)}
|
||||
</Button>
|
||||
<Button variant="primary" onClick={ModalCommands.pop} data-flx="app.domain-moved-steps-modal.button.close">
|
||||
{i18n._(CLOSE_DESCRIPTOR)}
|
||||
</Button>
|
||||
</Modal.Footer>
|
||||
</Modal.Root>
|
||||
);
|
||||
}
|
||||
|
||||
export function showDomainMovedStepsModal(target: string, platform: DomainMovedStepsPlatform): void {
|
||||
ModalCommands.push(
|
||||
modal(() => (
|
||||
<DomainMovedStepsModal
|
||||
target={target}
|
||||
platform={platform}
|
||||
data-flx="app.domain-moved-steps-modal.show-domain-moved-steps-modal.domain-moved-steps-modal"
|
||||
/>
|
||||
)),
|
||||
);
|
||||
}
|
||||
@@ -54,7 +54,12 @@ const MIN_CHECK_INTERVAL_MS = 60 * 1000;
|
||||
const MANUAL_DOWNLOAD_REFRESH_TIMEOUT_MS = 5 * 1000;
|
||||
const VERSION_ENDPOINT = '/version.json';
|
||||
const CURRENT_BUILD_VERSION = Config.PUBLIC_BUILD_VERSION ?? null;
|
||||
const ALLOWED_WEB_UPDATE_HOSTS = new Set(['web.fluxer.app', 'web.canary.fluxer.app']);
|
||||
const ALLOWED_WEB_UPDATE_HOSTS = new Set([
|
||||
'web.fluxer.app',
|
||||
'web.canary.fluxer.app',
|
||||
'fluxer.com',
|
||||
'canary.fluxer.com',
|
||||
]);
|
||||
|
||||
function normalizeUpdaterContext(context: NativeUpdaterEvent['context']): UpdaterContext {
|
||||
switch (context) {
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {isPasskeyBridgeAvailable} from '@app/features/auth/utils/PasskeyBridge';
|
||||
import {Button} from '@app/features/ui/button/Button';
|
||||
import {WarningAlert} from '@app/features/ui/warning_alert/WarningAlert';
|
||||
import {msg} from '@lingui/core/macro';
|
||||
import {useLingui} from '@lingui/react/macro';
|
||||
import {PasswordIcon} from '@phosphor-icons/react';
|
||||
import type React from 'react';
|
||||
|
||||
const USE_A_PASSWORD_MANAGER_PASSKEY_DESCRIPTOR = msg({
|
||||
message: 'Use a password manager passkey',
|
||||
comment:
|
||||
'Button that runs the passkey prompt in a small pop-up window on the old web address, so password manager extensions can find passkeys saved there.',
|
||||
});
|
||||
const PASSWORD_MANAGER_PASSKEY_SUGGESTION_DESCRIPTOR = msg({
|
||||
message:
|
||||
'Password managers may not offer your passkey on this web address. Try it in a pop-up window on the old address instead.',
|
||||
comment:
|
||||
'Shown after a passkey prompt fails, above the button that retries the passkey prompt in a pop-up window on the old web address.',
|
||||
});
|
||||
|
||||
interface PasswordManagerPasskeyActionProps {
|
||||
suggested: boolean;
|
||||
disabled?: boolean;
|
||||
onClick: (event: React.MouseEvent<HTMLButtonElement> | React.KeyboardEvent<HTMLButtonElement>) => void;
|
||||
}
|
||||
|
||||
export function PasswordManagerPasskeyAction({suggested, disabled, onClick}: PasswordManagerPasskeyActionProps) {
|
||||
const {i18n} = useLingui();
|
||||
if (!isPasskeyBridgeAvailable()) {
|
||||
return null;
|
||||
}
|
||||
const button = (
|
||||
<Button
|
||||
type="button"
|
||||
fitContainer
|
||||
variant={suggested ? 'primary' : 'secondary'}
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
leftIcon={<PasswordIcon size={16} data-flx="auth.password-manager-passkey-action.icon" />}
|
||||
data-flx="auth.password-manager-passkey-action.button"
|
||||
>
|
||||
{i18n._(USE_A_PASSWORD_MANAGER_PASSKEY_DESCRIPTOR)}
|
||||
</Button>
|
||||
);
|
||||
if (!suggested) {
|
||||
return button;
|
||||
}
|
||||
return (
|
||||
<WarningAlert actions={button} data-flx="auth.password-manager-passkey-action.suggestion">
|
||||
{i18n._(PASSWORD_MANAGER_PASSKEY_SUGGESTION_DESCRIPTOR)}
|
||||
</WarningAlert>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,12 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import * as Modal from '@app/features/app/components/dialogs/Modal';
|
||||
import {PasswordManagerPasskeyAction} from '@app/features/auth/components/PasswordManagerPasskeyAction';
|
||||
import {describePasskeyBridgeFailure, shouldSuggestPasskeyBridge} from '@app/features/auth/utils/PasskeyBridge';
|
||||
import {
|
||||
PASSKEY_DOMAIN_UNSUPPORTED_DESCRIPTOR,
|
||||
PasskeyDomainUnsupportedError,
|
||||
} from '@app/features/auth/utils/WebAuthnUtils';
|
||||
import {HttpError} from '@app/features/platform/types/EndpointError';
|
||||
import {Button} from '@app/features/ui/button/Button';
|
||||
import * as ModalCommands from '@app/features/ui/commands/ModalCommands';
|
||||
@@ -10,6 +16,8 @@ import * as FormUtils from '@app/lib/forms';
|
||||
import {msg} from '@lingui/core/macro';
|
||||
import {Trans, useLingui} from '@lingui/react/macro';
|
||||
import {observer} from 'mobx-react-lite';
|
||||
import type React from 'react';
|
||||
import {useState} from 'react';
|
||||
import {useForm} from 'react-hook-form';
|
||||
|
||||
const NAME_PASSKEY_FORM_DESCRIPTOR = msg({
|
||||
@@ -33,16 +41,58 @@ interface FormInputs {
|
||||
name: string;
|
||||
}
|
||||
|
||||
export const PasskeyNameModal = observer(({onSubmit}: {onSubmit: (name: string) => void | Promise<void>}) => {
|
||||
interface PasskeyNameModalProps {
|
||||
onSubmit: (name: string) => void | Promise<void>;
|
||||
onSubmitWithPasswordManager?: (name: string) => Promise<void>;
|
||||
}
|
||||
|
||||
export const PasskeyNameModal = observer(({onSubmit, onSubmitWithPasswordManager}: PasskeyNameModalProps) => {
|
||||
const {i18n} = useLingui();
|
||||
const form = useForm<FormInputs>();
|
||||
const [passkeyBridgeSuggested, setPasskeyBridgeSuggested] = useState(false);
|
||||
const [passkeyBridgeSubmitting, setPasskeyBridgeSubmitting] = useState(false);
|
||||
const handlePasswordManagerSubmit = (
|
||||
event: React.MouseEvent<HTMLButtonElement> | React.KeyboardEvent<HTMLButtonElement>,
|
||||
) => {
|
||||
if (!onSubmitWithPasswordManager || passkeyBridgeSubmitting || form.formState.isSubmitting) {
|
||||
return;
|
||||
}
|
||||
if (event.currentTarget.form?.reportValidity() === false) {
|
||||
return;
|
||||
}
|
||||
const submission = onSubmitWithPasswordManager(form.getValues('name').trim());
|
||||
form.clearErrors('name');
|
||||
setPasskeyBridgeSubmitting(true);
|
||||
submission
|
||||
.then(() => {
|
||||
ModalCommands.pop();
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
if (error instanceof HttpError) {
|
||||
FormUtils.handleError(i18n, form, error, 'name');
|
||||
return;
|
||||
}
|
||||
const descriptor = describePasskeyBridgeFailure(error);
|
||||
if (descriptor) {
|
||||
form.setError('name', {type: 'server', message: i18n._(descriptor)});
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
setPasskeyBridgeSubmitting(false);
|
||||
});
|
||||
};
|
||||
const handleSubmit = async (data: FormInputs) => {
|
||||
try {
|
||||
await onSubmit(data.name.trim());
|
||||
ModalCommands.pop();
|
||||
} catch (error) {
|
||||
if (onSubmitWithPasswordManager && shouldSuggestPasskeyBridge(error)) {
|
||||
setPasskeyBridgeSuggested(true);
|
||||
}
|
||||
if (error instanceof HttpError) {
|
||||
FormUtils.handleError(i18n, form, error, 'name');
|
||||
} else if (error instanceof PasskeyDomainUnsupportedError) {
|
||||
form.setError('name', {type: 'server', message: i18n._(PASSKEY_DOMAIN_UNSUPPORTED_DESCRIPTOR)});
|
||||
} else {
|
||||
form.setError('name', {type: 'server', message: FormUtils.extractErrorMessage(i18n, error)});
|
||||
}
|
||||
@@ -73,6 +123,14 @@ export const PasskeyNameModal = observer(({onSubmit}: {onSubmit: (name: string)
|
||||
required={true}
|
||||
type="text"
|
||||
/>
|
||||
{onSubmitWithPasswordManager && (
|
||||
<PasswordManagerPasskeyAction
|
||||
suggested={passkeyBridgeSuggested}
|
||||
disabled={form.formState.isSubmitting || passkeyBridgeSubmitting}
|
||||
onClick={handlePasswordManagerSubmit}
|
||||
data-flx="auth.passkey-name-modal.password-manager-passkey-action"
|
||||
/>
|
||||
)}
|
||||
</Modal.ContentLayout>
|
||||
</Modal.Content>
|
||||
<Modal.Footer data-flx="auth.passkey-name-modal.modal-footer">
|
||||
@@ -82,6 +140,7 @@ export const PasskeyNameModal = observer(({onSubmit}: {onSubmit: (name: string)
|
||||
<Button
|
||||
type="submit"
|
||||
submitting={form.formState.isSubmitting}
|
||||
disabled={passkeyBridgeSubmitting}
|
||||
data-flx="auth.passkey-name-modal.button.submit"
|
||||
>
|
||||
<Trans>Save</Trans>
|
||||
|
||||
@@ -3,7 +3,13 @@
|
||||
import * as Modal from '@app/features/app/components/dialogs/Modal';
|
||||
import {Endpoints} from '@app/features/app/constants/Endpoints';
|
||||
import styles from '@app/features/auth/components/modals/SudoVerificationModal.module.css';
|
||||
import {PasswordManagerPasskeyAction} from '@app/features/auth/components/PasswordManagerPasskeyAction';
|
||||
import SudoPrompt, {SudoVerificationMethod} from '@app/features/auth/state/SudoPrompt';
|
||||
import {
|
||||
describePasskeyBridgeFailure,
|
||||
runPasskeyViaBridge,
|
||||
shouldSuggestPasskeyBridge,
|
||||
} from '@app/features/auth/utils/PasskeyBridge';
|
||||
import * as WebAuthnUtils from '@app/features/auth/utils/WebAuthnUtils';
|
||||
import {PASSWORD_DESCRIPTOR, VERIFY_DESCRIPTOR} from '@app/features/i18n/utils/CommonMessageDescriptors';
|
||||
import {http} from '@app/features/platform/transport/RestTransport';
|
||||
@@ -15,6 +21,7 @@ import {Spinner} from '@app/features/ui/components/Spinner';
|
||||
import * as FormUtils from '@app/lib/forms';
|
||||
import {msg} from '@lingui/core/macro';
|
||||
import {Trans, useLingui} from '@lingui/react/macro';
|
||||
import type {PublicKeyCredentialRequestOptionsJSON} from '@simplewebauthn/browser';
|
||||
import {observer} from 'mobx-react-lite';
|
||||
import type React from 'react';
|
||||
import {useEffect, useRef, useState} from 'react';
|
||||
@@ -82,6 +89,7 @@ const SudoVerificationModal: React.FC = observer(() => {
|
||||
const form = useForm<FormInputs>({defaultValues: {password: '', totp: ''}});
|
||||
const [webAuthnInFlight, setWebAuthnInFlight] = useState(false);
|
||||
const [webAuthnError, setWebAuthnError] = useState<string | null>(null);
|
||||
const [passkeyBridgeSuggested, setPasskeyBridgeSuggested] = useState(false);
|
||||
const autoTriggeredRef = useRef(false);
|
||||
const showPasskey = availableMethods.webauthn;
|
||||
const showTotp = availableMethods.totp;
|
||||
@@ -126,9 +134,43 @@ const SudoVerificationModal: React.FC = observer(() => {
|
||||
setWebAuthnError(i18n._(PASSKEYS_REQUIRE_A_SIGNED_MACOS_BUNDLE_WITH_A_DESCRIPTOR));
|
||||
return;
|
||||
}
|
||||
if (shouldSuggestPasskeyBridge(err)) {
|
||||
setPasskeyBridgeSuggested(true);
|
||||
return;
|
||||
}
|
||||
if (err instanceof WebAuthnUtils.PasskeyDomainUnsupportedError) {
|
||||
setWebAuthnError(i18n._(WebAuthnUtils.PASSKEY_DOMAIN_UNSUPPORTED_DESCRIPTOR));
|
||||
return;
|
||||
}
|
||||
setWebAuthnError(i18n._(COULDN_T_VERIFY_WITH_PASSKEY_PLEASE_TRY_AGAIN_DESCRIPTOR));
|
||||
}
|
||||
};
|
||||
const handlePasskeyBridge = () => {
|
||||
if (webAuthnInFlight || isVerifying) return;
|
||||
const options = http
|
||||
.post<PublicKeyCredentialRequestOptionsJSON>(Endpoints.SUDO_WEBAUTHN_OPTIONS)
|
||||
.then((response) => response.body);
|
||||
const credential = runPasskeyViaBridge('authenticate', options);
|
||||
setWebAuthnError(null);
|
||||
form.clearErrors();
|
||||
setWebAuthnInFlight(true);
|
||||
Promise.all([options, credential])
|
||||
.then(([resolvedOptions, resolvedCredential]) => {
|
||||
SudoPrompt.submit({
|
||||
mfa_method: SudoVerificationMethod.WEBAUTHN,
|
||||
webauthn_challenge: resolvedOptions.challenge,
|
||||
webauthn_response: resolvedCredential,
|
||||
});
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
logger.error('WebAuthn verification in the pop-up window failed', err);
|
||||
setWebAuthnInFlight(false);
|
||||
const descriptor = describePasskeyBridgeFailure(err);
|
||||
if (descriptor) {
|
||||
setWebAuthnError(i18n._(descriptor));
|
||||
}
|
||||
});
|
||||
};
|
||||
useEffect(() => {
|
||||
if (autoTriggeredRef.current) return;
|
||||
if (!showPasskey || showPassword || showCode) return;
|
||||
@@ -245,6 +287,14 @@ const SudoVerificationModal: React.FC = observer(() => {
|
||||
<Trans>Continue with passkey</Trans>
|
||||
</Button>
|
||||
)}
|
||||
{!webAuthnInFlight && (
|
||||
<PasswordManagerPasskeyAction
|
||||
suggested={passkeyBridgeSuggested}
|
||||
disabled={isVerifying}
|
||||
onClick={handlePasskeyBridge}
|
||||
data-flx="auth.sudo-verification-modal.password-manager-passkey-action"
|
||||
/>
|
||||
)}
|
||||
{webAuthnError && (
|
||||
<p className={styles.formError} role="alert" data-flx="auth.sudo-verification-modal.form-error">
|
||||
{webAuthnError}
|
||||
|
||||
+2
-1
@@ -26,6 +26,7 @@ import {
|
||||
selectAuthorizePhase,
|
||||
transitionAuthorizeSnapshot,
|
||||
} from '@app/features/auth/components/pages/oauth_authorize_page/state/authorizeMachine';
|
||||
import {getDefaultLandingPath} from '@app/features/navigation/utils/DefaultLandingUtils';
|
||||
import type {BotPermissionOption} from '@app/features/permissions/utils/PermissionUtils';
|
||||
import {http} from '@app/features/platform/transport/RestTransport';
|
||||
import {failureMessage} from '@app/features/platform/utils/ResponseInspection';
|
||||
@@ -425,7 +426,7 @@ export function useAuthorizeFlow(options: UseAuthorizeFlowOptions = {}): Authori
|
||||
window.location.href = url.toString();
|
||||
return;
|
||||
}
|
||||
window.location.href = '/';
|
||||
window.location.href = getDefaultLandingPath();
|
||||
} catch (err) {
|
||||
logger.error('Failed to redirect on cancel', err);
|
||||
setSubmitting(null);
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {PRODUCT_NAME} from '@app/features/app/config/I18nDisplayConstants';
|
||||
import {detectDomainMigrationInstallKind} from '@app/features/app/domain_migration/DomainMigrationBrowser';
|
||||
import RuntimeConfig from '@app/features/app/state/RuntimeConfig';
|
||||
import * as AuthenticationCommands from '@app/features/auth/commands/AuthenticationCommands';
|
||||
import {AccountSelector} from '@app/features/auth/components/accounts/AccountSelector';
|
||||
@@ -17,6 +19,10 @@ import AuthLoginPasskeyActions, {
|
||||
AuthLoginDivider,
|
||||
} from '@app/features/auth/flow/auth_login_core/AuthLoginPasskeyActions';
|
||||
import {isApprovalFlowMode, useDesktopHandoffFlow} from '@app/features/auth/flow/auth_login_core/useDesktopHandoffFlow';
|
||||
import {
|
||||
SIGN_IN_WITH_OLD_APP_DESCRIPTOR,
|
||||
showBrowserLoginHandoffModal,
|
||||
} from '@app/features/auth/flow/BrowserLoginHandoffModal';
|
||||
import DesktopHandoffAccountSelector from '@app/features/auth/flow/DesktopHandoffAccountSelector';
|
||||
import {ConnectedHandoffApprovalFlow} from '@app/features/auth/flow/HandoffApprovalFlow';
|
||||
import IpAuthorizationScreen from '@app/features/auth/flow/IpAuthorizationScreen';
|
||||
@@ -28,6 +34,7 @@ import {
|
||||
type LoginSuccessPayload,
|
||||
startSsoLogin,
|
||||
} from '@app/features/auth/state/AuthFlow';
|
||||
import {shouldOfferOldAppSignIn} from '@app/features/auth/utils/OldAppSignIn';
|
||||
import {NEED_ACCOUNT_DESCRIPTOR, SIGN_IN_DESCRIPTOR} from '@app/features/i18n/utils/CommonMessageDescriptors';
|
||||
import * as RouterUtils from '@app/features/navigation/utils/RouterUtils';
|
||||
import {useLocation} from '@app/features/platform/components/router/RouterReact';
|
||||
@@ -64,6 +71,11 @@ const FORGOT_PASSWORD_DESCRIPTOR = msg({
|
||||
message: 'Forgot your password?',
|
||||
comment: 'Authentication link label that opens password recovery.',
|
||||
});
|
||||
const OLD_APP_SIGN_IN_HINT_DESCRIPTOR = msg({
|
||||
message: 'Approve this app from the {productName} app you already use. No password needed.',
|
||||
comment:
|
||||
'Hint under the sign-in option on fluxer.com that pairs a newly installed app with the old installed app. productName is the app name.',
|
||||
});
|
||||
const SIGN_IN_VIA_BROWSER_DESCRIPTOR = msg({
|
||||
message: 'Sign in via browser',
|
||||
comment: 'Passkey sign-in action that opens the browser flow from the desktop app.',
|
||||
@@ -150,20 +162,50 @@ export const AuthLoginLayout = observer(function AuthLoginLayout({
|
||||
},
|
||||
[desktopHandoff, handoff, onLoginComplete],
|
||||
);
|
||||
const {form, isLoading, fieldErrors, handlePasskeyLogin, handlePasskeyBrowserLogin, isPasskeyLoading} =
|
||||
useLoginFormController({
|
||||
redirectPath,
|
||||
inviteCode,
|
||||
onLoginSuccess: handleLoginSuccess,
|
||||
onRequireMfa: (challenge) => {
|
||||
AuthenticationCommands.setMfaTicket(challenge);
|
||||
},
|
||||
onRequireIpAuthorization: (challenge) => {
|
||||
setIpAuthChallenge(challenge);
|
||||
},
|
||||
});
|
||||
const {
|
||||
form,
|
||||
isLoading,
|
||||
fieldErrors,
|
||||
handlePasskeyLogin,
|
||||
handlePasskeyBrowserLogin,
|
||||
handlePasskeyBridgeLogin,
|
||||
passkeyBridgeSuggested,
|
||||
isPasskeyLoading,
|
||||
} = useLoginFormController({
|
||||
redirectPath,
|
||||
inviteCode,
|
||||
onLoginSuccess: handleLoginSuccess,
|
||||
onRequireMfa: (challenge) => {
|
||||
AuthenticationCommands.setMfaTicket(challenge);
|
||||
},
|
||||
onRequireIpAuthorization: (challenge) => {
|
||||
setIpAuthChallenge(challenge);
|
||||
},
|
||||
});
|
||||
const showBrowserPasskey = IS_DEV || isDesktop();
|
||||
const passkeyControlsDisabled = isLoading || Boolean(form.isSubmitting) || isPasskeyLoading;
|
||||
const offerOldAppSignIn = useMemo(
|
||||
() =>
|
||||
!desktopHandoff &&
|
||||
shouldOfferOldAppSignIn({
|
||||
origin: window.location.origin,
|
||||
installKind: detectDomainMigrationInstallKind(),
|
||||
hasStoredAccounts,
|
||||
}),
|
||||
[desktopHandoff, hasStoredAccounts],
|
||||
);
|
||||
const handleOldAppSignIn = useCallback(() => {
|
||||
showBrowserLoginHandoffModal(
|
||||
async (payload) => {
|
||||
await handleLoginSuccess(payload);
|
||||
if (redirectPath) {
|
||||
RouterUtils.replaceWith(redirectPath);
|
||||
}
|
||||
},
|
||||
undefined,
|
||||
'old_app',
|
||||
);
|
||||
}, [handleLoginSuccess, redirectPath]);
|
||||
const handleIpAuthorizationComplete = useCallback(
|
||||
async (payload: LoginSuccessPayload) => {
|
||||
await handleLoginSuccess(payload);
|
||||
@@ -327,6 +369,21 @@ export const AuthLoginLayout = observer(function AuthLoginLayout({
|
||||
{switchError}
|
||||
</div>
|
||||
) : null}
|
||||
{offerOldAppSignIn ? (
|
||||
<div className={styles.ssoBlock} data-flx="auth.flow.auth-login-layout.old-app-block">
|
||||
<Button
|
||||
fitContainer
|
||||
onClick={handleOldAppSignIn}
|
||||
type="button"
|
||||
data-flx="auth.flow.auth-login-layout.button.old-app-sign-in"
|
||||
>
|
||||
{i18n._(SIGN_IN_WITH_OLD_APP_DESCRIPTOR, {productName: PRODUCT_NAME})}
|
||||
</Button>
|
||||
<div className={styles.ssoSubtitle} data-flx="auth.flow.auth-login-layout.old-app-subtitle">
|
||||
{i18n._(OLD_APP_SIGN_IN_HINT_DESCRIPTOR, {productName: PRODUCT_NAME})}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{ssoConfig?.enabled ? (
|
||||
<div className={styles.ssoBlock} data-flx="auth.flow.auth-login-layout.sso-block">
|
||||
<Button
|
||||
@@ -376,6 +433,8 @@ export const AuthLoginLayout = observer(function AuthLoginLayout({
|
||||
onPasskeyLogin={handlePasskeyLogin}
|
||||
showBrowserOption={showBrowserPasskey}
|
||||
onBrowserLogin={handlePasskeyBrowserLogin}
|
||||
onPasswordManagerLogin={handlePasskeyBridgeLogin}
|
||||
passwordManagerSuggested={passkeyBridgeSuggested}
|
||||
browserLabel={i18n._(SIGN_IN_VIA_BROWSER_DESCRIPTOR)}
|
||||
data-flx="auth.flow.auth-login-layout.auth-login-passkey-actions"
|
||||
/>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import * as Modal from '@app/features/app/components/dialogs/Modal';
|
||||
import {PRODUCT_NAME} from '@app/features/app/config/I18nDisplayConstants';
|
||||
import RuntimeConfig from '@app/features/app/state/RuntimeConfig';
|
||||
import * as AuthenticationCommands from '@app/features/auth/commands/AuthenticationCommands';
|
||||
import styles from '@app/features/auth/flow/BrowserLoginHandoffModal.module.css';
|
||||
@@ -23,15 +24,25 @@ const ADD_ACCOUNT_DESCRIPTOR = msg({
|
||||
comment: 'Short label in the authentication browser login handoff modal. Keep the tone plain and specific.',
|
||||
});
|
||||
|
||||
export const SIGN_IN_WITH_OLD_APP_DESCRIPTOR = msg({
|
||||
message: 'Sign in with your old {productName} app',
|
||||
comment:
|
||||
'Sign-in option and modal title on fluxer.com that pairs a newly installed app with the old installed app still signed in on the previous domain. productName is the app name.',
|
||||
});
|
||||
|
||||
export type BrowserLoginHandoffVariant = 'browser' | 'old_app';
|
||||
|
||||
interface BrowserLoginHandoffModalProps {
|
||||
onSuccess: (payload: LoginSuccessPayload) => Promise<void>;
|
||||
prefillEmail?: string;
|
||||
variant?: BrowserLoginHandoffVariant;
|
||||
}
|
||||
|
||||
const POLL_INTERVAL_MS = 2000;
|
||||
|
||||
const BrowserLoginHandoffModal = observer(({onSuccess, prefillEmail}: BrowserLoginHandoffModalProps) => {
|
||||
const BrowserLoginHandoffModal = observer(({onSuccess, prefillEmail, variant}: BrowserLoginHandoffModalProps) => {
|
||||
const {i18n} = useLingui();
|
||||
const isOldAppVariant = variant === 'old_app';
|
||||
const currentWebAppUrl = RuntimeConfig.webAppBaseUrl;
|
||||
const [handoffCode, setHandoffCode] = useState<string | null>(null);
|
||||
const [handoffExpiresAt, setHandoffExpiresAt] = useState<string | null>(null);
|
||||
@@ -104,13 +115,21 @@ const BrowserLoginHandoffModal = observer(({onSuccess, prefillEmail}: BrowserLog
|
||||
data-flx="auth.flow.browser-login-handoff-modal.modal-root"
|
||||
>
|
||||
<Modal.Header
|
||||
title={i18n._(ADD_ACCOUNT_DESCRIPTOR)}
|
||||
title={
|
||||
isOldAppVariant
|
||||
? i18n._(SIGN_IN_WITH_OLD_APP_DESCRIPTOR, {productName: PRODUCT_NAME})
|
||||
: i18n._(ADD_ACCOUNT_DESCRIPTOR)
|
||||
}
|
||||
data-flx="auth.flow.browser-login-handoff-modal.modal-header"
|
||||
/>
|
||||
<Modal.Content data-flx="auth.flow.browser-login-handoff-modal.modal-content">
|
||||
<Modal.ContentLayout className={styles.content} data-flx="auth.flow.browser-login-handoff-modal.content">
|
||||
<Modal.Description data-flx="auth.flow.browser-login-handoff-modal.description">
|
||||
<Trans>Open your browser, sign in, then enter the code below to link your account.</Trans>
|
||||
{isOldAppVariant ? (
|
||||
<Trans>Open your old {PRODUCT_NAME} app and choose Link a new device, then enter the code below.</Trans>
|
||||
) : (
|
||||
<Trans>Open your browser, sign in, then enter the code below to link your account.</Trans>
|
||||
)}
|
||||
</Modal.Description>
|
||||
<HandoffCodeDisplay
|
||||
code={handoffCode}
|
||||
@@ -118,9 +137,14 @@ const BrowserLoginHandoffModal = observer(({onSuccess, prefillEmail}: BrowserLog
|
||||
isGenerating={isGenerating}
|
||||
error={error}
|
||||
onRetry={generateCode}
|
||||
description={
|
||||
isOldAppVariant ? (
|
||||
<Trans>Enter this code in your old {PRODUCT_NAME} app to complete sign-in.</Trans>
|
||||
) : undefined
|
||||
}
|
||||
data-flx="auth.flow.browser-login-handoff-modal.handoff-code-display"
|
||||
/>
|
||||
{prefillEmail ? (
|
||||
{prefillEmail && !isOldAppVariant ? (
|
||||
<Modal.Description
|
||||
className={styles.prefillHint}
|
||||
data-flx="auth.flow.browser-login-handoff-modal.prefill-hint"
|
||||
@@ -139,19 +163,21 @@ const BrowserLoginHandoffModal = observer(({onSuccess, prefillEmail}: BrowserLog
|
||||
>
|
||||
<Trans>Cancel</Trans>
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={handleOpenBrowser}
|
||||
submitting={isGenerating}
|
||||
data-flx="auth.flow.browser-login-handoff-modal.button.open-browser"
|
||||
>
|
||||
<ArrowSquareOutIcon
|
||||
size={remFromPx(16)}
|
||||
weight="bold"
|
||||
data-flx="auth.flow.browser-login-handoff-modal.arrow-square-out-icon"
|
||||
/>
|
||||
<Trans>Open browser</Trans>
|
||||
</Button>
|
||||
{isOldAppVariant ? null : (
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={handleOpenBrowser}
|
||||
submitting={isGenerating}
|
||||
data-flx="auth.flow.browser-login-handoff-modal.button.open-browser"
|
||||
>
|
||||
<ArrowSquareOutIcon
|
||||
size={remFromPx(16)}
|
||||
weight="bold"
|
||||
data-flx="auth.flow.browser-login-handoff-modal.arrow-square-out-icon"
|
||||
/>
|
||||
<Trans>Open browser</Trans>
|
||||
</Button>
|
||||
)}
|
||||
</Modal.Footer>
|
||||
</Modal.Root>
|
||||
);
|
||||
@@ -160,6 +186,7 @@ const BrowserLoginHandoffModal = observer(({onSuccess, prefillEmail}: BrowserLog
|
||||
export function showBrowserLoginHandoffModal(
|
||||
onSuccess: (payload: LoginSuccessPayload) => Promise<void>,
|
||||
prefillEmail?: string,
|
||||
variant: BrowserLoginHandoffVariant = 'browser',
|
||||
): void {
|
||||
ModalCommands.push(
|
||||
modal(() => (
|
||||
@@ -168,6 +195,7 @@ export function showBrowserLoginHandoffModal(
|
||||
await onSuccess(payload);
|
||||
}}
|
||||
prefillEmail={prefillEmail}
|
||||
variant={variant}
|
||||
data-flx="auth.flow.browser-login-handoff-modal.show-browser-login-handoff-modal.browser-login-handoff-modal"
|
||||
/>
|
||||
)),
|
||||
|
||||
@@ -66,7 +66,7 @@ const DesktopHandoffAccountSelector = observer(function DesktopHandoffAccountSel
|
||||
<AccountSelector
|
||||
accounts={accounts}
|
||||
title={<Trans>Choose an account</Trans>}
|
||||
description={<Trans>Select the account you want to sign in with on the desktop app.</Trans>}
|
||||
description={<Trans>Select the account you want to sign in with on your new device.</Trans>}
|
||||
disabled={isLoading}
|
||||
error={error}
|
||||
clickableRows
|
||||
|
||||
@@ -6,7 +6,7 @@ import {Button} from '@app/features/ui/button/Button';
|
||||
import * as TextCopyCommands from '@app/features/ui/commands/TextCopyCommands';
|
||||
import {Trans, useLingui} from '@lingui/react/macro';
|
||||
import {CheckCircleIcon, ClipboardIcon} from '@phosphor-icons/react';
|
||||
import {useCallback, useEffect, useRef, useState} from 'react';
|
||||
import {type ReactNode, useCallback, useEffect, useRef, useState} from 'react';
|
||||
|
||||
interface HandoffCodeDisplayProps {
|
||||
code: string | null;
|
||||
@@ -14,6 +14,7 @@ interface HandoffCodeDisplayProps {
|
||||
isGenerating: boolean;
|
||||
error: string | null;
|
||||
onRetry?: () => void;
|
||||
description?: ReactNode;
|
||||
}
|
||||
|
||||
function useCountdown(expiresAt: string | null): number | null {
|
||||
@@ -48,7 +49,14 @@ function useCountdown(expiresAt: string | null): number | null {
|
||||
return remaining;
|
||||
}
|
||||
|
||||
export function HandoffCodeDisplay({code, expiresAt, isGenerating, error, onRetry}: HandoffCodeDisplayProps) {
|
||||
export function HandoffCodeDisplay({
|
||||
code,
|
||||
expiresAt,
|
||||
isGenerating,
|
||||
error,
|
||||
onRetry,
|
||||
description,
|
||||
}: HandoffCodeDisplayProps) {
|
||||
const {i18n} = useLingui();
|
||||
const [copied, setCopied] = useState(false);
|
||||
const remaining = useCountdown(expiresAt);
|
||||
@@ -121,7 +129,7 @@ export function HandoffCodeDisplay({code, expiresAt, isGenerating, error, onRetr
|
||||
<Trans>Your code is ready</Trans>
|
||||
</h1>
|
||||
<p className={styles.description} data-flx="auth.flow.handoff-code-display.description">
|
||||
<Trans>Enter this code in your browser to complete sign-in.</Trans>
|
||||
{description ?? <Trans>Enter this code in your browser to complete sign-in.</Trans>}
|
||||
</p>
|
||||
<div className={styles.codeSection} data-flx="auth.flow.handoff-code-display.code-section">
|
||||
<p className={styles.codeLabel} data-flx="auth.flow.handoff-code-display.code-label">
|
||||
|
||||
@@ -30,6 +30,9 @@
|
||||
}
|
||||
|
||||
.webauthnSection {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {MFA_CODE_DIGIT_COUNT} from '@app/features/app/config/I18nDisplayConstants';
|
||||
import {PasswordManagerPasskeyAction} from '@app/features/auth/components/PasswordManagerPasskeyAction';
|
||||
import FormField from '@app/features/auth/flow/AuthFormField';
|
||||
import styles from '@app/features/auth/flow/MfaScreen.module.css';
|
||||
import {useAuthCardPresentation} from '@app/features/auth/flow/useAuthCardPresentation';
|
||||
@@ -53,7 +54,16 @@ interface MfaScreenProps {
|
||||
|
||||
const MfaScreen = ({challenge, inviteCode, onSuccess, onCancel}: MfaScreenProps) => {
|
||||
const {i18n} = useLingui();
|
||||
const {form, isLoading, fieldErrors, handleWebAuthn, isWebAuthnLoading, supports} = useMfaController({
|
||||
const {
|
||||
form,
|
||||
isLoading,
|
||||
fieldErrors,
|
||||
handleWebAuthn,
|
||||
handlePasskeyBridge,
|
||||
passkeyBridgeSuggested,
|
||||
isWebAuthnLoading,
|
||||
supports,
|
||||
} = useMfaController({
|
||||
ticket: challenge.ticket,
|
||||
methods: {totp: challenge.totp, webauthn: challenge.webauthn, backupCodes: challenge.backupCodes},
|
||||
inviteCode,
|
||||
@@ -128,6 +138,12 @@ const MfaScreen = ({challenge, inviteCode, onSuccess, onCancel}: MfaScreenProps)
|
||||
>
|
||||
{i18n._(isCodePrimary ? TRY_SECURITY_KEY_INSTEAD_DESCRIPTOR : SECURITY_KEY_OR_PASSKEY_DESCRIPTOR)}
|
||||
</Button>
|
||||
<PasswordManagerPasskeyAction
|
||||
suggested={passkeyBridgeSuggested}
|
||||
disabled={isWebAuthnLoading}
|
||||
onClick={handlePasskeyBridge}
|
||||
data-flx="auth.flow.mfa-screen.password-manager-passkey-action"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className={styles.footerButtons} data-flx="auth.flow.mfa-screen.footer-buttons">
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {PasswordManagerPasskeyAction} from '@app/features/auth/components/PasswordManagerPasskeyAction';
|
||||
import {Button} from '@app/features/ui/button/Button';
|
||||
import {Trans} from '@lingui/react/macro';
|
||||
import {BrowserIcon, KeyIcon} from '@phosphor-icons/react';
|
||||
@@ -50,6 +51,8 @@ interface Props {
|
||||
onPasskeyLogin: () => void;
|
||||
showBrowserOption: boolean;
|
||||
onBrowserLogin?: () => void;
|
||||
onPasswordManagerLogin?: () => void;
|
||||
passwordManagerSuggested?: boolean;
|
||||
primaryLabel?: React.ReactNode;
|
||||
browserLabel?: React.ReactNode;
|
||||
}
|
||||
@@ -60,6 +63,8 @@ export default function AuthLoginPasskeyActions({
|
||||
onPasskeyLogin,
|
||||
showBrowserOption,
|
||||
onBrowserLogin,
|
||||
onPasswordManagerLogin,
|
||||
passwordManagerSuggested = false,
|
||||
primaryLabel = <Trans>Sign in with a passkey</Trans>,
|
||||
browserLabel = <Trans>Sign in via browser</Trans>,
|
||||
}: Props) {
|
||||
@@ -91,6 +96,14 @@ export default function AuthLoginPasskeyActions({
|
||||
{browserLabel}
|
||||
</Button>
|
||||
) : null}
|
||||
{onPasswordManagerLogin ? (
|
||||
<PasswordManagerPasskeyAction
|
||||
suggested={passwordManagerSuggested}
|
||||
disabled={disabled}
|
||||
onClick={onPasswordManagerLogin}
|
||||
data-flx="auth.flow.auth-login-core.auth-login-passkey-actions.password-manager-passkey-action"
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -16,10 +16,18 @@ import {
|
||||
loginWithPassword,
|
||||
type MfaChallenge,
|
||||
} from '@app/features/auth/state/AuthFlow';
|
||||
import {
|
||||
describePasskeyBridgeFailure,
|
||||
runPasskeyViaBridge,
|
||||
shouldSuggestPasskeyBridge,
|
||||
} from '@app/features/auth/utils/PasskeyBridge';
|
||||
import * as WebAuthnUtils from '@app/features/auth/utils/WebAuthnUtils';
|
||||
import * as RouterUtils from '@app/features/navigation/utils/RouterUtils';
|
||||
import {Logger} from '@app/features/platform/utils/AppLogger';
|
||||
import * as ToastCommands from '@app/features/ui/commands/ToastCommands';
|
||||
import {isDesktop} from '@app/features/ui/utils/NativeUtils';
|
||||
import {useLingui} from '@lingui/react/macro';
|
||||
import type {AuthenticationResponseJSON, PublicKeyCredentialRequestOptionsJSON} from '@simplewebauthn/browser';
|
||||
import {useCallback, useMemo, useRef, useState} from 'react';
|
||||
|
||||
const logger = Logger.create('useLoginFlow');
|
||||
@@ -87,7 +95,9 @@ export function useLoginFormController({
|
||||
onRequireMfa,
|
||||
onRequireIpAuthorization,
|
||||
}: LoginFormControllerOptions) {
|
||||
const {i18n} = useLingui();
|
||||
const [isPasskeyLoading, setIsPasskeyLoading] = useState(false);
|
||||
const [passkeyBridgeSuggested, setPasskeyBridgeSuggested] = useState(false);
|
||||
const {form, isLoading, fieldErrors, error} = useAuthForm({
|
||||
initialValues: {email: '', password: ''},
|
||||
onSubmit: async (values) => {
|
||||
@@ -109,12 +119,8 @@ export function useLoginFormController({
|
||||
}
|
||||
});
|
||||
}, [onLoginSuccess, redirectPath]);
|
||||
const handlePasskeyLogin = useCallback(async () => {
|
||||
setIsPasskeyLoading(true);
|
||||
try {
|
||||
await WebAuthnUtils.assertWebAuthnSupported();
|
||||
const options = await getWebAuthnAuthenticationOptions();
|
||||
const credential = await WebAuthnUtils.performAuthentication(options);
|
||||
const completePasskeyLogin = useCallback(
|
||||
async (options: PublicKeyCredentialRequestOptionsJSON, credential: AuthenticationResponseJSON) => {
|
||||
const response = await authenticateWithWebAuthn({
|
||||
response: credential,
|
||||
challenge: options.challenge,
|
||||
@@ -124,11 +130,29 @@ export function useLoginFormController({
|
||||
if (redirectPath) {
|
||||
RouterUtils.replaceWith(redirectPath);
|
||||
}
|
||||
},
|
||||
[inviteCode, onLoginSuccess, redirectPath],
|
||||
);
|
||||
const handlePasskeyLogin = useCallback(async () => {
|
||||
setIsPasskeyLoading(true);
|
||||
try {
|
||||
await WebAuthnUtils.assertWebAuthnSupported();
|
||||
const options = await getWebAuthnAuthenticationOptions();
|
||||
const credential = await WebAuthnUtils.performAuthentication(options);
|
||||
await completePasskeyLogin(options, credential);
|
||||
} catch (err) {
|
||||
if (err instanceof CaptchaCancelledError) {
|
||||
return;
|
||||
}
|
||||
logger.error('Passkey login failed', err);
|
||||
if (shouldSuggestPasskeyBridge(err)) {
|
||||
setPasskeyBridgeSuggested(true);
|
||||
return;
|
||||
}
|
||||
if (err instanceof WebAuthnUtils.PasskeyDomainUnsupportedError) {
|
||||
ToastCommands.error(i18n._(WebAuthnUtils.PASSKEY_DOMAIN_UNSUPPORTED_DESCRIPTOR));
|
||||
return;
|
||||
}
|
||||
const userCancelled =
|
||||
err instanceof DOMException && (err.name === 'NotAllowedError' || err.name === 'AbortError');
|
||||
if (isDesktop() && !userCancelled) {
|
||||
@@ -137,7 +161,27 @@ export function useLoginFormController({
|
||||
} finally {
|
||||
setIsPasskeyLoading(false);
|
||||
}
|
||||
}, [inviteCode, onLoginSuccess, redirectPath, handleDesktopPasskeyHandoff]);
|
||||
}, [completePasskeyLogin, handleDesktopPasskeyHandoff, i18n]);
|
||||
const handlePasskeyBridgeLogin = useCallback(() => {
|
||||
const options = getWebAuthnAuthenticationOptions();
|
||||
const credential = runPasskeyViaBridge('authenticate', options);
|
||||
setIsPasskeyLoading(true);
|
||||
Promise.all([options, credential])
|
||||
.then(([resolvedOptions, resolvedCredential]) => completePasskeyLogin(resolvedOptions, resolvedCredential))
|
||||
.catch((err: unknown) => {
|
||||
if (err instanceof CaptchaCancelledError) {
|
||||
return;
|
||||
}
|
||||
logger.error('Passkey login in the pop-up window failed', err);
|
||||
const descriptor = describePasskeyBridgeFailure(err);
|
||||
if (descriptor) {
|
||||
ToastCommands.error(i18n._(descriptor));
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
setIsPasskeyLoading(false);
|
||||
});
|
||||
}, [completePasskeyLogin, i18n]);
|
||||
return {
|
||||
form,
|
||||
isLoading,
|
||||
@@ -145,6 +189,8 @@ export function useLoginFormController({
|
||||
error,
|
||||
handlePasskeyLogin,
|
||||
handlePasskeyBrowserLogin: handleDesktopPasskeyHandoff,
|
||||
handlePasskeyBridgeLogin,
|
||||
passkeyBridgeSuggested,
|
||||
isPasskeyLoading,
|
||||
};
|
||||
}
|
||||
@@ -161,7 +207,9 @@ interface MfaControllerOptions {
|
||||
}
|
||||
|
||||
export function useMfaController({ticket, methods, inviteCode, onLoginSuccess}: MfaControllerOptions) {
|
||||
const {i18n} = useLingui();
|
||||
const [isWebAuthnLoading, setIsWebAuthnLoading] = useState(false);
|
||||
const [passkeyBridgeSuggested, setPasskeyBridgeSuggested] = useState(false);
|
||||
const {form, isLoading, fieldErrors} = useAuthForm({
|
||||
initialValues: {code: ''},
|
||||
onSubmit: async (values) => {
|
||||
@@ -179,11 +227,8 @@ export function useMfaController({ticket, methods, inviteCode, onLoginSuccess}:
|
||||
firstFieldName: 'code',
|
||||
redirectPath: undefined,
|
||||
});
|
||||
const handleWebAuthn = useCallback(async () => {
|
||||
setIsWebAuthnLoading(true);
|
||||
try {
|
||||
const options = await getWebAuthnMfaOptions(ticket);
|
||||
const credential = await WebAuthnUtils.performAuthentication(options);
|
||||
const completeWebAuthnMfa = useCallback(
|
||||
async (options: PublicKeyCredentialRequestOptionsJSON, credential: AuthenticationResponseJSON) => {
|
||||
const response = await authenticateMfaWithWebAuthn({
|
||||
response: credential,
|
||||
challenge: options.challenge,
|
||||
@@ -191,12 +236,45 @@ export function useMfaController({ticket, methods, inviteCode, onLoginSuccess}:
|
||||
inviteCode,
|
||||
});
|
||||
await onLoginSuccess?.(response);
|
||||
},
|
||||
[inviteCode, onLoginSuccess, ticket],
|
||||
);
|
||||
const handleWebAuthn = useCallback(async () => {
|
||||
setIsWebAuthnLoading(true);
|
||||
try {
|
||||
const options = await getWebAuthnMfaOptions(ticket);
|
||||
const credential = await WebAuthnUtils.performAuthentication(options);
|
||||
await completeWebAuthnMfa(options, credential);
|
||||
} catch (error) {
|
||||
logger.error('WebAuthn MFA failed', error);
|
||||
if (shouldSuggestPasskeyBridge(error)) {
|
||||
setPasskeyBridgeSuggested(true);
|
||||
return;
|
||||
}
|
||||
if (error instanceof WebAuthnUtils.PasskeyDomainUnsupportedError) {
|
||||
ToastCommands.error(i18n._(WebAuthnUtils.PASSKEY_DOMAIN_UNSUPPORTED_DESCRIPTOR));
|
||||
}
|
||||
} finally {
|
||||
setIsWebAuthnLoading(false);
|
||||
}
|
||||
}, [inviteCode, onLoginSuccess, ticket]);
|
||||
}, [completeWebAuthnMfa, ticket, i18n]);
|
||||
const handlePasskeyBridge = useCallback(() => {
|
||||
const options = getWebAuthnMfaOptions(ticket);
|
||||
const credential = runPasskeyViaBridge('authenticate', options);
|
||||
setIsWebAuthnLoading(true);
|
||||
Promise.all([options, credential])
|
||||
.then(([resolvedOptions, resolvedCredential]) => completeWebAuthnMfa(resolvedOptions, resolvedCredential))
|
||||
.catch((error: unknown) => {
|
||||
logger.error('WebAuthn MFA in the pop-up window failed', error);
|
||||
const descriptor = describePasskeyBridgeFailure(error);
|
||||
if (descriptor) {
|
||||
ToastCommands.error(i18n._(descriptor));
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
setIsWebAuthnLoading(false);
|
||||
});
|
||||
}, [completeWebAuthnMfa, ticket, i18n]);
|
||||
const supports = useMemo(
|
||||
() => ({totp: methods.totp, webauthn: methods.webauthn, backupCodes: methods.backupCodes}),
|
||||
[methods.totp, methods.webauthn, methods.backupCodes],
|
||||
@@ -206,6 +284,8 @@ export function useMfaController({ticket, methods, inviteCode, onLoginSuccess}:
|
||||
isLoading,
|
||||
fieldErrors,
|
||||
handleWebAuthn,
|
||||
handlePasskeyBridge,
|
||||
passkeyBridgeSuggested,
|
||||
isWebAuthnLoading,
|
||||
supports,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import styles from '@app/features/app/components/ErrorFallback.module.css';
|
||||
import {PRODUCT_NAME} from '@app/features/app/config/I18nDisplayConstants';
|
||||
import {
|
||||
type PasskeyBridgeSession,
|
||||
type PasskeyBridgeViewState,
|
||||
startPasskeyBridgeSession,
|
||||
} from '@app/features/auth/passkey_bridge/PasskeyBridgeSession';
|
||||
import {CONTINUE_DESCRIPTOR} from '@app/features/i18n/utils/CommonMessageDescriptors';
|
||||
import {Button} from '@app/features/ui/button/Button';
|
||||
import {FluxerIcon} from '@app/features/ui/components/icons/FluxerIcon';
|
||||
import {msg} from '@lingui/core/macro';
|
||||
import {useLingui} from '@lingui/react/macro';
|
||||
import type React from 'react';
|
||||
import {useCallback, useEffect, useRef, useState} from 'react';
|
||||
|
||||
const USE_YOUR_PASSKEY_DESCRIPTOR = msg({
|
||||
message: 'Use your passkey',
|
||||
comment: 'Heading of the small pop-up window that runs a passkey prompt for the new web address.',
|
||||
});
|
||||
const CONTINUE_TO_SIGN_IN_DESCRIPTOR = msg({
|
||||
message: 'Continue with your passkey to sign in to {host}',
|
||||
comment:
|
||||
'Body of the passkey pop-up window when signing in or confirming identity. host is a web address such as fluxer.com.',
|
||||
});
|
||||
const CONTINUE_TO_CREATE_DESCRIPTOR = msg({
|
||||
message: 'Continue to create a passkey for {host}',
|
||||
comment: 'Body of the passkey pop-up window when adding a new passkey. host is a web address such as fluxer.com.',
|
||||
});
|
||||
const CLOSE_WINDOW_DESCRIPTOR = msg({
|
||||
message: 'Close window',
|
||||
comment: 'Button in the passkey pop-up window that closes it after an error.',
|
||||
});
|
||||
const BRIDGE_UNAVAILABLE_DESCRIPTOR = msg({
|
||||
message: 'Open this window from {productName} to use your passkey.',
|
||||
comment: 'Error in the passkey pop-up window when someone opens its address directly. productName is the app name.',
|
||||
});
|
||||
const BRIDGE_REJECTED_DESCRIPTOR = msg({
|
||||
message: 'This passkey request could not be verified. Close this window and try again.',
|
||||
comment: 'Error in the passkey pop-up window when the request it received is not valid.',
|
||||
});
|
||||
const BRIDGE_TIMED_OUT_DESCRIPTOR = msg({
|
||||
message: 'This passkey request timed out. Close this window and try again.',
|
||||
comment: 'Error in the passkey pop-up window after five minutes without finishing.',
|
||||
});
|
||||
|
||||
interface PasskeyBridgeScreenProps {
|
||||
openerOrigin: string;
|
||||
}
|
||||
|
||||
export const PasskeyBridgeScreen: React.FC<PasskeyBridgeScreenProps> = ({openerOrigin}) => {
|
||||
const {i18n} = useLingui();
|
||||
const [state, setState] = useState<PasskeyBridgeViewState>({status: 'waiting'});
|
||||
const sessionRef = useRef<PasskeyBridgeSession | null>(null);
|
||||
useEffect(() => {
|
||||
const session = startPasskeyBridgeSession(openerOrigin, setState);
|
||||
sessionRef.current = session;
|
||||
return () => {
|
||||
session.dispose();
|
||||
sessionRef.current = null;
|
||||
};
|
||||
}, [openerOrigin]);
|
||||
const handleContinue = useCallback(() => {
|
||||
sessionRef.current?.continueCeremony();
|
||||
}, []);
|
||||
const handleClose = useCallback(() => {
|
||||
window.close();
|
||||
}, []);
|
||||
const host = new URL(openerOrigin).host;
|
||||
const failure =
|
||||
state.status === 'unavailable'
|
||||
? i18n._(BRIDGE_UNAVAILABLE_DESCRIPTOR, {productName: PRODUCT_NAME})
|
||||
: state.status === 'rejected'
|
||||
? i18n._(BRIDGE_REJECTED_DESCRIPTOR)
|
||||
: state.status === 'timed_out'
|
||||
? i18n._(BRIDGE_TIMED_OUT_DESCRIPTOR)
|
||||
: null;
|
||||
const kind = state.status === 'ready' || state.status === 'running' ? state.kind : 'authenticate';
|
||||
return (
|
||||
<main className={styles.errorFallbackContainer} data-flx="auth.passkey-bridge-screen.container">
|
||||
<FluxerIcon className={styles.errorFallbackIcon} data-flx="auth.passkey-bridge-screen.icon" />
|
||||
<div className={styles.errorFallbackContent} data-flx="auth.passkey-bridge-screen.content">
|
||||
<h1 className={styles.errorFallbackTitle} data-flx="auth.passkey-bridge-screen.title">
|
||||
{i18n._(USE_YOUR_PASSKEY_DESCRIPTOR)}
|
||||
</h1>
|
||||
<p
|
||||
className={styles.errorFallbackDescription}
|
||||
role={failure === null ? undefined : 'alert'}
|
||||
data-flx="auth.passkey-bridge-screen.description"
|
||||
>
|
||||
{failure ??
|
||||
i18n._(kind === 'register' ? CONTINUE_TO_CREATE_DESCRIPTOR : CONTINUE_TO_SIGN_IN_DESCRIPTOR, {host})}
|
||||
</p>
|
||||
</div>
|
||||
<div className={styles.errorFallbackActions} data-flx="auth.passkey-bridge-screen.actions">
|
||||
{failure === null ? (
|
||||
<Button
|
||||
onClick={handleContinue}
|
||||
disabled={state.status !== 'ready'}
|
||||
submitting={state.status === 'waiting' || state.status === 'running'}
|
||||
autoFocus
|
||||
data-flx="auth.passkey-bridge-screen.button.continue"
|
||||
>
|
||||
{i18n._(CONTINUE_DESCRIPTOR)}
|
||||
</Button>
|
||||
) : state.status !== 'unavailable' ? (
|
||||
<Button variant="secondary" onClick={handleClose} data-flx="auth.passkey-bridge-screen.button.close">
|
||||
{i18n._(CLOSE_WINDOW_DESCRIPTOR)}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,125 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {
|
||||
PASSKEY_BRIDGE_READY_TYPE,
|
||||
PASSKEY_BRIDGE_RESULT_TYPE,
|
||||
PASSKEY_BRIDGE_TIMEOUT_MS,
|
||||
PASSKEY_BRIDGE_VERSION,
|
||||
type PasskeyBridgeKind,
|
||||
type PasskeyBridgeRequest,
|
||||
type PasskeyBridgeResponseMap,
|
||||
type PasskeyBridgeResult,
|
||||
parsePasskeyBridgeRequest,
|
||||
readPasskeyBridgeRequestId,
|
||||
toPasskeyBridgeErrorPayload,
|
||||
} from '@app/features/auth/utils/PasskeyBridgeProtocol';
|
||||
import {startAuthentication, startRegistration} from '@simplewebauthn/browser';
|
||||
|
||||
export type PasskeyBridgeViewState =
|
||||
| {status: 'unavailable'}
|
||||
| {status: 'waiting'}
|
||||
| {status: 'ready'; kind: PasskeyBridgeKind}
|
||||
| {status: 'running'; kind: PasskeyBridgeKind}
|
||||
| {status: 'rejected'}
|
||||
| {status: 'timed_out'};
|
||||
|
||||
export interface PasskeyBridgeSession {
|
||||
continueCeremony(): void;
|
||||
dispose(): void;
|
||||
}
|
||||
|
||||
function runCeremony(request: PasskeyBridgeRequest): Promise<PasskeyBridgeResponseMap[PasskeyBridgeKind]> {
|
||||
if (request.kind === 'authenticate') {
|
||||
return startAuthentication({optionsJSON: request.options});
|
||||
}
|
||||
return startRegistration({optionsJSON: request.options});
|
||||
}
|
||||
|
||||
function failureResult(id: string, error: unknown): PasskeyBridgeResult {
|
||||
return {
|
||||
type: PASSKEY_BRIDGE_RESULT_TYPE,
|
||||
v: PASSKEY_BRIDGE_VERSION,
|
||||
id,
|
||||
ok: false,
|
||||
error: toPasskeyBridgeErrorPayload(error),
|
||||
};
|
||||
}
|
||||
|
||||
export function startPasskeyBridgeSession(
|
||||
openerOrigin: string,
|
||||
onStateChange: (state: PasskeyBridgeViewState) => void,
|
||||
): PasskeyBridgeSession {
|
||||
const opener = window.opener as Window | null;
|
||||
if (opener === null || opener === window) {
|
||||
onStateChange({status: 'unavailable'});
|
||||
return {continueCeremony() {}, dispose() {}};
|
||||
}
|
||||
let request: PasskeyBridgeRequest | null = null;
|
||||
let running = false;
|
||||
let finished = false;
|
||||
const post = (message: unknown) => {
|
||||
opener.postMessage(message, openerOrigin);
|
||||
};
|
||||
const finish = (result: PasskeyBridgeResult) => {
|
||||
finished = true;
|
||||
window.clearTimeout(timeout);
|
||||
post(result);
|
||||
window.close();
|
||||
};
|
||||
const handleMessage = (event: MessageEvent) => {
|
||||
if (event.origin !== openerOrigin || event.source !== opener) {
|
||||
return;
|
||||
}
|
||||
window.removeEventListener('message', handleMessage);
|
||||
const parsed = parsePasskeyBridgeRequest(event.data);
|
||||
if (parsed === null) {
|
||||
finished = true;
|
||||
window.clearTimeout(timeout);
|
||||
const id = readPasskeyBridgeRequestId(event.data);
|
||||
if (id !== null) {
|
||||
post(failureResult(id, new DOMException('The passkey request was rejected', 'SecurityError')));
|
||||
}
|
||||
onStateChange({status: 'rejected'});
|
||||
return;
|
||||
}
|
||||
request = parsed;
|
||||
onStateChange({status: 'ready', kind: parsed.kind});
|
||||
};
|
||||
const timeout = window.setTimeout(() => {
|
||||
if (running || finished) {
|
||||
return;
|
||||
}
|
||||
finished = true;
|
||||
window.removeEventListener('message', handleMessage);
|
||||
if (request !== null) {
|
||||
post(failureResult(request.id, new DOMException('The passkey window timed out', 'TimeoutError')));
|
||||
request = null;
|
||||
}
|
||||
onStateChange({status: 'timed_out'});
|
||||
}, PASSKEY_BRIDGE_TIMEOUT_MS);
|
||||
window.addEventListener('message', handleMessage);
|
||||
onStateChange({status: 'waiting'});
|
||||
post({type: PASSKEY_BRIDGE_READY_TYPE, v: PASSKEY_BRIDGE_VERSION});
|
||||
return {
|
||||
continueCeremony() {
|
||||
const current = request;
|
||||
if (current === null || running || finished) {
|
||||
return;
|
||||
}
|
||||
running = true;
|
||||
onStateChange({status: 'running', kind: current.kind});
|
||||
runCeremony(current).then(
|
||||
(response) => {
|
||||
finish({type: PASSKEY_BRIDGE_RESULT_TYPE, v: PASSKEY_BRIDGE_VERSION, id: current.id, ok: true, response});
|
||||
},
|
||||
(error: unknown) => {
|
||||
finish(failureResult(current.id, error));
|
||||
},
|
||||
);
|
||||
},
|
||||
dispose() {
|
||||
window.removeEventListener('message', handleMessage);
|
||||
window.clearTimeout(timeout);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -449,6 +449,13 @@ class AccountStorage {
|
||||
}
|
||||
}
|
||||
|
||||
async importAccounts(records: ReadonlyArray<StoredAccount>): Promise<void> {
|
||||
await this.ensureDb();
|
||||
for (const record of records) {
|
||||
await this.putRecord(this.sanitizeRecord(record));
|
||||
}
|
||||
}
|
||||
|
||||
async deleteAccount(userId: string): Promise<void> {
|
||||
await this.ensureDb();
|
||||
if (!userId) {
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {shouldOfferOldAppSignIn} from '@app/features/auth/utils/OldAppSignIn';
|
||||
import {describe, expect, it} from 'vitest';
|
||||
|
||||
describe('shouldOfferOldAppSignIn', () => {
|
||||
it('offers the old app sign-in in an installed WebKit app on an official target origin', () => {
|
||||
expect(
|
||||
shouldOfferOldAppSignIn({origin: 'https://fluxer.com', installKind: 'webkit', hasStoredAccounts: false}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
shouldOfferOldAppSignIn({origin: 'https://canary.fluxer.com', installKind: 'webkit', hasStoredAccounts: false}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('offers the old app sign-in in an installed Android Chromium app on an official target origin', () => {
|
||||
expect(
|
||||
shouldOfferOldAppSignIn({
|
||||
origin: 'https://fluxer.com',
|
||||
installKind: 'chromium-android',
|
||||
hasStoredAccounts: false,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('does not offer it once an account is stored', () => {
|
||||
expect(
|
||||
shouldOfferOldAppSignIn({origin: 'https://fluxer.com', installKind: 'webkit', hasStoredAccounts: true}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('does not offer it outside the installed WebKit and Android Chromium apps', () => {
|
||||
for (const installKind of ['none', 'chromium-desktop', 'firefox', 'other'] as const) {
|
||||
expect(shouldOfferOldAppSignIn({origin: 'https://fluxer.com', installKind, hasStoredAccounts: false})).toBe(
|
||||
false,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('does not offer it on source or self-hosted origins', () => {
|
||||
for (const origin of ['https://web.fluxer.app', 'https://web.canary.fluxer.app', 'https://chat.example.com']) {
|
||||
expect(shouldOfferOldAppSignIn({origin, installKind: 'webkit', hasStoredAccounts: false})).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {
|
||||
type DomainMigrationInstallKind,
|
||||
resolveDomainMigrationSide,
|
||||
} from '@app/features/app/domain_migration/DomainMigrationCore';
|
||||
|
||||
const OLD_APP_SIGN_IN_INSTALL_KINDS: ReadonlySet<DomainMigrationInstallKind> = new Set<DomainMigrationInstallKind>([
|
||||
'webkit',
|
||||
'chromium-android',
|
||||
]);
|
||||
|
||||
export interface OldAppSignInInput {
|
||||
origin: string;
|
||||
installKind: DomainMigrationInstallKind;
|
||||
hasStoredAccounts: boolean;
|
||||
}
|
||||
|
||||
export function shouldOfferOldAppSignIn({origin, installKind, hasStoredAccounts}: OldAppSignInInput): boolean {
|
||||
if (hasStoredAccounts || !OLD_APP_SIGN_IN_INSTALL_KINDS.has(installKind)) {
|
||||
return false;
|
||||
}
|
||||
return resolveDomainMigrationSide(origin)?.role === 'target';
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {
|
||||
isPasskeyBridgeReady,
|
||||
PASSKEY_BRIDGE_PATH,
|
||||
PASSKEY_BRIDGE_REQUEST_TYPE,
|
||||
PASSKEY_BRIDGE_TIMEOUT_MS,
|
||||
PASSKEY_BRIDGE_VERSION,
|
||||
type PasskeyBridgeKind,
|
||||
type PasskeyBridgeOptionsMap,
|
||||
type PasskeyBridgeRequest,
|
||||
type PasskeyBridgeResponseMap,
|
||||
parsePasskeyBridgeResult,
|
||||
resolvePasskeyBridgeLegacyOrigin,
|
||||
} from '@app/features/auth/utils/PasskeyBridgeProtocol';
|
||||
import {PasskeyDomainUnsupportedError} from '@app/features/auth/utils/WebAuthnUtils';
|
||||
import {Platform} from '@app/features/platform/types/Platform';
|
||||
import type {MessageDescriptor} from '@lingui/core';
|
||||
import {msg} from '@lingui/core/macro';
|
||||
|
||||
const PASSKEY_BRIDGE_POPUP_FEATURES = 'popup,width=460,height=620';
|
||||
const PASSKEY_BRIDGE_CLOSED_POLL_MS = 500;
|
||||
const SUGGEST_BRIDGE_ERROR_NAMES: ReadonlySet<string> = new Set(['NotAllowedError', 'SecurityError']);
|
||||
|
||||
const PASSKEY_BRIDGE_POPUP_BLOCKED_DESCRIPTOR = msg({
|
||||
message: 'Your browser blocked the passkey pop-up window. Allow pop-ups for this site and try again.',
|
||||
comment: 'Error shown when the browser blocks the pop-up window used for password manager passkeys.',
|
||||
});
|
||||
const PASSKEY_BRIDGE_FAILED_DESCRIPTOR = msg({
|
||||
message: "Couldn't use your passkey in the pop-up window. Try again.",
|
||||
comment: 'Error shown when the pop-up window used for password manager passkeys does not finish.',
|
||||
});
|
||||
|
||||
export class PasskeyBridgeError extends Error {
|
||||
constructor(name: string, message: string) {
|
||||
super(message);
|
||||
this.name = name;
|
||||
}
|
||||
}
|
||||
|
||||
export function isPasskeyBridgeAvailable(): boolean {
|
||||
return !Platform.isElectron && resolvePasskeyBridgeLegacyOrigin(window.location.origin) !== null;
|
||||
}
|
||||
|
||||
export function shouldSuggestPasskeyBridge(error: unknown): boolean {
|
||||
if (!isPasskeyBridgeAvailable() || error instanceof PasskeyBridgeError) {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
error instanceof PasskeyDomainUnsupportedError ||
|
||||
(error instanceof Error && SUGGEST_BRIDGE_ERROR_NAMES.has(error.name))
|
||||
);
|
||||
}
|
||||
|
||||
function isPasskeyBridgeDismissal(error: unknown): boolean {
|
||||
return error instanceof PasskeyBridgeError && (error.name === 'AbortError' || error.name === 'NotAllowedError');
|
||||
}
|
||||
|
||||
export function describePasskeyBridgeFailure(error: unknown): MessageDescriptor | null {
|
||||
if (isPasskeyBridgeDismissal(error)) {
|
||||
return null;
|
||||
}
|
||||
if (error instanceof PasskeyBridgeError && error.name === 'PopupBlockedError') {
|
||||
return PASSKEY_BRIDGE_POPUP_BLOCKED_DESCRIPTOR;
|
||||
}
|
||||
return PASSKEY_BRIDGE_FAILED_DESCRIPTOR;
|
||||
}
|
||||
|
||||
export function runPasskeyViaBridge<K extends PasskeyBridgeKind>(
|
||||
kind: K,
|
||||
options: Promise<PasskeyBridgeOptionsMap[K]>,
|
||||
): Promise<PasskeyBridgeResponseMap[K]> {
|
||||
const legacyOrigin = isPasskeyBridgeAvailable() ? resolvePasskeyBridgeLegacyOrigin(window.location.origin) : null;
|
||||
const popup =
|
||||
legacyOrigin === null
|
||||
? null
|
||||
: window.open(`${legacyOrigin}${PASSKEY_BRIDGE_PATH}`, '_blank', PASSKEY_BRIDGE_POPUP_FEATURES);
|
||||
if (legacyOrigin === null || popup === null) {
|
||||
options.catch(() => {});
|
||||
const name = legacyOrigin === null ? 'NotSupportedError' : 'PopupBlockedError';
|
||||
return Promise.reject(new PasskeyBridgeError(name, 'The passkey window could not be opened'));
|
||||
}
|
||||
return new Promise((resolve, reject) => {
|
||||
const id = crypto.randomUUID();
|
||||
let ready = false;
|
||||
let settled = false;
|
||||
let requestSent = false;
|
||||
let closedSeen = false;
|
||||
let resolvedOptions: PasskeyBridgeOptionsMap[K] | null = null;
|
||||
const cleanup = () => {
|
||||
window.removeEventListener('message', handleMessage);
|
||||
window.clearInterval(closedPoll);
|
||||
window.clearTimeout(timeout);
|
||||
};
|
||||
const fail = (error: unknown) => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
cleanup();
|
||||
if (!popup.closed) {
|
||||
popup.close();
|
||||
}
|
||||
reject(error);
|
||||
};
|
||||
const sendRequest = () => {
|
||||
if (!ready || resolvedOptions === null || requestSent || settled) {
|
||||
return;
|
||||
}
|
||||
requestSent = true;
|
||||
const request = {
|
||||
type: PASSKEY_BRIDGE_REQUEST_TYPE,
|
||||
v: PASSKEY_BRIDGE_VERSION,
|
||||
id,
|
||||
kind,
|
||||
options: resolvedOptions,
|
||||
} as PasskeyBridgeRequest;
|
||||
popup.postMessage(request, legacyOrigin);
|
||||
popup.focus();
|
||||
};
|
||||
const handleMessage = (event: MessageEvent) => {
|
||||
if (settled || event.origin !== legacyOrigin || event.source !== popup) {
|
||||
return;
|
||||
}
|
||||
if (isPasskeyBridgeReady(event.data)) {
|
||||
ready = true;
|
||||
sendRequest();
|
||||
return;
|
||||
}
|
||||
const result = parsePasskeyBridgeResult(event.data, id);
|
||||
if (result === null) {
|
||||
return;
|
||||
}
|
||||
if (!result.ok) {
|
||||
fail(new PasskeyBridgeError(result.error.name, result.error.message));
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
cleanup();
|
||||
resolve(result.response as PasskeyBridgeResponseMap[K]);
|
||||
};
|
||||
const closedPoll = window.setInterval(() => {
|
||||
if (!popup.closed) {
|
||||
return;
|
||||
}
|
||||
if (closedSeen) {
|
||||
fail(new PasskeyBridgeError('AbortError', 'The passkey window was closed'));
|
||||
}
|
||||
closedSeen = true;
|
||||
}, PASSKEY_BRIDGE_CLOSED_POLL_MS);
|
||||
const timeout = window.setTimeout(() => {
|
||||
fail(new PasskeyBridgeError('TimeoutError', 'The passkey window timed out'));
|
||||
}, PASSKEY_BRIDGE_TIMEOUT_MS);
|
||||
window.addEventListener('message', handleMessage);
|
||||
options.then(
|
||||
(value) => {
|
||||
resolvedOptions = value;
|
||||
sendRequest();
|
||||
},
|
||||
(error: unknown) => fail(error),
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {
|
||||
PASSKEY_BRIDGE_REQUEST_TYPE,
|
||||
PASSKEY_BRIDGE_RESULT_TYPE,
|
||||
parsePasskeyBridgeRequest,
|
||||
parsePasskeyBridgeResult,
|
||||
resolvePasskeyBridgeLegacyOrigin,
|
||||
resolvePasskeyBridgeOpenerOrigin,
|
||||
} from '@app/features/auth/utils/PasskeyBridgeProtocol';
|
||||
import {describe, expect, it} from 'vitest';
|
||||
|
||||
function authenticateRequest(options: Record<string, unknown>): Record<string, unknown> {
|
||||
return {type: PASSKEY_BRIDGE_REQUEST_TYPE, v: 1, id: 'req', kind: 'authenticate', options};
|
||||
}
|
||||
|
||||
function registerRequest(options: Record<string, unknown>): Record<string, unknown> {
|
||||
return {type: PASSKEY_BRIDGE_REQUEST_TYPE, v: 1, id: 'req', kind: 'register', options};
|
||||
}
|
||||
|
||||
describe('resolvePasskeyBridgeOpenerOrigin', () => {
|
||||
it('pairs each official legacy origin with its target only', () => {
|
||||
expect(resolvePasskeyBridgeOpenerOrigin('https://web.fluxer.app', '/passkey-bridge')).toBe('https://fluxer.com');
|
||||
expect(resolvePasskeyBridgeOpenerOrigin('https://web.canary.fluxer.app', '/passkey-bridge')).toBe(
|
||||
'https://canary.fluxer.com',
|
||||
);
|
||||
});
|
||||
|
||||
it('ignores other origins and paths', () => {
|
||||
expect(resolvePasskeyBridgeOpenerOrigin('https://fluxer.com', '/passkey-bridge')).toBeNull();
|
||||
expect(resolvePasskeyBridgeOpenerOrigin('https://fluxer.app', '/passkey-bridge')).toBeNull();
|
||||
expect(resolvePasskeyBridgeOpenerOrigin('https://chat.example.com', '/passkey-bridge')).toBeNull();
|
||||
expect(resolvePasskeyBridgeOpenerOrigin('https://web.fluxer.app', '/passkey-bridge/')).toBeNull();
|
||||
expect(resolvePasskeyBridgeOpenerOrigin('https://web.fluxer.app', '/login')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolvePasskeyBridgeLegacyOrigin', () => {
|
||||
it('resolves only from official target origins', () => {
|
||||
expect(resolvePasskeyBridgeLegacyOrigin('https://fluxer.com')).toBe('https://web.fluxer.app');
|
||||
expect(resolvePasskeyBridgeLegacyOrigin('https://canary.fluxer.com')).toBe('https://web.canary.fluxer.app');
|
||||
expect(resolvePasskeyBridgeLegacyOrigin('https://web.fluxer.app')).toBeNull();
|
||||
expect(resolvePasskeyBridgeLegacyOrigin('https://chat.example.com')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('parsePasskeyBridgeRequest', () => {
|
||||
it('accepts requests for the fluxer.app relying party', () => {
|
||||
expect(parsePasskeyBridgeRequest(authenticateRequest({challenge: 'c', rpId: 'fluxer.app'}))).not.toBeNull();
|
||||
expect(
|
||||
parsePasskeyBridgeRequest(registerRequest({challenge: 'c', rp: {id: 'fluxer.app', name: 'Fluxer'}, user: {}})),
|
||||
).not.toBeNull();
|
||||
});
|
||||
|
||||
it('rejects any other relying party', () => {
|
||||
for (const rpId of ['evil.example', 'web.fluxer.app', 'fluxer.com', 'app', undefined]) {
|
||||
expect(parsePasskeyBridgeRequest(authenticateRequest({challenge: 'c', rpId}))).toBeNull();
|
||||
expect(parsePasskeyBridgeRequest(registerRequest({challenge: 'c', rp: {id: rpId}, user: {}}))).toBeNull();
|
||||
}
|
||||
expect(parsePasskeyBridgeRequest(registerRequest({challenge: 'c', rpId: 'fluxer.app', user: {}}))).toBeNull();
|
||||
expect(parsePasskeyBridgeRequest(authenticateRequest({challenge: 'c', rp: {id: 'fluxer.app'}}))).toBeNull();
|
||||
});
|
||||
|
||||
it('rejects malformed envelopes', () => {
|
||||
const options = {challenge: 'c', rpId: 'fluxer.app'};
|
||||
expect(parsePasskeyBridgeRequest({...authenticateRequest(options), v: 2})).toBeNull();
|
||||
expect(parsePasskeyBridgeRequest({...authenticateRequest(options), type: 'other'})).toBeNull();
|
||||
expect(parsePasskeyBridgeRequest({...authenticateRequest(options), id: ''})).toBeNull();
|
||||
expect(parsePasskeyBridgeRequest({...authenticateRequest(options), kind: 'sign'})).toBeNull();
|
||||
expect(parsePasskeyBridgeRequest(authenticateRequest({rpId: 'fluxer.app'}))).toBeNull();
|
||||
expect(parsePasskeyBridgeRequest('request')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('parsePasskeyBridgeResult', () => {
|
||||
const response = {id: 'cred', rawId: 'cred', response: {}, type: 'public-key', clientExtensionResults: {}};
|
||||
|
||||
it('accepts only the matching request id', () => {
|
||||
const result = {type: PASSKEY_BRIDGE_RESULT_TYPE, v: 1, id: 'req', ok: true, response};
|
||||
expect(parsePasskeyBridgeResult(result, 'req')).not.toBeNull();
|
||||
expect(parsePasskeyBridgeResult(result, 'other')).toBeNull();
|
||||
});
|
||||
|
||||
it('normalises failures and rejects malformed successes', () => {
|
||||
expect(
|
||||
parsePasskeyBridgeResult({type: PASSKEY_BRIDGE_RESULT_TYPE, v: 1, id: 'req', ok: false, error: {}}, 'req'),
|
||||
).toMatchObject({ok: false, error: {name: 'UnknownError', message: ''}});
|
||||
expect(
|
||||
parsePasskeyBridgeResult({type: PASSKEY_BRIDGE_RESULT_TYPE, v: 1, id: 'req', ok: true, response: {}}, 'req'),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,156 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {
|
||||
DOMAIN_MIGRATION_SOURCE_TO_TARGET,
|
||||
DOMAIN_MIGRATION_TARGET_TO_SOURCE,
|
||||
} from '@app/features/app/domain_migration/DomainMigrationCore';
|
||||
import type {
|
||||
AuthenticationResponseJSON,
|
||||
PublicKeyCredentialCreationOptionsJSON,
|
||||
PublicKeyCredentialRequestOptionsJSON,
|
||||
RegistrationResponseJSON,
|
||||
} from '@simplewebauthn/browser';
|
||||
|
||||
export const PASSKEY_BRIDGE_PATH = '/passkey-bridge';
|
||||
export const PASSKEY_BRIDGE_VERSION = 1;
|
||||
export const PASSKEY_BRIDGE_RP_ID = 'fluxer.app';
|
||||
export const PASSKEY_BRIDGE_TIMEOUT_MS = 5 * 60 * 1000;
|
||||
export const PASSKEY_BRIDGE_READY_TYPE = 'fluxer:passkey-bridge:ready';
|
||||
export const PASSKEY_BRIDGE_REQUEST_TYPE = 'fluxer:passkey-bridge:request';
|
||||
export const PASSKEY_BRIDGE_RESULT_TYPE = 'fluxer:passkey-bridge:result';
|
||||
|
||||
export type PasskeyBridgeKind = 'authenticate' | 'register';
|
||||
|
||||
export interface PasskeyBridgeOptionsMap {
|
||||
authenticate: PublicKeyCredentialRequestOptionsJSON;
|
||||
register: PublicKeyCredentialCreationOptionsJSON;
|
||||
}
|
||||
|
||||
export interface PasskeyBridgeResponseMap {
|
||||
authenticate: AuthenticationResponseJSON;
|
||||
register: RegistrationResponseJSON;
|
||||
}
|
||||
|
||||
export type PasskeyBridgeRequest = {
|
||||
[K in PasskeyBridgeKind]: {
|
||||
type: typeof PASSKEY_BRIDGE_REQUEST_TYPE;
|
||||
v: typeof PASSKEY_BRIDGE_VERSION;
|
||||
id: string;
|
||||
kind: K;
|
||||
options: PasskeyBridgeOptionsMap[K];
|
||||
};
|
||||
}[PasskeyBridgeKind];
|
||||
|
||||
export interface PasskeyBridgeErrorPayload {
|
||||
name: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export type PasskeyBridgeResult =
|
||||
| {
|
||||
type: typeof PASSKEY_BRIDGE_RESULT_TYPE;
|
||||
v: typeof PASSKEY_BRIDGE_VERSION;
|
||||
id: string;
|
||||
ok: true;
|
||||
response: PasskeyBridgeResponseMap[PasskeyBridgeKind];
|
||||
}
|
||||
| {
|
||||
type: typeof PASSKEY_BRIDGE_RESULT_TYPE;
|
||||
v: typeof PASSKEY_BRIDGE_VERSION;
|
||||
id: string;
|
||||
ok: false;
|
||||
error: PasskeyBridgeErrorPayload;
|
||||
};
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function isNonEmptyString(value: unknown): value is string {
|
||||
return typeof value === 'string' && value.length > 0;
|
||||
}
|
||||
|
||||
export function resolvePasskeyBridgeOpenerOrigin(origin: string, pathname: string): string | null {
|
||||
if (pathname !== PASSKEY_BRIDGE_PATH) {
|
||||
return null;
|
||||
}
|
||||
return DOMAIN_MIGRATION_SOURCE_TO_TARGET[origin] ?? null;
|
||||
}
|
||||
|
||||
export function resolvePasskeyBridgeLegacyOrigin(origin: string): string | null {
|
||||
return DOMAIN_MIGRATION_TARGET_TO_SOURCE[origin] ?? null;
|
||||
}
|
||||
|
||||
export function isPasskeyBridgeReady(data: unknown): boolean {
|
||||
return isRecord(data) && data.type === PASSKEY_BRIDGE_READY_TYPE && data.v === PASSKEY_BRIDGE_VERSION;
|
||||
}
|
||||
|
||||
export function readPasskeyBridgeRequestId(data: unknown): string | null {
|
||||
return isRecord(data) && isNonEmptyString(data.id) ? data.id : null;
|
||||
}
|
||||
|
||||
function readRequestRpId(kind: unknown, options: Record<string, unknown>): unknown {
|
||||
if (kind === 'authenticate') {
|
||||
return options.rpId;
|
||||
}
|
||||
if (kind === 'register') {
|
||||
return isRecord(options.rp) ? options.rp.id : undefined;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function parsePasskeyBridgeRequest(data: unknown): PasskeyBridgeRequest | null {
|
||||
if (
|
||||
!isRecord(data) ||
|
||||
data.type !== PASSKEY_BRIDGE_REQUEST_TYPE ||
|
||||
data.v !== PASSKEY_BRIDGE_VERSION ||
|
||||
!isNonEmptyString(data.id) ||
|
||||
!isRecord(data.options) ||
|
||||
!isNonEmptyString(data.options.challenge)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
if (readRequestRpId(data.kind, data.options) !== PASSKEY_BRIDGE_RP_ID) {
|
||||
return null;
|
||||
}
|
||||
if (data.kind === 'register' && !isRecord(data.options.user)) {
|
||||
return null;
|
||||
}
|
||||
return data as unknown as PasskeyBridgeRequest;
|
||||
}
|
||||
|
||||
export function parsePasskeyBridgeResult(data: unknown, id: string): PasskeyBridgeResult | null {
|
||||
if (
|
||||
!isRecord(data) ||
|
||||
data.type !== PASSKEY_BRIDGE_RESULT_TYPE ||
|
||||
data.v !== PASSKEY_BRIDGE_VERSION ||
|
||||
data.id !== id
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
if (data.ok === true) {
|
||||
return isRecord(data.response) && isNonEmptyString(data.response.id) && isRecord(data.response.response)
|
||||
? (data as unknown as PasskeyBridgeResult)
|
||||
: null;
|
||||
}
|
||||
if (data.ok === false && isRecord(data.error)) {
|
||||
return {
|
||||
type: PASSKEY_BRIDGE_RESULT_TYPE,
|
||||
v: PASSKEY_BRIDGE_VERSION,
|
||||
id,
|
||||
ok: false,
|
||||
error: {
|
||||
name: typeof data.error.name === 'string' ? data.error.name : 'UnknownError',
|
||||
message: typeof data.error.message === 'string' ? data.error.message : '',
|
||||
},
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function toPasskeyBridgeErrorPayload(error: unknown): PasskeyBridgeErrorPayload {
|
||||
if (error instanceof Error) {
|
||||
return {name: error.name, message: error.message};
|
||||
}
|
||||
return {name: 'UnknownError', message: String(error)};
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import {promptForSecurityKeyPin} from '@app/features/auth/components/modals/Pass
|
||||
import {parsePasskeyPinFailure} from '@app/features/auth/utils/PasskeyPinErrors';
|
||||
import {Platform} from '@app/features/platform/types/Platform';
|
||||
import {getElectronAPI} from '@app/features/ui/utils/NativeUtils';
|
||||
import {msg} from '@lingui/core/macro';
|
||||
import {
|
||||
type AuthenticationResponseJSON,
|
||||
browserSupportsWebAuthn,
|
||||
@@ -14,6 +15,47 @@ import {
|
||||
startRegistration,
|
||||
} from '@simplewebauthn/browser';
|
||||
|
||||
export const PASSKEY_DOMAIN_UNSUPPORTED_DESCRIPTOR = msg({
|
||||
message:
|
||||
'Your browser does not support passkeys on this domain. Update your browser, or sign in with your password and two-factor code.',
|
||||
comment:
|
||||
'Error shown when a passkey prompt fails because the browser cannot use the passkey on this web address. Keep plain.',
|
||||
});
|
||||
const RP_MISMATCH_MESSAGE_PATTERN = /relying party|\brp ?id\b|\bdomain\b|\borigin\b/i;
|
||||
|
||||
export class PasskeyDomainUnsupportedError extends Error {
|
||||
constructor() {
|
||||
super('Passkeys are not supported on this domain in this browser');
|
||||
this.name = 'PasskeyDomainUnsupportedError';
|
||||
}
|
||||
}
|
||||
|
||||
function isRelatedOriginFailure(error: unknown, rpId: string | undefined): boolean {
|
||||
if (!rpId || !(error instanceof Error)) {
|
||||
return false;
|
||||
}
|
||||
const hostname = window.location.hostname.toLowerCase();
|
||||
const normalizedRpId = rpId.toLowerCase();
|
||||
if (hostname === normalizedRpId || hostname.endsWith(`.${normalizedRpId}`)) {
|
||||
return false;
|
||||
}
|
||||
if (error.name === 'SecurityError') {
|
||||
return true;
|
||||
}
|
||||
return error.name === 'NotAllowedError' && RP_MISMATCH_MESSAGE_PATTERN.test(error.message);
|
||||
}
|
||||
|
||||
async function runBrowserCeremony<T>(rpId: string | undefined, run: () => Promise<T>): Promise<T> {
|
||||
try {
|
||||
return await run();
|
||||
} catch (error) {
|
||||
if (isRelatedOriginFailure(error, rpId)) {
|
||||
throw new PasskeyDomainUnsupportedError();
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function runNativeCeremonyWithPinSupport<T>(run: (requestContext?: {pin?: string}) => Promise<T>): Promise<T> {
|
||||
try {
|
||||
return await run();
|
||||
@@ -54,7 +96,7 @@ export async function performRegistration(
|
||||
return runNativeCeremonyWithPinSupport((requestContext) => passkeyRegister(options, requestContext));
|
||||
}
|
||||
}
|
||||
return await startRegistration({optionsJSON: options});
|
||||
return await runBrowserCeremony(options.rp.id, () => startRegistration({optionsJSON: options}));
|
||||
}
|
||||
|
||||
export async function performAuthentication(
|
||||
@@ -69,5 +111,5 @@ export async function performAuthentication(
|
||||
return runNativeCeremonyWithPinSupport((requestContext) => passkeyAuthenticate(options, requestContext));
|
||||
}
|
||||
}
|
||||
return await startAuthentication({optionsJSON: options});
|
||||
return await runBrowserCeremony(options.rpId, () => startAuthentication({optionsJSON: options}));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
/* SPDX-License-Identifier: AGPL-3.0-or-later */
|
||||
|
||||
.frame {
|
||||
--origin-badge-size: calc(var(--origin-icon-size) * 0.5625);
|
||||
--origin-badge-offset: calc(var(--origin-icon-size) * -0.09375);
|
||||
--origin-badge-gap: calc(var(--origin-icon-size) * 0.0625);
|
||||
--origin-badge-center: calc(var(--origin-icon-size) - var(--origin-badge-size) / 2 - var(--origin-badge-offset));
|
||||
--origin-badge-cutout: calc(var(--origin-badge-size) / 2 + var(--origin-badge-gap));
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
width: var(--origin-icon-size);
|
||||
height: var(--origin-icon-size);
|
||||
}
|
||||
|
||||
.cutout {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
mask-image: radial-gradient(
|
||||
circle at var(--origin-badge-center) var(--origin-badge-center),
|
||||
transparent var(--origin-badge-cutout),
|
||||
#000 calc(var(--origin-badge-cutout) + 0.03125rem)
|
||||
);
|
||||
}
|
||||
|
||||
.badge {
|
||||
position: absolute;
|
||||
right: var(--origin-badge-offset);
|
||||
bottom: var(--origin-badge-offset);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: var(--origin-badge-size);
|
||||
height: var(--origin-badge-size);
|
||||
border-radius: 50%;
|
||||
background-color: var(--background-tertiary);
|
||||
color: var(--text-primary-muted);
|
||||
}
|
||||
|
||||
.badgeHighlighted {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.badgeIcon {
|
||||
width: calc(var(--origin-icon-size) * 0.375);
|
||||
height: calc(var(--origin-icon-size) * 0.375);
|
||||
}
|
||||
|
||||
.fallbackIcon {
|
||||
width: var(--origin-icon-size);
|
||||
height: var(--origin-icon-size);
|
||||
flex-shrink: 0;
|
||||
color: var(--text-primary-muted);
|
||||
}
|
||||
|
||||
.fallbackIconHighlighted {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import styles from '@app/features/channel/components/ChannelOriginIcon.module.css';
|
||||
import type {Channel} from '@app/features/channel/models/Channel';
|
||||
import * as ChannelUtils from '@app/features/channel/utils/ChannelUtils';
|
||||
import {GuildIcon} from '@app/features/guild/components/popouts/GuildIcon';
|
||||
import Guilds from '@app/features/guild/state/Guilds';
|
||||
import {remFromPx} from '@app/features/theme/layout/RemFromPx';
|
||||
import {clsx} from 'clsx';
|
||||
import {observer} from 'mobx-react-lite';
|
||||
import type {CSSProperties, ReactNode} from 'react';
|
||||
|
||||
type OriginIconStyle = CSSProperties & {'--origin-icon-size': string};
|
||||
|
||||
interface ChannelBadgedIconProps {
|
||||
channel: Channel;
|
||||
children: ReactNode;
|
||||
highlighted?: boolean;
|
||||
size: number;
|
||||
}
|
||||
|
||||
interface ChannelOriginIconProps {
|
||||
channel: Channel;
|
||||
highlighted?: boolean;
|
||||
size: number;
|
||||
}
|
||||
|
||||
function originIconStyle(size: number): OriginIconStyle {
|
||||
return {'--origin-icon-size': remFromPx(size)};
|
||||
}
|
||||
|
||||
export function ChannelBadgedIcon({channel, children, highlighted = false, size}: ChannelBadgedIconProps) {
|
||||
return (
|
||||
<div
|
||||
className={styles.frame}
|
||||
style={originIconStyle(size)}
|
||||
data-flx="channel.channel-origin-icon.channel-badged-icon.frame"
|
||||
>
|
||||
<div className={styles.cutout} data-flx="channel.channel-origin-icon.channel-badged-icon.cutout">
|
||||
{children}
|
||||
</div>
|
||||
<div
|
||||
className={clsx(styles.badge, highlighted && styles.badgeHighlighted)}
|
||||
data-flx="channel.channel-origin-icon.channel-badged-icon.badge"
|
||||
>
|
||||
{ChannelUtils.getIcon(channel, {className: styles.badgeIcon, weight: 'bold'})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const ChannelOriginIcon = observer(function ChannelOriginIcon({
|
||||
channel,
|
||||
highlighted = false,
|
||||
size,
|
||||
}: ChannelOriginIconProps) {
|
||||
const guild = channel.guildId == null ? undefined : Guilds.getGuild(channel.guildId);
|
||||
if (guild == null) {
|
||||
return (
|
||||
<span className={styles.frame} style={originIconStyle(size)} data-flx="channel.channel-origin-icon.frame">
|
||||
{ChannelUtils.getIcon(channel, {
|
||||
className: clsx(styles.fallbackIcon, highlighted && styles.fallbackIconHighlighted),
|
||||
weight: 'bold',
|
||||
})}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<ChannelBadgedIcon
|
||||
channel={channel}
|
||||
highlighted={highlighted}
|
||||
size={size}
|
||||
data-flx="channel.channel-origin-icon.channel-badged-icon"
|
||||
>
|
||||
<GuildIcon
|
||||
id={guild.id}
|
||||
name={guild.name}
|
||||
icon={guild.icon}
|
||||
sizePx={size}
|
||||
data-flx="channel.channel-origin-icon.guild-icon"
|
||||
/>
|
||||
</ChannelBadgedIcon>
|
||||
);
|
||||
});
|
||||
@@ -46,10 +46,10 @@ function resolveFrecencyRecordId(key: string): string | null {
|
||||
}
|
||||
|
||||
class ChannelFrecency {
|
||||
usageHistory = new Map<string, ChannelFrecencyEntry>();
|
||||
useLog = new Map<string, ChannelFrecencyEntry>();
|
||||
|
||||
constructor() {
|
||||
makeAutoObservable(this, {usageHistory: observableShallow}, {autoBind: true});
|
||||
makeAutoObservable(this, {useLog: observableShallow}, {autoBind: true});
|
||||
void this.initPersistence();
|
||||
}
|
||||
|
||||
@@ -57,11 +57,11 @@ class ChannelFrecency {
|
||||
await makeSyncedField(this, {
|
||||
field: 'channelFrecency',
|
||||
schema: ChannelFrecencyStateSchema,
|
||||
persist: ['usageHistory'],
|
||||
persist: ['useLog'],
|
||||
debounceMs: FRECENCY_SYNC_DEBOUNCE_MS,
|
||||
toMessage: (store) => ({usage: channelFrecencyHistoryToWire(store.usageHistory)}),
|
||||
toMessage: (store) => ({usage: channelFrecencyHistoryToWire(store.useLog)}),
|
||||
applyMessage: (store, message) => {
|
||||
store.usageHistory = channelFrecencyHistoryFromWire(message.usage, Date.now());
|
||||
store.useLog = channelFrecencyHistoryFromWire(message.usage, Date.now());
|
||||
},
|
||||
mergeRemote: (local, incoming) => ({
|
||||
usage: mergeChannelFrecencyWireUsage(local.usage, incoming.usage, Date.now()),
|
||||
@@ -85,17 +85,17 @@ class ChannelFrecency {
|
||||
}
|
||||
|
||||
get frequentIds(): ReadonlyArray<string> {
|
||||
return rankFrequentChannelIds(this.usageHistory, resolveFrecencyRecordId);
|
||||
return rankFrequentChannelIds(this.useLog, resolveFrecencyRecordId);
|
||||
}
|
||||
|
||||
getScore(id: string): number {
|
||||
return this.usageHistory.get(id)?.frecency ?? 0;
|
||||
scoreFor(id: string): number {
|
||||
return this.useLog.get(id)?.heat ?? 0;
|
||||
}
|
||||
|
||||
track(key: string, timestamp?: number): void {
|
||||
trackChannelUse(this.usageHistory, key, timestamp);
|
||||
capChannelFrecencyHistory(this.usageHistory);
|
||||
computeChannelFrecency(this.usageHistory, Date.now());
|
||||
recordUse(key: string, timestamp?: number): void {
|
||||
trackChannelUse(this.useLog, key, timestamp);
|
||||
capChannelFrecencyHistory(this.useLog);
|
||||
computeChannelFrecency(this.useLog, Date.now());
|
||||
}
|
||||
|
||||
recordSelection(guildId: string | null, channelId: string | null): void {
|
||||
@@ -103,21 +103,21 @@ class ChannelFrecency {
|
||||
if (channelId !== lastChannelId) {
|
||||
lastChannelId = channelId;
|
||||
if (isTrackableId(channelId)) {
|
||||
this.track(channelId);
|
||||
this.recordUse(channelId);
|
||||
}
|
||||
}
|
||||
if (selectedGuildId !== lastGuildId) {
|
||||
lastGuildId = selectedGuildId;
|
||||
if (isTrackableId(selectedGuildId)) {
|
||||
this.track(selectedGuildId);
|
||||
this.recordUse(selectedGuildId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private refreshHistory(): void {
|
||||
const current: unknown = this.usageHistory;
|
||||
const current: unknown = this.useLog;
|
||||
if (!isObservableMap(current)) return;
|
||||
this.usageHistory = restoreChannelFrecencyHistory(current.entries(), Date.now());
|
||||
this.useLog = restoreChannelFrecencyHistory(current.entries(), Date.now());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
export interface ChannelFrecencyEntry {
|
||||
readonly totalUses: number;
|
||||
readonly recentUses: ReadonlyArray<number>;
|
||||
readonly frecency: number;
|
||||
readonly hitCount: number;
|
||||
readonly recentHits: ReadonlyArray<number>;
|
||||
readonly heat: number;
|
||||
readonly score: number;
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ export type ChannelFrecencyHistory = Map<string, ChannelFrecencyEntry>;
|
||||
|
||||
interface RankedChannelFrecency {
|
||||
readonly id: string;
|
||||
readonly frecency: number;
|
||||
readonly heat: number;
|
||||
}
|
||||
|
||||
const CHANNEL_FRECENCY_MAX_ITEMS = 100;
|
||||
@@ -22,12 +22,12 @@ const DAY_MS = 86_400_000;
|
||||
const MINUTE_MS = 60_000;
|
||||
|
||||
function createEntry(
|
||||
totalUses: number,
|
||||
recentUses: ReadonlyArray<number>,
|
||||
frecency: number,
|
||||
hitCount: number,
|
||||
recentHits: ReadonlyArray<number>,
|
||||
heat: number,
|
||||
score: number,
|
||||
): ChannelFrecencyEntry {
|
||||
return Object.freeze({totalUses, recentUses: Object.freeze(recentUses), frecency, score});
|
||||
return Object.freeze({hitCount, recentHits: Object.freeze(recentHits), heat, score});
|
||||
}
|
||||
|
||||
function utcOffsetMinutes(timestamp: number): number {
|
||||
@@ -52,11 +52,11 @@ export function channelFrecencyWeight(dayDiff: number): number {
|
||||
return 1;
|
||||
}
|
||||
|
||||
function scoreRecentUses(recentUses: ReadonlyArray<number>, now: number): number {
|
||||
const sampleCount = Math.min(recentUses.length, CHANNEL_FRECENCY_MAX_SAMPLES);
|
||||
function scoreRecentUses(recentHits: ReadonlyArray<number>, now: number): number {
|
||||
const sampleCount = Math.min(recentHits.length, CHANNEL_FRECENCY_MAX_SAMPLES);
|
||||
let score = 0;
|
||||
for (let index = 0; index < sampleCount; index++) {
|
||||
score += channelFrecencyWeight(channelFrecencyDayDiff(now, recentUses[index]));
|
||||
score += channelFrecencyWeight(channelFrecencyDayDiff(now, recentHits[index]));
|
||||
}
|
||||
return score;
|
||||
}
|
||||
@@ -68,15 +68,15 @@ export function trackChannelUse(history: ChannelFrecencyHistory, key: string, ti
|
||||
history.set(key, createEntry(1, [use], CHANNEL_FRECENCY_UNCOMPUTED, 0));
|
||||
return;
|
||||
}
|
||||
const recentUses = [...entry.recentUses, use];
|
||||
const recentHits = [...entry.recentHits, use];
|
||||
if (timestamp !== undefined) {
|
||||
recentUses.sort(compareAscending);
|
||||
recentHits.sort(compareAscending);
|
||||
}
|
||||
history.set(
|
||||
key,
|
||||
createEntry(
|
||||
entry.totalUses + 1,
|
||||
recentUses.slice(-CHANNEL_FRECENCY_MAX_SAMPLES),
|
||||
entry.hitCount + 1,
|
||||
recentHits.slice(-CHANNEL_FRECENCY_MAX_SAMPLES),
|
||||
CHANNEL_FRECENCY_UNCOMPUTED,
|
||||
entry.score,
|
||||
),
|
||||
@@ -85,11 +85,11 @@ export function trackChannelUse(history: ChannelFrecencyHistory, key: string, ti
|
||||
|
||||
export function computeChannelFrecency(history: ChannelFrecencyHistory, now: number): void {
|
||||
for (const [key, entry] of history) {
|
||||
if (entry.frecency !== CHANNEL_FRECENCY_UNCOMPUTED) continue;
|
||||
const score = scoreRecentUses(entry.recentUses, now);
|
||||
if (entry.heat !== CHANNEL_FRECENCY_UNCOMPUTED) continue;
|
||||
const score = scoreRecentUses(entry.recentHits, now);
|
||||
if (score > 0) {
|
||||
const frecency = Math.ceil(entry.totalUses * (score / entry.recentUses.length));
|
||||
history.set(key, createEntry(entry.totalUses, entry.recentUses, frecency, score));
|
||||
const heat = Math.ceil(entry.hitCount * (score / entry.recentHits.length));
|
||||
history.set(key, createEntry(entry.hitCount, entry.recentHits, heat, score));
|
||||
} else {
|
||||
history.delete(key);
|
||||
}
|
||||
@@ -100,7 +100,7 @@ function findOldestLastUseKey(history: ReadonlyMap<string, ChannelFrecencyEntry>
|
||||
let oldestKey: string | null = null;
|
||||
let oldestLastUse = Number.POSITIVE_INFINITY;
|
||||
for (const [key, entry] of history) {
|
||||
const lastUse = entry.recentUses.at(-1);
|
||||
const lastUse = entry.recentHits.at(-1);
|
||||
if (lastUse !== undefined && lastUse < oldestLastUse) {
|
||||
oldestKey = key;
|
||||
oldestLastUse = lastUse;
|
||||
@@ -125,10 +125,10 @@ export function rankFrequentChannelIds(
|
||||
for (const [key, entry] of history) {
|
||||
const id = resolveRecordId(key);
|
||||
if (id !== null) {
|
||||
ranked.push({id, frecency: entry.frecency});
|
||||
ranked.push({id, heat: entry.heat});
|
||||
}
|
||||
}
|
||||
ranked.sort((a, b) => b.frecency - a.frecency);
|
||||
ranked.sort((a, b) => b.heat - a.heat);
|
||||
return Object.freeze(ranked.slice(0, CHANNEL_FRECENCY_MAX_ITEMS).map((item) => item.id));
|
||||
}
|
||||
|
||||
@@ -142,7 +142,7 @@ export type ChannelFrecencyWireUsage = Record<string, {totalUses: number; recent
|
||||
export type ChannelFrecencyWireUsageInput = Readonly<Record<string, ChannelFrecencyWireEntry | undefined>>;
|
||||
|
||||
function entryToWire(entry: ChannelFrecencyEntry): {totalUses: number; recentUsesMs: Array<bigint>} {
|
||||
return {totalUses: entry.totalUses, recentUsesMs: entry.recentUses.map((use) => BigInt(use))};
|
||||
return {totalUses: entry.hitCount, recentUsesMs: entry.recentHits.map((use) => BigInt(use))};
|
||||
}
|
||||
|
||||
export function channelFrecencyHistoryToWire(
|
||||
@@ -164,11 +164,11 @@ export function channelFrecencyHistoryFromWire(
|
||||
usage: ChannelFrecencyWireUsageInput,
|
||||
now: number,
|
||||
): ChannelFrecencyHistory {
|
||||
const persisted: Array<readonly [string, unknown]> = [];
|
||||
const revived: Array<readonly [string, unknown]> = [];
|
||||
for (const [key, entry] of Object.entries(usage)) {
|
||||
persisted.push([key, {totalUses: entry?.totalUses, recentUses: readWireUses(entry)}]);
|
||||
revived.push([key, {hitCount: entry?.totalUses, recentHits: readWireUses(entry)}]);
|
||||
}
|
||||
return restoreChannelFrecencyHistory(persisted, now);
|
||||
return restoreChannelFrecencyHistory(revived, now);
|
||||
}
|
||||
|
||||
export function mergeChannelFrecencyWireUsage(
|
||||
@@ -199,17 +199,17 @@ function isUseTimestamp(value: unknown): value is number {
|
||||
|
||||
function readPersistedEntry(value: unknown): ChannelFrecencyEntry | null {
|
||||
if (typeof value !== 'object' || value === null) return null;
|
||||
const {totalUses, recentUses} = value as {readonly totalUses?: unknown; readonly recentUses?: unknown};
|
||||
if (typeof totalUses !== 'number' || !Number.isFinite(totalUses) || !Array.isArray(recentUses)) return null;
|
||||
return createEntry(totalUses, recentUses.filter(isUseTimestamp), CHANNEL_FRECENCY_UNCOMPUTED, 0);
|
||||
const {hitCount, recentHits} = value as {readonly hitCount?: unknown; readonly recentHits?: unknown};
|
||||
if (typeof hitCount !== 'number' || !Number.isFinite(hitCount) || !Array.isArray(recentHits)) return null;
|
||||
return createEntry(hitCount, recentHits.filter(isUseTimestamp), CHANNEL_FRECENCY_UNCOMPUTED, 0);
|
||||
}
|
||||
|
||||
export function restoreChannelFrecencyHistory(
|
||||
persisted: Iterable<readonly [unknown, unknown]>,
|
||||
revived: Iterable<readonly [unknown, unknown]>,
|
||||
now: number,
|
||||
): ChannelFrecencyHistory {
|
||||
const history: ChannelFrecencyHistory = new Map();
|
||||
for (const [key, value] of persisted) {
|
||||
for (const [key, value] of revived) {
|
||||
if (typeof key !== 'string') continue;
|
||||
const entry = readPersistedEntry(value);
|
||||
if (entry !== null) {
|
||||
|
||||
@@ -73,6 +73,11 @@ const INVITES_DISABLED_NAGBAR_DESCRIPTOR = msg({
|
||||
message: 'Invites disabled nagbar',
|
||||
comment: 'Developer control label for the invites-disabled banner.',
|
||||
});
|
||||
const DOMAIN_MOVED_NAGBAR_DESCRIPTOR = msg({
|
||||
message: 'Domain moved nagbar',
|
||||
comment:
|
||||
'Developer or debug surface, keep terse and technical. Label in the developer Nagbar controls panel for the banner telling installed web apps that the app has moved to a new domain.',
|
||||
});
|
||||
const GUILD_MFA_REQUIREMENT_NAGBAR_DESCRIPTOR = msg({
|
||||
message: 'Community MFA requirement nagbar',
|
||||
comment: 'Developer control label for the community MFA requirement banner.',
|
||||
@@ -409,4 +414,16 @@ export const getNagbarControls = (): Array<NagbarControlDefinition> => [
|
||||
forceShowDisabled: (state) => state.forceGuildMfaRequirement,
|
||||
forceHideDisabled: (state) => state.forceHideGuildMfaRequirement,
|
||||
},
|
||||
{
|
||||
key: 'forceDomainMoved',
|
||||
label: DOMAIN_MOVED_NAGBAR_DESCRIPTOR,
|
||||
forceKey: 'forceDomainMoved',
|
||||
forceHideKey: 'forceHideDomainMoved',
|
||||
resetKeys: ['forceDomainMoved'],
|
||||
status: (state) =>
|
||||
state.forceDomainMoved ? FORCE_ENABLED : state.forceHideDomainMoved ? FORCE_DISABLED : USING_ACTUAL_STATE,
|
||||
useActualDisabled: (state) => !state.forceDomainMoved && !state.forceHideDomainMoved,
|
||||
forceShowDisabled: (state) => state.forceDomainMoved,
|
||||
forceHideDisabled: (state) => state.forceHideDomainMoved,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {startDomainMigrationTrigger} from '@app/features/app/domain_migration/DomainMigrationTrigger';
|
||||
import Initialization from '@app/features/app/state/Initialization';
|
||||
import AccountManager from '@app/features/auth/state/AccountManager';
|
||||
import accountStorage from '@app/features/auth/state/AccountStorage';
|
||||
@@ -184,4 +185,5 @@ function handleReadyInternal(data: ReadyPayload, context: GatewayHandlerContext)
|
||||
Initialization.setReady();
|
||||
context.setReady();
|
||||
Messages.handleGatewayReady();
|
||||
startDomainMigrationTrigger();
|
||||
}
|
||||
|
||||
@@ -8,6 +8,8 @@ const OFFICIAL_GIFT_URL_BASES = Object.freeze([
|
||||
'https://canary.fluxer.app/gift',
|
||||
'https://web.fluxer.app/gift',
|
||||
'https://web.canary.fluxer.app/gift',
|
||||
'https://fluxer.com/gift',
|
||||
'https://canary.fluxer.com/gift',
|
||||
'https://fluxer.gift',
|
||||
'https://fluxer.gift/gift',
|
||||
]);
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1521,6 +1521,9 @@
|
||||
{
|
||||
"msgid": "Approval required"
|
||||
},
|
||||
{
|
||||
"msgid": "Approve this app from the {productName} app you already use. No password needed."
|
||||
},
|
||||
{
|
||||
"msgid": "Are you sure you want to kick <0>{targetUserTag}</0> from the community? They will be able to rejoin with a new invite."
|
||||
},
|
||||
@@ -1716,6 +1719,9 @@
|
||||
{
|
||||
"msgid": "Choices"
|
||||
},
|
||||
{
|
||||
"msgid": "Choose File, then Add to Dock."
|
||||
},
|
||||
{
|
||||
"msgid": "Choose how new accounts are created on this instance."
|
||||
},
|
||||
@@ -1782,6 +1788,12 @@
|
||||
{
|
||||
"msgid": "Contact the administrators of this instance for help."
|
||||
},
|
||||
{
|
||||
"msgid": "Continue to create a passkey for {host}"
|
||||
},
|
||||
{
|
||||
"msgid": "Continue with your passkey to sign in to {host}"
|
||||
},
|
||||
{
|
||||
"msgid": "Copy emoji"
|
||||
},
|
||||
@@ -1803,6 +1815,9 @@
|
||||
{
|
||||
"msgid": "Couldn't update passkey two-factor authentication"
|
||||
},
|
||||
{
|
||||
"msgid": "Couldn't use your passkey in the pop-up window. Try again."
|
||||
},
|
||||
{
|
||||
"msgid": "Create administrator account"
|
||||
},
|
||||
@@ -1878,6 +1893,9 @@
|
||||
{
|
||||
"msgid": "Do you want to join {communityName}?"
|
||||
},
|
||||
{
|
||||
"msgid": "Domain moved nagbar"
|
||||
},
|
||||
{
|
||||
"msgid": "Drag members between voice channels they can access, and disconnect them from voice."
|
||||
},
|
||||
@@ -1932,6 +1950,9 @@
|
||||
{
|
||||
"msgid": "Enter the PIN for your security key to continue."
|
||||
},
|
||||
{
|
||||
"msgid": "Enter this code in your old {PRODUCT_NAME} app to complete sign-in."
|
||||
},
|
||||
{
|
||||
"msgid": "Entire screen"
|
||||
},
|
||||
@@ -1968,6 +1989,9 @@
|
||||
{
|
||||
"msgid": "Fully quit and restart {productName} so macOS applies this permission."
|
||||
},
|
||||
{
|
||||
"msgid": "Get the new {productName} app"
|
||||
},
|
||||
{
|
||||
"msgid": "Give everyone on this instance the highest premium limits."
|
||||
},
|
||||
@@ -2025,6 +2049,12 @@
|
||||
{
|
||||
"msgid": "Input level"
|
||||
},
|
||||
{
|
||||
"msgid": "Install it from your browser menu."
|
||||
},
|
||||
{
|
||||
"msgid": "Install the new app"
|
||||
},
|
||||
{
|
||||
"msgid": "Invite new people to the community with a link to this channel."
|
||||
},
|
||||
@@ -2085,6 +2115,9 @@
|
||||
{
|
||||
"msgid": "Limited the channel to {count, plural, one {# user} other {# users}}."
|
||||
},
|
||||
{
|
||||
"msgid": "Link a new device"
|
||||
},
|
||||
{
|
||||
"msgid": "Loading instance configuration"
|
||||
},
|
||||
@@ -2214,6 +2247,24 @@
|
||||
{
|
||||
"msgid": "Open popup"
|
||||
},
|
||||
{
|
||||
"msgid": "Open the new app and choose Sign in with your old {productName} app. Then choose Link a new device here and enter the code it shows."
|
||||
},
|
||||
{
|
||||
"msgid": "Open this window from {productName} to use your passkey."
|
||||
},
|
||||
{
|
||||
"msgid": "Open your old {PRODUCT_NAME} app and choose Link a new device, then enter the code below."
|
||||
},
|
||||
{
|
||||
"msgid": "Open {host} in Safari."
|
||||
},
|
||||
{
|
||||
"msgid": "Open {host} in your browser."
|
||||
},
|
||||
{
|
||||
"msgid": "Open {productName} in your browser"
|
||||
},
|
||||
{
|
||||
"msgid": "Opens the Members page. Press Space or Enter to open."
|
||||
},
|
||||
@@ -2238,6 +2289,9 @@
|
||||
{
|
||||
"msgid": "Our phone number check is down right now. This is on us, not your number. Wait a few minutes and try the same number again."
|
||||
},
|
||||
{
|
||||
"msgid": "Password managers may not offer your passkey on this web address. Try it in a pop-up window on the old address instead."
|
||||
},
|
||||
{
|
||||
"msgid": "Pause preview when Fluxer isn’t focused"
|
||||
},
|
||||
@@ -2310,6 +2364,9 @@
|
||||
{
|
||||
"msgid": "Previous 6 days"
|
||||
},
|
||||
{
|
||||
"msgid": "Previous channels"
|
||||
},
|
||||
{
|
||||
"msgid": "Product name"
|
||||
},
|
||||
@@ -2568,6 +2625,21 @@
|
||||
{
|
||||
"msgid": "Search for {query}"
|
||||
},
|
||||
{
|
||||
"msgid": "Searching all users"
|
||||
},
|
||||
{
|
||||
"msgid": "Searching communities"
|
||||
},
|
||||
{
|
||||
"msgid": "Searching friends and members of {communityName}"
|
||||
},
|
||||
{
|
||||
"msgid": "Searching text channels"
|
||||
},
|
||||
{
|
||||
"msgid": "Searching voice channels"
|
||||
},
|
||||
{
|
||||
"msgid": "Second factor"
|
||||
},
|
||||
@@ -2580,6 +2652,9 @@
|
||||
{
|
||||
"msgid": "See this channel. Denying it for {everyoneMention} makes the channel private."
|
||||
},
|
||||
{
|
||||
"msgid": "Select the account you want to sign in with on your new device."
|
||||
},
|
||||
{
|
||||
"msgid": "Self-host setup"
|
||||
},
|
||||
@@ -2760,6 +2835,9 @@
|
||||
{
|
||||
"msgid": "Show fewer filters"
|
||||
},
|
||||
{
|
||||
"msgid": "Show me how"
|
||||
},
|
||||
{
|
||||
"msgid": "Show per-device participant volume sliders in voice menus"
|
||||
},
|
||||
@@ -2772,6 +2850,9 @@
|
||||
{
|
||||
"msgid": "Showed members with this role in their own section in the member list."
|
||||
},
|
||||
{
|
||||
"msgid": "Sign in with your old {productName} app"
|
||||
},
|
||||
{
|
||||
"msgid": "Sign-in details"
|
||||
},
|
||||
@@ -2901,6 +2982,9 @@
|
||||
{
|
||||
"msgid": "Talk in calls and test your input."
|
||||
},
|
||||
{
|
||||
"msgid": "Tap Share, then Add to Home Screen."
|
||||
},
|
||||
{
|
||||
"msgid": "Text is rendered with grayscale antialiasing instead of ClearType while transparency is enabled, so it will look softer."
|
||||
},
|
||||
@@ -3060,6 +3144,12 @@
|
||||
{
|
||||
"msgid": "This option is required. Please provide a value."
|
||||
},
|
||||
{
|
||||
"msgid": "This passkey request could not be verified. Close this window and try again."
|
||||
},
|
||||
{
|
||||
"msgid": "This passkey request timed out. Close this window and try again."
|
||||
},
|
||||
{
|
||||
"msgid": "This reset link has expired. Reset links last 1 hour. Please request a new one."
|
||||
},
|
||||
@@ -3120,12 +3210,18 @@
|
||||
{
|
||||
"msgid": "Unavailable"
|
||||
},
|
||||
{
|
||||
"msgid": "Unread channels"
|
||||
},
|
||||
{
|
||||
"msgid": "Upload files and media in messages."
|
||||
},
|
||||
{
|
||||
"msgid": "Upload files and media in this channel."
|
||||
},
|
||||
{
|
||||
"msgid": "Use a password manager passkey"
|
||||
},
|
||||
{
|
||||
"msgid": "Use emoji from other communities in this channel."
|
||||
},
|
||||
@@ -3153,6 +3249,9 @@
|
||||
{
|
||||
"msgid": "Use your operating system's spellchecker when available. Otherwise, use {productName}'s in-app dictionaries."
|
||||
},
|
||||
{
|
||||
"msgid": "Use your passkey"
|
||||
},
|
||||
{
|
||||
"msgid": "Used in Custom mode. The Gaming and Screen share presets set their own hint."
|
||||
},
|
||||
@@ -3222,6 +3321,9 @@
|
||||
{
|
||||
"msgid": "Your account works normally again straight away and nothing is removed. We can ask for this check again later."
|
||||
},
|
||||
{
|
||||
"msgid": "Your browser blocked the passkey pop-up window. Allow pop-ups for this site and try again."
|
||||
},
|
||||
{
|
||||
"msgid": "Your capture device is sending {deliveredResolution} instead of {resolution}."
|
||||
},
|
||||
@@ -3822,9 +3924,27 @@
|
||||
{
|
||||
"msgid": "{productName} cannot start a safe isolated route for this app's audio."
|
||||
},
|
||||
{
|
||||
"msgid": "{productName} has moved to {host}. Add it to your Home Screen or Dock, then sign in with this app."
|
||||
},
|
||||
{
|
||||
"msgid": "{productName} has moved to {host}. Install it from your browser, then sign in with this app."
|
||||
},
|
||||
{
|
||||
"msgid": "{productName} has moved to {host}. Install the new app and you will already be signed in."
|
||||
},
|
||||
{
|
||||
"msgid": "{productName} has moved to {host}. Open it in your browser to install the new app."
|
||||
},
|
||||
{
|
||||
"msgid": "{productName} needs screen recording access. Allow screen recording in your operating system privacy settings and restart the app."
|
||||
},
|
||||
{
|
||||
"msgid": "{productName} now lives at {host}. Install it from there, then sign in with this app."
|
||||
},
|
||||
{
|
||||
"msgid": "{productName} now lives at {host}. Install it from there."
|
||||
},
|
||||
{
|
||||
"msgid": "{productName} wordmark"
|
||||
},
|
||||
@@ -6821,6 +6941,9 @@
|
||||
{
|
||||
"msgid": "Open {displayName}"
|
||||
},
|
||||
{
|
||||
"msgid": "Open {host} in Safari."
|
||||
},
|
||||
{
|
||||
"msgid": "Open {landmarkName}"
|
||||
},
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user