mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-09 20:22:11 +09:00
Compare commits
13
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6730a242db | ||
|
|
e62ae77643 | ||
|
|
f4f39e6a89 | ||
|
|
00bf74cef5 | ||
|
|
c1c45d835f | ||
|
|
bbfe809bef | ||
|
|
e0843ac4f5 | ||
|
|
43741cdad8 | ||
|
|
b8e3807262 | ||
|
|
3304f01a84 | ||
|
|
2ba463235b | ||
|
|
15136fed59 | ||
|
|
6013581dd9 |
@@ -32,6 +32,7 @@
|
||||
/fluxer_docs/.astro/
|
||||
/fluxer_app/.devserver-cache.json
|
||||
/fluxer_app/pkgs/libfluxcore/
|
||||
/fluxer_app/pkgs/libfluxwebp/
|
||||
/fluxer_app/src/features/i18n/locales/*/messages.mjs
|
||||
/fluxer_app/src/features/messaging/utils/markdown/parser/MarkdownParserWasmBytes.ts
|
||||
/fluxer_app/src/features/theme/styles/generated/
|
||||
|
||||
@@ -123,12 +123,16 @@ jobs:
|
||||
with:
|
||||
path: |
|
||||
fluxer_app/pkgs/libfluxcore
|
||||
fluxer_app/pkgs/libfluxwebp
|
||||
fluxer_app/src/features/messaging/utils/markdown/parser/MarkdownParserWasmBytes.ts
|
||||
key: >-
|
||||
app-wasm-${{ runner.os }}-1.98.1-${{ hashFiles('Cargo.lock', 'tools/ci/src/app_wasm.rs',
|
||||
'tools/ci/templates/libfluxcore_wrapper.js', 'tools/ci/templates/libfluxcore_wrapper.d.ts',
|
||||
'fluxer_app/rust/libfluxcore/Cargo.toml', 'fluxer_app/rust/libfluxcore/Cargo.lock',
|
||||
'fluxer_app/rust/libfluxcore/.cargo/config.toml', 'fluxer_app/rust/libfluxcore/src/**',
|
||||
'fluxer_app/rust/libfluxwebp/Cargo.toml', 'fluxer_app/rust/libfluxwebp/Cargo.lock',
|
||||
'fluxer_app/rust/libfluxwebp/src/**', 'fluxer_app/rust/libfluxwebp/shim/**',
|
||||
'fluxer_app/rust/libfluxwebp/simd/**',
|
||||
'packages/markdown_parser/rust/Cargo.toml', 'packages/markdown_parser/rust/.cargo/config.toml',
|
||||
'packages/markdown_parser/rust/src/**') }}
|
||||
|
||||
@@ -142,12 +146,16 @@ jobs:
|
||||
with:
|
||||
path: |
|
||||
fluxer_app/pkgs/libfluxcore
|
||||
fluxer_app/pkgs/libfluxwebp
|
||||
fluxer_app/src/features/messaging/utils/markdown/parser/MarkdownParserWasmBytes.ts
|
||||
key: >-
|
||||
app-wasm-${{ runner.os }}-1.98.1-${{ hashFiles('Cargo.lock', 'tools/ci/src/app_wasm.rs',
|
||||
'tools/ci/templates/libfluxcore_wrapper.js', 'tools/ci/templates/libfluxcore_wrapper.d.ts',
|
||||
'fluxer_app/rust/libfluxcore/Cargo.toml', 'fluxer_app/rust/libfluxcore/Cargo.lock',
|
||||
'fluxer_app/rust/libfluxcore/.cargo/config.toml', 'fluxer_app/rust/libfluxcore/src/**',
|
||||
'fluxer_app/rust/libfluxwebp/Cargo.toml', 'fluxer_app/rust/libfluxwebp/Cargo.lock',
|
||||
'fluxer_app/rust/libfluxwebp/src/**', 'fluxer_app/rust/libfluxwebp/shim/**',
|
||||
'fluxer_app/rust/libfluxwebp/simd/**',
|
||||
'packages/markdown_parser/rust/Cargo.toml', 'packages/markdown_parser/rust/.cargo/config.toml',
|
||||
'packages/markdown_parser/rust/src/**') }}
|
||||
|
||||
@@ -190,6 +198,9 @@ jobs:
|
||||
- name: Check Rust dependencies
|
||||
run: cargo deny --locked check -D warnings
|
||||
|
||||
- name: Check libfluxwebp dependencies
|
||||
run: cargo deny --manifest-path fluxer_app/rust/libfluxwebp/Cargo.toml --config deny.toml --locked check licenses bans sources
|
||||
|
||||
- name: Check desktop native dependencies
|
||||
run: tools/ci/check-desktop-native-workspaces.sh dependencies
|
||||
|
||||
@@ -242,6 +253,9 @@ jobs:
|
||||
- name: Check formatting
|
||||
run: cargo fmt --all -- --check
|
||||
|
||||
- name: Check formatting (libfluxwebp)
|
||||
run: cargo fmt --manifest-path fluxer_app/rust/libfluxwebp/Cargo.toml -- --check
|
||||
|
||||
- name: Check formatting (desktop native workspaces)
|
||||
run: tools/ci/check-desktop-native-workspaces.sh fmt
|
||||
|
||||
@@ -398,12 +412,16 @@ jobs:
|
||||
with:
|
||||
path: |
|
||||
fluxer_app/pkgs/libfluxcore
|
||||
fluxer_app/pkgs/libfluxwebp
|
||||
fluxer_app/src/features/messaging/utils/markdown/parser/MarkdownParserWasmBytes.ts
|
||||
key: >-
|
||||
app-wasm-${{ runner.os }}-1.98.1-${{ hashFiles('Cargo.lock', 'tools/ci/src/app_wasm.rs',
|
||||
'tools/ci/templates/libfluxcore_wrapper.js', 'tools/ci/templates/libfluxcore_wrapper.d.ts',
|
||||
'fluxer_app/rust/libfluxcore/Cargo.toml', 'fluxer_app/rust/libfluxcore/Cargo.lock',
|
||||
'fluxer_app/rust/libfluxcore/.cargo/config.toml', 'fluxer_app/rust/libfluxcore/src/**',
|
||||
'fluxer_app/rust/libfluxwebp/Cargo.toml', 'fluxer_app/rust/libfluxwebp/Cargo.lock',
|
||||
'fluxer_app/rust/libfluxwebp/src/**', 'fluxer_app/rust/libfluxwebp/shim/**',
|
||||
'fluxer_app/rust/libfluxwebp/simd/**',
|
||||
'packages/markdown_parser/rust/Cargo.toml', 'packages/markdown_parser/rust/.cargo/config.toml',
|
||||
'packages/markdown_parser/rust/src/**') }}
|
||||
|
||||
@@ -417,12 +435,16 @@ jobs:
|
||||
with:
|
||||
path: |
|
||||
fluxer_app/pkgs/libfluxcore
|
||||
fluxer_app/pkgs/libfluxwebp
|
||||
fluxer_app/src/features/messaging/utils/markdown/parser/MarkdownParserWasmBytes.ts
|
||||
key: >-
|
||||
app-wasm-${{ runner.os }}-1.98.1-${{ hashFiles('Cargo.lock', 'tools/ci/src/app_wasm.rs',
|
||||
'tools/ci/templates/libfluxcore_wrapper.js', 'tools/ci/templates/libfluxcore_wrapper.d.ts',
|
||||
'fluxer_app/rust/libfluxcore/Cargo.toml', 'fluxer_app/rust/libfluxcore/Cargo.lock',
|
||||
'fluxer_app/rust/libfluxcore/.cargo/config.toml', 'fluxer_app/rust/libfluxcore/src/**',
|
||||
'fluxer_app/rust/libfluxwebp/Cargo.toml', 'fluxer_app/rust/libfluxwebp/Cargo.lock',
|
||||
'fluxer_app/rust/libfluxwebp/src/**', 'fluxer_app/rust/libfluxwebp/shim/**',
|
||||
'fluxer_app/rust/libfluxwebp/simd/**',
|
||||
'packages/markdown_parser/rust/Cargo.toml', 'packages/markdown_parser/rust/.cargo/config.toml',
|
||||
'packages/markdown_parser/rust/src/**') }}
|
||||
|
||||
|
||||
@@ -26,6 +26,7 @@
|
||||
|
||||
/fluxer_app/.devserver-cache.json
|
||||
/fluxer_app/pkgs/libfluxcore/
|
||||
/fluxer_app/pkgs/libfluxwebp/
|
||||
/fluxer_app/src/features/i18n/locales/*/messages.mjs
|
||||
/fluxer_app/src/features/messaging/utils/markdown/parser/MarkdownParserWasmBytes.ts
|
||||
/fluxer_app/src/features/theme/styles/generated/
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
{
|
||||
"auth.unknown_location": "Ubicación desconocida",
|
||||
"billing.donation_description_monthly": "Donación mensual para apoyar a {product_name}",
|
||||
"billing.donation_description_one_time": "Donación única para apoyar a {product_name}",
|
||||
"billing.donation_description_yearly": "Donación anual para apoyar a {product_name}",
|
||||
"billing.donation_name_one_time": "Donación a {product_name}",
|
||||
"billing.donation_name_recurring": "Donación recurrente a {product_name}",
|
||||
"billing.eu_withdrawal_waiver_checkout": "Si soy un consumidor de la UE/EEE, doy mi consentimiento expreso para que el contenido digital de {product_name} {premium_tier_name} se proporcione de inmediato y reconozco que pierdo mi derecho legal de desistimiento una vez que se otorgue el acceso. Esto no afecta otros derechos de consumo obligatorios. Consulta los [Términos de servicio]({terms_url}).",
|
||||
"bulk_message_deletion.complete": "Terminamos de eliminar tus mensajes. Eliminamos {message_count, plural, =0 {0 mensajes} one {# mensaje} other {# mensajes}} de {channel_count, plural, =0 {0 lugares} one {# lugar} other {# lugares}}.",
|
||||
"content.virus_detected": "Ese archivo fue marcado como potencialmente inseguro y se ha eliminado.",
|
||||
"guild.bulk_create.emoji_limit": "Se alcanzó el límite máximo de emojis ({limit}).",
|
||||
"guild.bulk_create.sticker_limit": "Se alcanzó el límite máximo de stickers ({limit}).",
|
||||
"guild.bulk_create.unknown_error": "Error desconocido.",
|
||||
"guild.default_category_text": "Canales de texto",
|
||||
"guild.default_category_voice": "Canales de voz",
|
||||
"guild.default_channel_text": "general",
|
||||
"guild.default_channel_voice": "General"
|
||||
"auth.unknown_location": "Ubicación desconocida",
|
||||
"billing.donation_description_monthly": "Donación mensual para apoyar a {product_name}",
|
||||
"billing.donation_description_one_time": "Donación única para apoyar a {product_name}",
|
||||
"billing.donation_description_yearly": "Donación anual para apoyar a {product_name}",
|
||||
"billing.donation_name_one_time": "Donación a {product_name}",
|
||||
"billing.donation_name_recurring": "Donación recurrente a {product_name}",
|
||||
"billing.eu_withdrawal_waiver_checkout": "Si soy un consumidor de la UE/EEE, doy mi consentimiento expreso para que el contenido digital de {product_name} {premium_tier_name} se proporcione de inmediato y reconozco que pierdo mi derecho legal de desistimiento una vez que se otorgue el acceso. Esto no afecta otros derechos de consumo obligatorios. Consulta los [Términos de servicio]({terms_url}).",
|
||||
"bulk_message_deletion.complete": "Terminamos de eliminar tus mensajes. Eliminamos {message_count, plural, =0 {0 mensajes} one {# mensaje} other {# mensajes}} de {channel_count, plural, =0 {0 lugares} one {# lugar} other {# lugares}}.",
|
||||
"content.virus_detected": "Ese archivo fue marcado como potencialmente inseguro y se ha eliminado.",
|
||||
"guild.bulk_create.emoji_limit": "Se alcanzó el límite máximo de emojis ({limit}).",
|
||||
"guild.bulk_create.sticker_limit": "Se alcanzó el límite máximo de stickers ({limit}).",
|
||||
"guild.bulk_create.unknown_error": "Error desconocido.",
|
||||
"guild.default_category_text": "Canales de texto",
|
||||
"guild.default_category_voice": "Canales de voz",
|
||||
"guild.default_channel_text": "general",
|
||||
"guild.default_channel_voice": "General"
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -84,6 +84,41 @@ describe('stripNonJpegImageMetadataForUpload', () => {
|
||||
});
|
||||
});
|
||||
|
||||
function riffChunk(type: string, data: Uint8Array): Uint8Array {
|
||||
const out = new Uint8Array(8 + data.length + (data.length & 1));
|
||||
out.set(textBytes(type), 0);
|
||||
new DataView(out.buffer).setUint32(4, data.length, true);
|
||||
out.set(data, 8);
|
||||
return out;
|
||||
}
|
||||
|
||||
function webp(chunks: ReadonlyArray<Uint8Array>): Uint8Array {
|
||||
const body = concatBytes(chunks);
|
||||
const header = concatBytes([textBytes('RIFF'), new Uint8Array(4), textBytes('WEBP')]);
|
||||
new DataView(header.buffer).setUint32(4, 4 + body.length, true);
|
||||
return concatBytes([header, body]);
|
||||
}
|
||||
|
||||
describe('stripNonJpegImageMetadataForUpload for WebP', () => {
|
||||
it('drops EXIF and XMP chunks without re-encoding frames', async () => {
|
||||
const vp8x = new Uint8Array(10);
|
||||
vp8x[0] = 0x02 | 0x08 | 0x04;
|
||||
const anmf = riffChunk('ANMF', new Uint8Array([9, 8, 7]));
|
||||
const input = webp([
|
||||
riffChunk('VP8X', vp8x),
|
||||
riffChunk('ANIM', new Uint8Array(6)),
|
||||
anmf,
|
||||
riffChunk('EXIF', textBytes('GPS=1,2')),
|
||||
riffChunk('XMP ', textBytes('private metadata')),
|
||||
]);
|
||||
const stripped = await stripNonJpegImageMetadataForUpload(input, 'image/webp');
|
||||
const expectedVp8x = new Uint8Array(10);
|
||||
expectedVp8x[0] = 0x02;
|
||||
expect(stripped.contentType).toBe('image/webp');
|
||||
expect(stripped.body).toEqual(webp([riffChunk('VP8X', expectedVp8x), riffChunk('ANIM', new Uint8Array(6)), anmf]));
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildProcessedMediaObject', () => {
|
||||
it('leaves non-media objects for plain copy', async () => {
|
||||
await expect(buildProcessedMediaObject(textBytes('plain text'), 'text/plain')).resolves.toBeNull();
|
||||
|
||||
@@ -162,6 +162,8 @@ export async function stripNonJpegImageMetadataForUpload(
|
||||
contentType: normalizedContentType === 'image/apng' ? 'image/apng' : 'image/png',
|
||||
};
|
||||
}
|
||||
const strippedWebp = isWebp(data) ? stripWebpMetadataChunks(data) : null;
|
||||
if (strippedWebp) return {body: strippedWebp, contentType: 'image/webp'};
|
||||
const image = sharp(data, {animated: true});
|
||||
const metadata = await image.metadata();
|
||||
switch (metadata.format) {
|
||||
@@ -242,6 +244,50 @@ function stripPngMetadataChunks(data: Uint8Array): Uint8Array {
|
||||
return output;
|
||||
}
|
||||
|
||||
const WEBP_CHUNKS_TO_KEEP = new Set(['VP8 ', 'VP8L', 'VP8X', 'ALPH', 'ANIM', 'ANMF', 'ICCP']);
|
||||
const WEBP_VP8X_EXIF_FLAG = 0x08;
|
||||
const WEBP_VP8X_XMP_FLAG = 0x04;
|
||||
|
||||
function readFourCc(data: Uint8Array, offset: number): string {
|
||||
return String.fromCharCode(data[offset]!, data[offset + 1]!, data[offset + 2]!, data[offset + 3]!);
|
||||
}
|
||||
|
||||
function readU32LE(data: Uint8Array, offset: number): number {
|
||||
return (data[offset]! | (data[offset + 1]! << 8) | (data[offset + 2]! << 16) | (data[offset + 3]! << 24)) >>> 0;
|
||||
}
|
||||
|
||||
function isWebp(data: Uint8Array): boolean {
|
||||
return data.length >= 12 && readFourCc(data, 0) === 'RIFF' && readFourCc(data, 8) === 'WEBP';
|
||||
}
|
||||
|
||||
function stripWebpMetadataChunks(data: Uint8Array): Uint8Array | null {
|
||||
const riffEnd = Math.min(data.length, 8 + readU32LE(data, 4));
|
||||
const chunks: Array<Uint8Array> = [];
|
||||
let offset = 12;
|
||||
while (offset + 8 <= riffEnd) {
|
||||
const length = readU32LE(data, offset + 4);
|
||||
const chunkEnd = offset + 8 + length + (length & 1);
|
||||
if (offset + 8 + length > riffEnd) return null;
|
||||
const type = readFourCc(data, offset);
|
||||
if (WEBP_CHUNKS_TO_KEEP.has(type)) {
|
||||
const chunk = data.slice(offset, Math.min(chunkEnd, riffEnd));
|
||||
if (type === 'VP8X' && length > 0) chunk[8] = (chunk[8] ?? 0) & ~(WEBP_VP8X_EXIF_FLAG | WEBP_VP8X_XMP_FLAG);
|
||||
chunks.push(chunk);
|
||||
}
|
||||
offset = chunkEnd;
|
||||
}
|
||||
const bodyLength = chunks.reduce((sum, chunk) => sum + chunk.length, 0);
|
||||
const output = new Uint8Array(12 + bodyLength);
|
||||
output.set(data.subarray(0, 12));
|
||||
new DataView(output.buffer).setUint32(4, 4 + bodyLength, true);
|
||||
let cursor = 12;
|
||||
for (const chunk of chunks) {
|
||||
output.set(chunk, cursor);
|
||||
cursor += chunk.length;
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
function imageExtensionForContentType(contentType: string): string {
|
||||
if (contentType.includes('svg')) return 'svg';
|
||||
if (contentType.includes('tiff')) return 'tiff';
|
||||
|
||||
@@ -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});
|
||||
}
|
||||
|
||||
@@ -433,6 +433,7 @@ export default () => {
|
||||
staticFilesPlugin({
|
||||
staticCdnEndpoint: normalizedStaticCdnEndpoint,
|
||||
fontsDir: path.join(MONOREPO_ROOT, 'packages', 'fonts'),
|
||||
wasmCratesDir: path.join(ROOT_DIR, 'rust'),
|
||||
}),
|
||||
new DefinePlugin({
|
||||
__FLUXER_PRECACHE_MANIFEST__: JSON.stringify([]),
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
BSD 3-Clause License
|
||||
|
||||
Copyright (c) 2026, Alexandre Bury
|
||||
|
||||
Redistribution and use in source and binary forms, with or without
|
||||
modification, are permitted provided that the following conditions are met:
|
||||
|
||||
1. Redistributions of source code must retain the above copyright notice, this
|
||||
list of conditions and the following disclaimer.
|
||||
|
||||
2. Redistributions in binary form must reproduce the above copyright notice,
|
||||
this list of conditions and the following disclaimer in the documentation
|
||||
and/or other materials provided with the distribution.
|
||||
|
||||
3. Neither the name of the copyright holder nor the names of its
|
||||
contributors may be used to endorse or promote products derived from
|
||||
this software without specific prior written permission.
|
||||
|
||||
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
||||
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
||||
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
||||
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
|
||||
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
|
||||
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
|
||||
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
|
||||
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
|
||||
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
||||
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||
@@ -0,0 +1,32 @@
|
||||
The auto-generated bindings are under the 3-clause BSD license:
|
||||
|
||||
BSD License
|
||||
|
||||
For Zstandard software
|
||||
|
||||
Copyright (c) Meta Platforms, Inc. and affiliates. All rights reserved.
|
||||
|
||||
Redistribution and use in source and binary forms, with or without modification,
|
||||
are permitted provided that the following conditions are met:
|
||||
|
||||
* Redistributions of source code must retain the above copyright notice, this
|
||||
list of conditions and the following disclaimer.
|
||||
|
||||
* Redistributions in binary form must reproduce the above copyright notice,
|
||||
this list of conditions and the following disclaimer in the documentation
|
||||
and/or other materials provided with the distribution.
|
||||
|
||||
* Neither the name Facebook, nor Meta, nor the names of its contributors may
|
||||
be used to endorse or promote products derived from this software without
|
||||
specific prior written permission.
|
||||
|
||||
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
|
||||
ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
|
||||
WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
||||
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR
|
||||
ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
|
||||
(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
|
||||
LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
|
||||
ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
|
||||
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
|
||||
SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||
@@ -0,0 +1,30 @@
|
||||
BSD License
|
||||
|
||||
For Zstandard software
|
||||
|
||||
Copyright (c) Meta Platforms, Inc. and affiliates. All rights reserved.
|
||||
|
||||
Redistribution and use in source and binary forms, with or without modification,
|
||||
are permitted provided that the following conditions are met:
|
||||
|
||||
* Redistributions of source code must retain the above copyright notice, this
|
||||
list of conditions and the following disclaimer.
|
||||
|
||||
* Redistributions in binary form must reproduce the above copyright notice,
|
||||
this list of conditions and the following disclaimer in the documentation
|
||||
and/or other materials provided with the distribution.
|
||||
|
||||
* Neither the name Facebook, nor Meta, nor the names of its contributors may
|
||||
be used to endorse or promote products derived from this software without
|
||||
specific prior written permission.
|
||||
|
||||
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
|
||||
ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
|
||||
WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
||||
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR
|
||||
ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
|
||||
(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
|
||||
LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
|
||||
ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
|
||||
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
|
||||
SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||
@@ -0,0 +1,12 @@
|
||||
# libfluxcore licenses
|
||||
|
||||
`libfluxcore_bg.wasm` is built from this crate and bundled into the app. It
|
||||
contains third-party code that keeps its upstream license.
|
||||
|
||||
| Component | Source | License |
|
||||
| --- | --- | --- |
|
||||
| Zstandard 1.5.7 | Vendored by the `zstd-sys` 2.1.0 crate | BSD-3-Clause, see `LICENSE-ZSTD.txt` (Zstandard is dual licensed, Fluxer uses it under BSD-3-Clause) |
|
||||
| `zstd-sys` 2.1.0 | Rust bindings, build script and WebAssembly libc shim | BSD-3-Clause, see `LICENSE-ZSTD-SYS.txt` |
|
||||
| `zstd` 0.14.0 | Rust wrapper | BSD-3-Clause, see `LICENSE-ZSTD-RS.txt` |
|
||||
|
||||
No Fluxer license notice grants rights to third-party trademarks or brand names.
|
||||
@@ -16,6 +16,22 @@ pub fn is_animated_image_bytes(input: &[u8]) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
pub fn sniff_image_format_bytes(input: &[u8]) -> u8 {
|
||||
if is_png(input) {
|
||||
1
|
||||
} else if is_gif(input) {
|
||||
2
|
||||
} else if is_webp(input) {
|
||||
3
|
||||
} else if is_avif_file(input) {
|
||||
4
|
||||
} else if input.starts_with(&[0xff, 0xd8, 0xff]) {
|
||||
5
|
||||
} else {
|
||||
0
|
||||
}
|
||||
}
|
||||
|
||||
fn is_gif(input: &[u8]) -> bool {
|
||||
input.starts_with(b"GIF89a") || input.starts_with(b"GIF87a")
|
||||
}
|
||||
@@ -35,7 +51,16 @@ fn is_avif_file(input: &[u8]) -> bool {
|
||||
}
|
||||
|
||||
fn has_avif_anim(input: &[u8]) -> bool {
|
||||
is_avif_file(input) && &input[8..12] == b"avis"
|
||||
if !is_avif_file(input) {
|
||||
return false;
|
||||
}
|
||||
if &input[8..12] == b"avis" {
|
||||
return true;
|
||||
}
|
||||
let box_end = read_u32_be(input, 0).map_or(0, |size| (size as usize).min(input.len()));
|
||||
input
|
||||
.get(16..box_end)
|
||||
.is_some_and(|brands| brands.as_chunks::<4>().0.contains(b"avis"))
|
||||
}
|
||||
|
||||
fn has_apng_actl(input: &[u8]) -> bool {
|
||||
@@ -243,13 +268,53 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn detects_avif_sequence_brand() {
|
||||
let avif = b"\x00\x00\x00\x18ftypavif\x00\x00\x00\x00avis";
|
||||
assert!(!is_animated_image_bytes(avif));
|
||||
let still = b"\x00\x00\x00\x18ftypavif\x00\x00\x00\x00mif1miaf";
|
||||
assert!(!is_animated_image_bytes(still));
|
||||
|
||||
let avis = b"\x00\x00\x00\x18ftypavis\x00\x00\x00\x00avif";
|
||||
assert!(is_animated_image_bytes(avis));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detects_avis_among_compatible_brands() {
|
||||
let compat = b"\x00\x00\x00\x1cftypavif\x00\x00\x00\x00mif1avismsf1";
|
||||
assert!(is_animated_image_bytes(compat));
|
||||
|
||||
let outside_ftyp = b"\x00\x00\x00\x14ftypavif\x00\x00\x00\x00mif1avis";
|
||||
assert!(!is_animated_image_bytes(outside_ftyp));
|
||||
|
||||
let truncated = b"\x00\x00\x00\x40ftypavif\x00\x00\x00\x00mif1av";
|
||||
assert!(!is_animated_image_bytes(truncated));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sniffs_image_formats_from_magic_bytes() {
|
||||
assert_eq!(sniff_image_format_bytes(b""), 0);
|
||||
assert_eq!(sniff_image_format_bytes(b"<svg xmlns"), 0);
|
||||
assert_eq!(
|
||||
sniff_image_format_bytes(b"\x89PNG\r\n\x1a\n\0\0\0\rIHDR"),
|
||||
1
|
||||
);
|
||||
assert_eq!(sniff_image_format_bytes(b"GIF89a\x01\x00"), 2);
|
||||
assert_eq!(sniff_image_format_bytes(b"GIF87a\x01\x00"), 2);
|
||||
assert_eq!(sniff_image_format_bytes(b"RIFF\x04\0\0\0WEBPVP8 "), 3);
|
||||
assert_eq!(sniff_image_format_bytes(b"RIFF\x04\0\0\0WAVEfmt "), 0);
|
||||
assert_eq!(
|
||||
sniff_image_format_bytes(b"\x00\x00\x00\x18ftypavif\x00\x00\x00\x00"),
|
||||
4
|
||||
);
|
||||
assert_eq!(
|
||||
sniff_image_format_bytes(b"\x00\x00\x00\x18ftypavis\x00\x00\x00\x00"),
|
||||
4
|
||||
);
|
||||
assert_eq!(
|
||||
sniff_image_format_bytes(b"\x00\x00\x00\x18ftypheic\x00\x00\x00\x00"),
|
||||
0
|
||||
);
|
||||
assert_eq!(sniff_image_format_bytes(b"\xff\xd8\xff\xe0\x00\x10JFIF"), 5);
|
||||
assert_eq!(sniff_image_format_bytes(b"\xff\xd8"), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_truncated_chunks_without_panicking() {
|
||||
assert!(!is_animated_image_bytes(
|
||||
|
||||
@@ -5,8 +5,8 @@ mod rgba;
|
||||
mod zstd_frame;
|
||||
mod zstd_stream;
|
||||
|
||||
use formats::is_animated_image_bytes;
|
||||
use rgba::{TransformRequest, crop_rotate_rgba_alloc};
|
||||
use formats::{is_animated_image_bytes, sniff_image_format_bytes};
|
||||
use rgba::{TransformRequest, crop_rotate_rgba_alloc, crop_rotate_rgba_into};
|
||||
use wasm_bindgen::prelude::*;
|
||||
|
||||
#[wasm_bindgen]
|
||||
@@ -40,6 +40,39 @@ pub fn crop_rotate_rgba_raw(
|
||||
.map_err(|error| JsValue::from_str(error.message()))
|
||||
}
|
||||
|
||||
#[wasm_bindgen]
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn crop_rotate_rgba_into_buffer(
|
||||
input: &[u8],
|
||||
output: &mut [u8],
|
||||
src_width: u32,
|
||||
src_height: u32,
|
||||
x: u32,
|
||||
y: u32,
|
||||
width: u32,
|
||||
height: u32,
|
||||
rotation_deg: u32,
|
||||
output_width: u32,
|
||||
output_height: u32,
|
||||
) -> Result<(), JsValue> {
|
||||
crop_rotate_rgba_into(
|
||||
input,
|
||||
output,
|
||||
TransformRequest {
|
||||
src_width,
|
||||
src_height,
|
||||
x,
|
||||
y,
|
||||
width,
|
||||
height,
|
||||
rotation_deg,
|
||||
resize_width: output_width,
|
||||
resize_height: output_height,
|
||||
},
|
||||
)
|
||||
.map_err(|error| JsValue::from_str(error.message()))
|
||||
}
|
||||
|
||||
#[wasm_bindgen]
|
||||
pub fn decompress_zstd_frame(input: &[u8]) -> Result<Vec<u8>, JsValue> {
|
||||
zstd_frame::decompress(input).map_err(zstd_error_to_js)
|
||||
@@ -106,6 +139,11 @@ pub fn is_animated_image(input: &[u8]) -> bool {
|
||||
is_animated_image_bytes(input)
|
||||
}
|
||||
|
||||
#[wasm_bindgen]
|
||||
pub fn sniff_image_format(input: &[u8]) -> u8 {
|
||||
sniff_image_format_bytes(input)
|
||||
}
|
||||
|
||||
fn optional_dimension_to_abi(value: Option<u32>) -> u32 {
|
||||
value.filter(|dimension| *dimension > 0).unwrap_or(u32::MAX)
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ pub struct TransformRequest {
|
||||
pub enum TransformError {
|
||||
InvalidDimensions,
|
||||
InvalidRgbaLength,
|
||||
InvalidOutputLength,
|
||||
EmptyCrop,
|
||||
EmptyTarget,
|
||||
ImageTooLarge,
|
||||
@@ -33,6 +34,7 @@ impl TransformError {
|
||||
match self {
|
||||
Self::InvalidDimensions => "invalid RGBA dimensions",
|
||||
Self::InvalidRgbaLength => "RGBA input length does not match dimensions",
|
||||
Self::InvalidOutputLength => "RGBA output length does not match target dimensions",
|
||||
Self::EmptyCrop => "Crop area is empty",
|
||||
Self::EmptyTarget => "Target dimensions are empty",
|
||||
Self::ImageTooLarge => "Image is too large to crop",
|
||||
@@ -75,12 +77,7 @@ pub fn crop_rotate_rgba_alloc(
|
||||
input: &[u8],
|
||||
request: TransformRequest,
|
||||
) -> Result<Vec<u8>, TransformError> {
|
||||
let geometry = output_geometry(request)?;
|
||||
let expected_len = rgba_byte_len(request.src_width, request.src_height, 0)?;
|
||||
if input.len() != expected_len {
|
||||
return Err(TransformError::InvalidRgbaLength);
|
||||
}
|
||||
|
||||
let geometry = checked_geometry(input, request)?;
|
||||
let output_len = rgba_byte_len(
|
||||
geometry.target_width,
|
||||
geometry.target_height,
|
||||
@@ -89,8 +86,45 @@ pub fn crop_rotate_rgba_alloc(
|
||||
let mut output = try_zeroed_vec(output_len)?;
|
||||
write_u32_le(&mut output, 0, geometry.target_width);
|
||||
write_u32_le(&mut output, 4, geometry.target_height);
|
||||
write_transformed(
|
||||
input,
|
||||
&mut output[RGBA_RESULT_HEADER_BYTES..],
|
||||
request,
|
||||
geometry,
|
||||
);
|
||||
Ok(output)
|
||||
}
|
||||
|
||||
let dst = &mut output[RGBA_RESULT_HEADER_BYTES..];
|
||||
pub fn crop_rotate_rgba_into(
|
||||
input: &[u8],
|
||||
output: &mut [u8],
|
||||
request: TransformRequest,
|
||||
) -> Result<(), TransformError> {
|
||||
let geometry = checked_geometry(input, request)?;
|
||||
if output.len() != rgba_byte_len(geometry.target_width, geometry.target_height, 0)? {
|
||||
return Err(TransformError::InvalidOutputLength);
|
||||
}
|
||||
write_transformed(input, output, request, geometry);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn checked_geometry(
|
||||
input: &[u8],
|
||||
request: TransformRequest,
|
||||
) -> Result<OutputGeometry, TransformError> {
|
||||
let geometry = output_geometry(request)?;
|
||||
if input.len() != rgba_byte_len(request.src_width, request.src_height, 0)? {
|
||||
return Err(TransformError::InvalidRgbaLength);
|
||||
}
|
||||
Ok(geometry)
|
||||
}
|
||||
|
||||
fn write_transformed(
|
||||
input: &[u8],
|
||||
dst: &mut [u8],
|
||||
request: TransformRequest,
|
||||
geometry: OutputGeometry,
|
||||
) {
|
||||
if geometry.target_width == geometry.base_width
|
||||
&& geometry.target_height == geometry.base_height
|
||||
{
|
||||
@@ -98,8 +132,6 @@ pub fn crop_rotate_rgba_alloc(
|
||||
} else {
|
||||
copy_rotated_with_nearest_resize(input, dst, request, geometry);
|
||||
}
|
||||
|
||||
Ok(output)
|
||||
}
|
||||
|
||||
fn output_geometry(request: TransformRequest) -> Result<OutputGeometry, TransformError> {
|
||||
@@ -463,6 +495,30 @@ mod tests {
|
||||
assert!(crop_rotate_rgba_alloc(&rgba(&[1, 2, 3, 4]), empty_target).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn into_writes_the_same_pixels_as_alloc() {
|
||||
let input = rgba(&[1, 2, 3, 4, 5, 6]);
|
||||
for rotation_deg in [0, 90, 180, 270] {
|
||||
let mut transform = request(2, 3);
|
||||
transform.rotation_deg = rotation_deg;
|
||||
transform.resize_width = 5;
|
||||
transform.resize_height = 4;
|
||||
let expected = crop_rotate_rgba_alloc(&input, transform).unwrap();
|
||||
let mut output = vec![0xaa; 5 * 4 * RGBA_BYTES_PER_PIXEL];
|
||||
crop_rotate_rgba_into(&input, &mut output, transform).unwrap();
|
||||
assert_eq!(output, payload(&expected));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn into_rejects_an_output_of_the_wrong_length() {
|
||||
let mut output = vec![0; 3 * RGBA_BYTES_PER_PIXEL];
|
||||
assert_eq!(
|
||||
crop_rotate_rgba_into(&rgba(&[1, 2, 3, 4]), &mut output, request(2, 2)).unwrap_err(),
|
||||
TransformError::InvalidOutputLength
|
||||
);
|
||||
}
|
||||
|
||||
proptest! {
|
||||
#[test]
|
||||
fn identity_transform_preserves_pixels(width in 1u32..16, height in 1u32..16) {
|
||||
|
||||
Generated
+160
@@ -0,0 +1,160 @@
|
||||
# This file is automatically @generated by Cargo.
|
||||
# It is not intended for manual editing.
|
||||
version = 4
|
||||
|
||||
[[package]]
|
||||
name = "bumpalo"
|
||||
version = "3.20.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
|
||||
|
||||
[[package]]
|
||||
name = "cc"
|
||||
version = "1.4.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "54413ede23c2daf518f35156dfde027feb2374004d63bd497f983c8db9c0e313"
|
||||
dependencies = [
|
||||
"find-msvc-tools",
|
||||
"shlex",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cfg-if"
|
||||
version = "1.0.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600"
|
||||
|
||||
[[package]]
|
||||
name = "find-msvc-tools"
|
||||
version = "0.1.13"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ef25905e51abafe4dcea6c15fec58c57b601cdbd0ee53d22ea1d3016c587d39b"
|
||||
|
||||
[[package]]
|
||||
name = "glob"
|
||||
version = "0.3.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b"
|
||||
|
||||
[[package]]
|
||||
name = "libfluxwebp"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"libwebp-sys",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "libwebp-sys"
|
||||
version = "0.14.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6b3a87b44e34d17161e4f17d92a463d596cb13825dcd1758ed18fd3a721e189c"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"glob",
|
||||
"pkg-config",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "once_cell"
|
||||
version = "1.21.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
|
||||
|
||||
[[package]]
|
||||
name = "pkg-config"
|
||||
version = "0.3.34"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548"
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro2"
|
||||
version = "1.0.107"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
|
||||
dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quote"
|
||||
version = "1.0.47"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustversion"
|
||||
version = "1.0.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
|
||||
|
||||
[[package]]
|
||||
name = "shlex"
|
||||
version = "2.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "3.0.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "unicode-ident"
|
||||
version = "1.0.26"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954"
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen"
|
||||
version = "0.2.128"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "aecb87a33d3b0c5e3b7aa46336eaf486cffafbd281b195e4c8b80d50df2351bf"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"once_cell",
|
||||
"rustversion",
|
||||
"wasm-bindgen-macro",
|
||||
"wasm-bindgen-shared",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-macro"
|
||||
version = "0.2.128"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a690d511e3c1a8b3a55e33511e3c2c00c78415cd23650f32b808627f5696b9ed"
|
||||
dependencies = [
|
||||
"quote",
|
||||
"wasm-bindgen-macro-support",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-macro-support"
|
||||
version = "0.2.128"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "411e4887f0071ef2d2164a9d5fdf2d20efbef78fccd3a78b0c10a1dc5295e48a"
|
||||
dependencies = [
|
||||
"bumpalo",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
"wasm-bindgen-shared",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-shared"
|
||||
version = "0.2.128"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "81941cd78d0c92026c33e5e01312845a4cb1e9af3407f9134b100dd03144103e"
|
||||
dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
@@ -0,0 +1,22 @@
|
||||
[package]
|
||||
name = "libfluxwebp"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
license = "AGPL-3.0-or-later"
|
||||
publish = false
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib"]
|
||||
|
||||
[dependencies]
|
||||
wasm-bindgen = "=0.2.128"
|
||||
libwebp-sys = {version = "=0.14.4", default-features = false}
|
||||
|
||||
[profile.release]
|
||||
codegen-units = 1
|
||||
lto = true
|
||||
opt-level = "z"
|
||||
panic = "abort"
|
||||
strip = true
|
||||
|
||||
[workspace]
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) the libwebp-sys authors (XianYou, Kornel Lesiński and contributors)
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,30 @@
|
||||
Copyright (c) 2010, Google Inc. All rights reserved.
|
||||
|
||||
Redistribution and use in source and binary forms, with or without
|
||||
modification, are permitted provided that the following conditions are
|
||||
met:
|
||||
|
||||
* Redistributions of source code must retain the above copyright
|
||||
notice, this list of conditions and the following disclaimer.
|
||||
|
||||
* Redistributions in binary form must reproduce the above copyright
|
||||
notice, this list of conditions and the following disclaimer in
|
||||
the documentation and/or other materials provided with the
|
||||
distribution.
|
||||
|
||||
* Neither the name of Google nor the names of its contributors may
|
||||
be used to endorse or promote products derived from this software
|
||||
without specific prior written permission.
|
||||
|
||||
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
|
||||
"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
|
||||
LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
|
||||
A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
|
||||
HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
|
||||
SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
|
||||
LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
|
||||
DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
|
||||
THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
|
||||
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
||||
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
# libfluxwebp licenses
|
||||
|
||||
`libfluxwebp_bg.wasm` and `libfluxwebp_simd_bg.wasm` are built from this crate
|
||||
and bundled into the app. They contain third-party code that keeps its upstream
|
||||
license.
|
||||
|
||||
| Component | Source | License |
|
||||
| --- | --- | --- |
|
||||
| libwebp 1.6.0 | Vendored by the `libwebp-sys` 0.14.4 crate | BSD-3-Clause, see `LICENSE-LIBWEBP.txt`, with the additional patent grant in `PATENTS-LIBWEBP.txt` |
|
||||
| `libwebp-sys` 0.14.4 | Rust bindings and build script | MIT, see `LICENSE-LIBWEBP-SYS.txt` |
|
||||
| `simd/xmmintrin.h`, `simd/emmintrin.h` | Emscripten 4.0.15 SSE compatibility headers | MIT or University of Illinois/NCSA, see `simd/LICENSE` |
|
||||
|
||||
The SIMD build compiles libwebp's SSE2 code paths through the Emscripten
|
||||
headers, unmodified. The headers in `shim/` and `src/shim.rs` are Fluxer code.
|
||||
|
||||
No Fluxer license notice grants rights to third-party trademarks or brand names.
|
||||
@@ -0,0 +1,23 @@
|
||||
Additional IP Rights Grant (Patents)
|
||||
------------------------------------
|
||||
|
||||
"These implementations" means the copyrightable works that implement the WebM
|
||||
codecs distributed by Google as part of the WebM Project.
|
||||
|
||||
Google hereby grants to you a perpetual, worldwide, non-exclusive, no-charge,
|
||||
royalty-free, irrevocable (except as stated in this section) patent license to
|
||||
make, have made, use, offer to sell, sell, import, transfer, and otherwise
|
||||
run, modify and propagate the contents of these implementations of WebM, where
|
||||
such license applies only to those patent claims, both currently owned by
|
||||
Google and acquired in the future, licensable by Google that are necessarily
|
||||
infringed by these implementations of WebM. This grant does not include claims
|
||||
that would be infringed only as a consequence of further modification of these
|
||||
implementations. If you or your agent or exclusive licensee institute or order
|
||||
or agree to the institution of patent litigation or any other patent
|
||||
enforcement activity against any entity (including a cross-claim or
|
||||
counterclaim in a lawsuit) alleging that any of these implementations of WebM
|
||||
or any code incorporated within any of these implementations of WebM
|
||||
constitute direct or contributory patent infringement, or inducement of
|
||||
patent infringement, then any patent rights granted to you under this License
|
||||
for these implementations of WebM shall terminate as of the date such
|
||||
litigation is filed.
|
||||
@@ -0,0 +1,6 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
#ifndef FLUXWEBP_ASSERT_H
|
||||
#define FLUXWEBP_ASSERT_H
|
||||
#define assert(expr) ((void)0)
|
||||
#endif
|
||||
@@ -0,0 +1,6 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
#ifndef FLUXWEBP_INTTYPES_H
|
||||
#define FLUXWEBP_INTTYPES_H
|
||||
#include <stdint.h>
|
||||
#endif
|
||||
@@ -0,0 +1,33 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
#ifndef FLUXWEBP_MATH_H
|
||||
#define FLUXWEBP_MATH_H
|
||||
double fluxwebp_shim_pow(double x, double y);
|
||||
double fluxwebp_shim_log(double x);
|
||||
float fluxwebp_shim_expf(float x);
|
||||
double fluxwebp_shim_log10(double x);
|
||||
float fluxwebp_shim_logf(float x);
|
||||
double fluxwebp_shim_round(double x);
|
||||
#define pow(x, y) fluxwebp_shim_pow(x, y)
|
||||
#define log(x) fluxwebp_shim_log(x)
|
||||
#define expf(x) fluxwebp_shim_expf(x)
|
||||
#define log10(x) fluxwebp_shim_log10(x)
|
||||
#define logf(x) fluxwebp_shim_logf(x)
|
||||
#define round(x) fluxwebp_shim_round(x)
|
||||
#define fabs(x) __builtin_fabs(x)
|
||||
#define floor(x) __builtin_floor(x)
|
||||
#define ceil(x) __builtin_ceil(x)
|
||||
#define sqrt(x) __builtin_sqrt(x)
|
||||
#define sqrtf(x) __builtin_sqrtf(x)
|
||||
#define rint(x) __builtin_rint(x)
|
||||
#define rintf(x) __builtin_rintf(x)
|
||||
#define fabsf(x) __builtin_fabsf(x)
|
||||
#define floorf(x) __builtin_floorf(x)
|
||||
#define ceilf(x) __builtin_ceilf(x)
|
||||
#define isnan(x) __builtin_isnan(x)
|
||||
#define isinf(x) __builtin_isinf(x)
|
||||
#define lrint(x) ((long)__builtin_rint(x))
|
||||
#define llrint(x) ((long long)__builtin_rint(x))
|
||||
#define lrintf(x) ((long)__builtin_rintf(x))
|
||||
#define llrintf(x) ((long long)__builtin_rintf(x))
|
||||
#endif
|
||||
@@ -0,0 +1,50 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
#ifndef FLUXWEBP_STDIO_H
|
||||
#define FLUXWEBP_STDIO_H
|
||||
#include <stddef.h>
|
||||
#include <stdarg.h>
|
||||
typedef struct FluxWebpFile FILE;
|
||||
#define stderr ((FILE*)0)
|
||||
#define stdout ((FILE*)0)
|
||||
#define fprintf(f, ...) ((void)0)
|
||||
#define printf(...) ((void)0)
|
||||
#define fflush(f) ((void)0)
|
||||
|
||||
static inline void fluxwebp_shim_put(char* buf, size_t cap, size_t* n, char c) {
|
||||
if (*n + 1 < cap) buf[*n] = c;
|
||||
(*n)++;
|
||||
}
|
||||
|
||||
static inline int snprintf(char* buf, size_t cap, const char* fmt, ...) {
|
||||
va_list ap;
|
||||
size_t n = 0;
|
||||
va_start(ap, fmt);
|
||||
for (; *fmt; fmt++) {
|
||||
if (*fmt != '%') {
|
||||
fluxwebp_shim_put(buf, cap, &n, *fmt);
|
||||
continue;
|
||||
}
|
||||
fmt++;
|
||||
if (*fmt == 's') {
|
||||
const char* s = va_arg(ap, const char*);
|
||||
while (s && *s) fluxwebp_shim_put(buf, cap, &n, *s++);
|
||||
} else if (*fmt == 'd') {
|
||||
int v = va_arg(ap, int);
|
||||
char tmp[12];
|
||||
int i = 0;
|
||||
unsigned int u = v < 0 ? 0u - (unsigned int)v : (unsigned int)v;
|
||||
if (v < 0) fluxwebp_shim_put(buf, cap, &n, '-');
|
||||
do { tmp[i++] = (char)('0' + u % 10); u /= 10; } while (u);
|
||||
while (i) fluxwebp_shim_put(buf, cap, &n, tmp[--i]);
|
||||
} else if (*fmt == '%') {
|
||||
fluxwebp_shim_put(buf, cap, &n, '%');
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
va_end(ap);
|
||||
if (cap) buf[n < cap ? n : cap - 1] = '\0';
|
||||
return (int)n;
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,16 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
#ifndef FLUXWEBP_STDLIB_H
|
||||
#define FLUXWEBP_STDLIB_H
|
||||
#include <stddef.h>
|
||||
void* fluxwebp_shim_malloc(size_t size);
|
||||
void* fluxwebp_shim_calloc(size_t nmemb, size_t size);
|
||||
void fluxwebp_shim_free(void* ptr);
|
||||
void fluxwebp_shim_qsort(void* base, size_t nitems, size_t size, int (*compar)(const void*, const void*));
|
||||
#define malloc(size) fluxwebp_shim_malloc(size)
|
||||
#define calloc(nmemb, size) fluxwebp_shim_calloc(nmemb, size)
|
||||
#define free(ptr) fluxwebp_shim_free(ptr)
|
||||
#define qsort(base, nitems, size, compar) fluxwebp_shim_qsort(base, nitems, size, compar)
|
||||
#define abort() __builtin_trap()
|
||||
static inline int abs(int x) { return x < 0 ? -x : x; }
|
||||
#endif
|
||||
@@ -0,0 +1,11 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
#ifndef FLUXWEBP_STRING_H
|
||||
#define FLUXWEBP_STRING_H
|
||||
#include <stddef.h>
|
||||
#define memcpy(d, s, n) __builtin_memcpy(d, s, n)
|
||||
#define memmove(d, s, n) __builtin_memmove(d, s, n)
|
||||
#define memset(d, c, n) __builtin_memset(d, c, n)
|
||||
#define memcmp(a, b, n) __builtin_memcmp(a, b, n)
|
||||
#define strlen(s) __builtin_strlen(s)
|
||||
#endif
|
||||
@@ -0,0 +1,102 @@
|
||||
Emscripten is available under 2 licenses, the MIT license and the
|
||||
University of Illinois/NCSA Open Source License.
|
||||
|
||||
Both are permissive open source licenses, with little if any
|
||||
practical difference between them.
|
||||
|
||||
The reason for offering both is that (1) the MIT license is
|
||||
well-known, while (2) the University of Illinois/NCSA Open Source
|
||||
License allows Emscripten's code to be integrated upstream into
|
||||
LLVM, which uses that license, should the opportunity arise.
|
||||
|
||||
The full text of both licenses follows.
|
||||
|
||||
==============================================================================
|
||||
|
||||
Copyright (c) 2010-2014 Emscripten authors, see AUTHORS file.
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in
|
||||
all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
THE SOFTWARE.
|
||||
|
||||
==============================================================================
|
||||
|
||||
Copyright (c) 2010-2014 Emscripten authors, see AUTHORS file.
|
||||
All rights reserved.
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a
|
||||
copy of this software and associated documentation files (the
|
||||
"Software"), to deal with the Software without restriction, including
|
||||
without limitation the rights to use, copy, modify, merge, publish,
|
||||
distribute, sublicense, and/or sell copies of the Software, and to
|
||||
permit persons to whom the Software is furnished to do so, subject to
|
||||
the following conditions:
|
||||
|
||||
Redistributions of source code must retain the above copyright
|
||||
notice, this list of conditions and the following disclaimers.
|
||||
|
||||
Redistributions in binary form must reproduce the above
|
||||
copyright notice, this list of conditions and the following disclaimers
|
||||
in the documentation and/or other materials provided with the
|
||||
distribution.
|
||||
|
||||
Neither the names of Mozilla,
|
||||
nor the names of its contributors may be used to endorse
|
||||
or promote products derived from this Software without specific prior
|
||||
written permission.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
|
||||
OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
|
||||
IN NO EVENT SHALL THE CONTRIBUTORS OR COPYRIGHT HOLDERS BE LIABLE FOR
|
||||
ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
|
||||
TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
|
||||
SOFTWARE OR THE USE OR OTHER DEALINGS WITH THE SOFTWARE.
|
||||
|
||||
==============================================================================
|
||||
|
||||
This program uses portions of Node.js source code located in src/library_path.js,
|
||||
in accordance with the terms of the MIT license. Node's license follows:
|
||||
|
||||
"""
|
||||
Copyright Joyent, Inc. and other Node contributors. All rights reserved.
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to
|
||||
deal in the Software without restriction, including without limitation the
|
||||
rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
|
||||
sell copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in
|
||||
all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
|
||||
IN THE SOFTWARE.
|
||||
"""
|
||||
|
||||
The musl libc project is bundled in this repo, and it has the MIT license, see
|
||||
system/lib/libc/musl/COPYRIGHT
|
||||
|
||||
The third_party/ subdirectory contains code with other licenses. None of it is
|
||||
used by default, but certain options use it (e.g., the optional closure compiler
|
||||
flag will run closure compiler from third_party/).
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,714 @@
|
||||
/*
|
||||
* Copyright 2020 The Emscripten Authors. All rights reserved.
|
||||
* Emscripten is available under two separate licenses, the MIT license and the
|
||||
* University of Illinois/NCSA Open Source License. Both these licenses can be
|
||||
* found in the LICENSE file.
|
||||
*/
|
||||
#ifndef __emscripten_xmmintrin_h__
|
||||
#define __emscripten_xmmintrin_h__
|
||||
|
||||
#include <wasm_simd128.h>
|
||||
|
||||
#include <limits.h>
|
||||
#include <math.h>
|
||||
#include <string.h>
|
||||
|
||||
#ifndef __SSE__
|
||||
#error "SSE instruction set not enabled"
|
||||
#endif
|
||||
|
||||
#ifdef WASM_SIMD_COMPAT_SLOW
|
||||
#define DIAGNOSE_SLOW diagnose_if(1, "Instruction emulated via slow path.", "warning")
|
||||
#else
|
||||
#define DIAGNOSE_SLOW
|
||||
#endif
|
||||
|
||||
// Emscripten SIMD support doesn't support MMX/float32x2/__m64.
|
||||
// However, we support loading and storing 2-vectors, so
|
||||
// recognize the type at least.
|
||||
typedef float __m64 __attribute__((__vector_size__(8), __aligned__(8)));
|
||||
typedef __f32x4 __m128;
|
||||
typedef v128_t __m128i;
|
||||
|
||||
#define __f32x4_shuffle(__a, __b, __c0, __c1, __c2, __c3) \
|
||||
((v128_t)(__builtin_shufflevector((__f32x4)(__a), (__f32x4)(__b), __c0, \
|
||||
__c1, __c2, __c3)))
|
||||
|
||||
// This is defined as a macro because __builtin_shufflevector requires its
|
||||
// mask argument to be a compile-time constant.
|
||||
#define _mm_shuffle_ps(__a, __b, __mask) __extension__ ({ \
|
||||
((__m128)__f32x4_shuffle(__a, __b, \
|
||||
(((__mask) >> 0) & 0x3) + 0, \
|
||||
(((__mask) >> 2) & 0x3) + 0, \
|
||||
(((__mask) >> 4) & 0x3) + 4, \
|
||||
(((__mask) >> 6) & 0x3) + 4)); })
|
||||
|
||||
static __inline__ __m128 __attribute__((__always_inline__, __nodebug__))
|
||||
_mm_set_ps(float __z, float __y, float __x, float __w)
|
||||
{
|
||||
return (__m128)wasm_f32x4_make(__w, __x, __y, __z);
|
||||
}
|
||||
|
||||
static __inline__ __m128 __attribute__((__always_inline__, __nodebug__))
|
||||
_mm_setr_ps(float __z, float __y, float __x, float __w)
|
||||
{
|
||||
return (__m128)wasm_f32x4_make(__z, __y, __x, __w);
|
||||
}
|
||||
|
||||
static __inline__ __m128 __attribute__((__always_inline__, __nodebug__))
|
||||
_mm_set_ss(float __w)
|
||||
{
|
||||
return (__m128)wasm_f32x4_make(__w, 0, 0, 0);
|
||||
}
|
||||
|
||||
static __inline__ __m128 __attribute__((__always_inline__, __nodebug__))
|
||||
_mm_set_ps1(float __w)
|
||||
{
|
||||
return (__m128)wasm_f32x4_splat(__w);
|
||||
}
|
||||
|
||||
#define _mm_set1_ps _mm_set_ps1
|
||||
|
||||
static __inline__ __m128 __attribute__((__always_inline__, __nodebug__))
|
||||
_mm_setzero_ps(void)
|
||||
{
|
||||
return (__m128)wasm_f32x4_const(0.f, 0.f, 0.f, 0.f);
|
||||
}
|
||||
|
||||
static __inline__ __m128 __attribute__((__always_inline__, __nodebug__))
|
||||
_mm_load_ps(const float *__p)
|
||||
{
|
||||
return *(__m128*)__p;
|
||||
}
|
||||
|
||||
static __inline__ __m128 __attribute__((__always_inline__, __nodebug__, DIAGNOSE_SLOW))
|
||||
_mm_loadl_pi(__m128 __a, const void /*__m64*/ *__p)
|
||||
{
|
||||
return (__m128)wasm_v128_load64_lane(__p, (v128_t)__a, 0);
|
||||
}
|
||||
|
||||
static __inline__ __m128 __attribute__((__always_inline__, __nodebug__, DIAGNOSE_SLOW))
|
||||
_mm_loadh_pi(__m128 __a, const void /*__m64*/ *__p)
|
||||
{
|
||||
return (__m128)wasm_v128_load64_lane(__p, (v128_t)__a, 1);
|
||||
}
|
||||
|
||||
static __inline__ __m128 __attribute__((__always_inline__, __nodebug__))
|
||||
_mm_loadr_ps(const float *__p)
|
||||
{
|
||||
__m128 __v = _mm_load_ps(__p);
|
||||
return (__m128)__f32x4_shuffle(__v, __v, 3, 2, 1, 0);
|
||||
}
|
||||
|
||||
static __inline__ __m128 __attribute__((__always_inline__, __nodebug__))
|
||||
_mm_loadu_ps(const float *__p)
|
||||
{
|
||||
return (__m128)wasm_v128_load(__p);
|
||||
}
|
||||
|
||||
static __inline__ __m128 __attribute__((__always_inline__, __nodebug__))
|
||||
_mm_load_ps1(const float *__p)
|
||||
{
|
||||
return (__m128)wasm_v32x4_load_splat(__p);
|
||||
}
|
||||
#define _mm_load1_ps _mm_load_ps1
|
||||
|
||||
static __inline__ __m128 __attribute__((__always_inline__, __nodebug__, DIAGNOSE_SLOW))
|
||||
_mm_load_ss(const float *__p)
|
||||
{
|
||||
return (__m128)wasm_v128_load32_zero(__p);
|
||||
}
|
||||
|
||||
static __inline__ void __attribute__((__always_inline__, __nodebug__, DIAGNOSE_SLOW))
|
||||
_mm_storel_pi(__m64 *__p, __m128 __a)
|
||||
{
|
||||
wasm_v128_store64_lane((void*)__p, (v128_t)__a, 0);
|
||||
}
|
||||
|
||||
static __inline__ void __attribute__((__always_inline__, __nodebug__, DIAGNOSE_SLOW))
|
||||
_mm_storeh_pi(__m64 *__p, __m128 __a)
|
||||
{
|
||||
wasm_v128_store64_lane((void*)__p, (v128_t)__a, 1);
|
||||
}
|
||||
|
||||
static __inline__ void __attribute__((__always_inline__, __nodebug__))
|
||||
_mm_store_ps(float *__p, __m128 __a)
|
||||
{
|
||||
*(__m128 *)__p = __a;
|
||||
}
|
||||
// No NTA cache hint available.
|
||||
#define _mm_stream_ps _mm_store_ps
|
||||
|
||||
#define _MM_HINT_T0 3
|
||||
#define _MM_HINT_T1 2
|
||||
#define _MM_HINT_T2 1
|
||||
#define _MM_HINT_NTA 0
|
||||
// No prefetch available, dummy it out.
|
||||
static __inline__ void __attribute__((__always_inline__, __nodebug__))
|
||||
_mm_prefetch(const void *__p, int __i)
|
||||
{
|
||||
((void)__p);
|
||||
((void)__i);
|
||||
}
|
||||
|
||||
static __inline__ void __attribute__((__always_inline__, __nodebug__))
|
||||
_mm_sfence(void)
|
||||
{
|
||||
// Wasm/SharedArrayBuffer memory model is sequentially consistent.
|
||||
// Perhaps a future version of the spec can provide a related fence.
|
||||
__sync_synchronize();
|
||||
}
|
||||
|
||||
#define _MM_SHUFFLE(w, z, y, x) (((w) << 6) | ((z) << 4) | ((y) << 2) | (x))
|
||||
|
||||
static __inline__ void __attribute__((__always_inline__, __nodebug__))
|
||||
_mm_storer_ps(float *__p, __m128 __a)
|
||||
{
|
||||
_mm_store_ps(__p, _mm_shuffle_ps(__a, __a, _MM_SHUFFLE(0, 1, 2, 3)));
|
||||
}
|
||||
|
||||
static __inline__ void __attribute__((__always_inline__, __nodebug__))
|
||||
_mm_store_ps1(float *__p, __m128 __a)
|
||||
{
|
||||
_mm_store_ps(__p, _mm_shuffle_ps(__a, __a, _MM_SHUFFLE(0, 0, 0, 0)));
|
||||
}
|
||||
#define _mm_store1_ps _mm_store_ps1
|
||||
|
||||
static __inline__ void __attribute__((__always_inline__, __nodebug__))
|
||||
_mm_store_ss(float *__p, __m128 __a)
|
||||
{
|
||||
wasm_v128_store32_lane((void*)__p, (v128_t)__a, 0);
|
||||
}
|
||||
|
||||
static __inline__ void __attribute__((__always_inline__, __nodebug__))
|
||||
_mm_storeu_ps(float *__p, __m128 __a)
|
||||
{
|
||||
struct __unaligned {
|
||||
__m128 __v;
|
||||
} __attribute__((__packed__, __may_alias__));
|
||||
((struct __unaligned *)__p)->__v = __a;
|
||||
}
|
||||
|
||||
static __inline__ int __attribute__((__always_inline__, __nodebug__))
|
||||
_mm_movemask_ps(__m128 __a)
|
||||
{
|
||||
return (int)wasm_i32x4_bitmask((v128_t)__a);
|
||||
}
|
||||
|
||||
static __inline__ __m128 __attribute__((__always_inline__, __nodebug__))
|
||||
_mm_move_ss(__m128 __a, __m128 __b)
|
||||
{
|
||||
return (__m128)__f32x4_shuffle(__a, __b, 4, 1, 2, 3);
|
||||
}
|
||||
|
||||
static __inline__ __m128 __attribute__((__always_inline__, __nodebug__))
|
||||
_mm_add_ps(__m128 __a, __m128 __b)
|
||||
{
|
||||
return (__m128)wasm_f32x4_add((v128_t)__a, (v128_t)__b);
|
||||
}
|
||||
|
||||
static __inline__ __m128 __attribute__((__always_inline__, __nodebug__))
|
||||
_mm_add_ss(__m128 __a, __m128 __b)
|
||||
{
|
||||
return _mm_move_ss(__a, _mm_add_ps(__a, __b));
|
||||
}
|
||||
|
||||
static __inline__ __m128 __attribute__((__always_inline__, __nodebug__))
|
||||
_mm_sub_ps(__m128 __a, __m128 __b)
|
||||
{
|
||||
return (__m128)wasm_f32x4_sub((v128_t)__a, (v128_t)__b);
|
||||
}
|
||||
|
||||
static __inline__ __m128 __attribute__((__always_inline__, __nodebug__))
|
||||
_mm_sub_ss(__m128 __a, __m128 __b)
|
||||
{
|
||||
return _mm_move_ss(__a, _mm_sub_ps(__a, __b));
|
||||
}
|
||||
|
||||
static __inline__ __m128 __attribute__((__always_inline__, __nodebug__))
|
||||
_mm_mul_ps(__m128 __a, __m128 __b)
|
||||
{
|
||||
return (__m128)wasm_f32x4_mul((v128_t)__a, (v128_t)__b);
|
||||
}
|
||||
|
||||
static __inline__ __m128 __attribute__((__always_inline__, __nodebug__))
|
||||
_mm_mul_ss(__m128 __a, __m128 __b)
|
||||
{
|
||||
return _mm_move_ss(__a, _mm_mul_ps(__a, __b));
|
||||
}
|
||||
|
||||
static __inline__ __m128 __attribute__((__always_inline__, __nodebug__))
|
||||
_mm_div_ps(__m128 __a, __m128 __b)
|
||||
{
|
||||
return (__m128)wasm_f32x4_div((v128_t)__a, (v128_t)__b);
|
||||
}
|
||||
|
||||
static __inline__ __m128 __attribute__((__always_inline__, __nodebug__))
|
||||
_mm_div_ss(__m128 __a, __m128 __b)
|
||||
{
|
||||
return _mm_move_ss(__a, _mm_div_ps(__a, __b));
|
||||
}
|
||||
|
||||
static __inline__ __m128 __attribute__((__always_inline__, __nodebug__))
|
||||
_mm_min_ps(__m128 __a, __m128 __b)
|
||||
{
|
||||
// return (__m128)wasm_f32x4_pmin((v128_t)__a, (v128_t)__b); // TODO: Migrate to this, once it works in VMs
|
||||
return (__m128)wasm_v128_bitselect((v128_t)__a, (v128_t)__b, (v128_t)wasm_f32x4_lt((v128_t)__a, (v128_t)__b));
|
||||
}
|
||||
|
||||
static __inline__ __m128 __attribute__((__always_inline__, __nodebug__))
|
||||
_mm_min_ss(__m128 __a, __m128 __b)
|
||||
{
|
||||
return _mm_move_ss(__a, _mm_min_ps(__a, __b));
|
||||
}
|
||||
|
||||
static __inline__ __m128 __attribute__((__always_inline__, __nodebug__))
|
||||
_mm_max_ps(__m128 __a, __m128 __b)
|
||||
{
|
||||
// return (__m128)wasm_f32x4_pmax((v128_t)__a, (v128_t)__b); // TODO: Migrate to this, once it works in VMs
|
||||
return (__m128)wasm_v128_bitselect((v128_t)__a, (v128_t)__b, (v128_t)wasm_f32x4_gt((v128_t)__a, (v128_t)__b));
|
||||
}
|
||||
|
||||
static __inline__ __m128 __attribute__((__always_inline__, __nodebug__))
|
||||
_mm_max_ss(__m128 __a, __m128 __b)
|
||||
{
|
||||
return _mm_move_ss(__a, _mm_max_ps(__a, __b));
|
||||
}
|
||||
|
||||
static __inline__ __m128 __attribute__((__always_inline__, __nodebug__, DIAGNOSE_SLOW))
|
||||
_mm_rcp_ps(__m128 __a)
|
||||
{
|
||||
return (__m128)wasm_f32x4_div((v128_t)_mm_set1_ps(1.0f), (v128_t)__a);
|
||||
}
|
||||
|
||||
static __inline__ __m128 __attribute__((__always_inline__, __nodebug__, DIAGNOSE_SLOW))
|
||||
_mm_rcp_ss(__m128 __a)
|
||||
{
|
||||
return _mm_move_ss(__a, _mm_rcp_ps(__a));
|
||||
}
|
||||
|
||||
static __inline__ __m128 __attribute__((__always_inline__, __nodebug__))
|
||||
_mm_sqrt_ps(__m128 __a)
|
||||
{
|
||||
return (__m128)wasm_f32x4_sqrt((v128_t)__a);
|
||||
}
|
||||
|
||||
static __inline__ __m128 __attribute__((__always_inline__, __nodebug__))
|
||||
_mm_sqrt_ss(__m128 __a)
|
||||
{
|
||||
return _mm_move_ss(__a, _mm_sqrt_ps(__a));
|
||||
}
|
||||
|
||||
#define _mm_rsqrt_ps(__a) _mm_rcp_ps(_mm_sqrt_ps((__a)))
|
||||
|
||||
static __inline__ __m128 __attribute__((__always_inline__, __nodebug__))
|
||||
_mm_rsqrt_ss(__m128 __a)
|
||||
{
|
||||
return _mm_move_ss(__a, _mm_rsqrt_ps(__a));
|
||||
}
|
||||
|
||||
static __inline__ __m128 __attribute__((__always_inline__, __nodebug__))
|
||||
_mm_unpackhi_ps(__m128 __a, __m128 __b)
|
||||
{
|
||||
return (__m128)__f32x4_shuffle(__a, __b, 2, 6, 3, 7);
|
||||
}
|
||||
|
||||
static __inline__ __m128 __attribute__((__always_inline__, __nodebug__))
|
||||
_mm_unpacklo_ps(__m128 __a, __m128 __b)
|
||||
{
|
||||
return (__m128)__f32x4_shuffle(__a, __b, 0, 4, 1, 5);
|
||||
}
|
||||
|
||||
static __inline__ __m128 __attribute__((__always_inline__, __nodebug__))
|
||||
_mm_movehl_ps(__m128 __a, __m128 __b)
|
||||
{
|
||||
return (__m128)__f32x4_shuffle(__a, __b, 6, 7, 2, 3);
|
||||
}
|
||||
|
||||
static __inline__ __m128 __attribute__((__always_inline__, __nodebug__))
|
||||
_mm_movelh_ps(__m128 __a, __m128 __b)
|
||||
{
|
||||
return (__m128)__f32x4_shuffle(__a, __b, 0, 1, 4, 5);
|
||||
}
|
||||
|
||||
#define _MM_TRANSPOSE4_PS(row0, row1, row2, row3) \
|
||||
do { \
|
||||
__m128 __row0 = (row0); \
|
||||
__m128 __row1 = (row1); \
|
||||
__m128 __row2 = (row2); \
|
||||
__m128 __row3 = (row3); \
|
||||
__m128 __tmp0 = _mm_unpacklo_ps(__row0, __row1); \
|
||||
__m128 __tmp1 = _mm_unpackhi_ps(__row0, __row1); \
|
||||
__m128 __tmp2 = _mm_unpacklo_ps(__row2, __row3); \
|
||||
__m128 __tmp3 = _mm_unpackhi_ps(__row2, __row3); \
|
||||
(row0) = _mm_movelh_ps(__tmp0, __tmp2); \
|
||||
(row1) = _mm_movehl_ps(__tmp2, __tmp0); \
|
||||
(row2) = _mm_movelh_ps(__tmp1, __tmp3); \
|
||||
(row3) = _mm_movehl_ps(__tmp3, __tmp1); \
|
||||
} while (0)
|
||||
|
||||
static __inline__ __m128 __attribute__((__always_inline__, __nodebug__))
|
||||
_mm_cmplt_ps(__m128 __a, __m128 __b)
|
||||
{
|
||||
return (__m128)wasm_f32x4_lt((v128_t)__a, (v128_t)__b);
|
||||
}
|
||||
|
||||
static __inline__ __m128 __attribute__((__always_inline__, __nodebug__))
|
||||
_mm_cmplt_ss(__m128 __a, __m128 __b)
|
||||
{
|
||||
return _mm_move_ss(__a, _mm_cmplt_ps(__a, __b));
|
||||
}
|
||||
|
||||
static __inline__ __m128 __attribute__((__always_inline__, __nodebug__))
|
||||
_mm_cmple_ps(__m128 __a, __m128 __b)
|
||||
{
|
||||
return (__m128)wasm_f32x4_le((v128_t)__a, (v128_t)__b);
|
||||
}
|
||||
|
||||
static __inline__ __m128 __attribute__((__always_inline__, __nodebug__))
|
||||
_mm_cmple_ss(__m128 __a, __m128 __b)
|
||||
{
|
||||
return _mm_move_ss(__a, _mm_cmple_ps(__a, __b));
|
||||
}
|
||||
|
||||
static __inline__ __m128 __attribute__((__always_inline__, __nodebug__))
|
||||
_mm_cmpeq_ps(__m128 __a, __m128 __b)
|
||||
{
|
||||
return (__m128)wasm_f32x4_eq((v128_t)__a, (v128_t)__b);
|
||||
}
|
||||
|
||||
static __inline__ __m128 __attribute__((__always_inline__, __nodebug__))
|
||||
_mm_cmpeq_ss(__m128 __a, __m128 __b)
|
||||
{
|
||||
return _mm_move_ss(__a, _mm_cmpeq_ps(__a, __b));
|
||||
}
|
||||
|
||||
static __inline__ __m128 __attribute__((__always_inline__, __nodebug__))
|
||||
_mm_cmpge_ps(__m128 __a, __m128 __b)
|
||||
{
|
||||
return (__m128)wasm_f32x4_ge((v128_t)__a, (v128_t)__b);
|
||||
}
|
||||
|
||||
static __inline__ __m128 __attribute__((__always_inline__, __nodebug__))
|
||||
_mm_cmpge_ss(__m128 __a, __m128 __b)
|
||||
{
|
||||
return _mm_move_ss(__a, _mm_cmpge_ps(__a, __b));
|
||||
}
|
||||
|
||||
static __inline__ __m128 __attribute__((__always_inline__, __nodebug__))
|
||||
_mm_cmpgt_ps(__m128 __a, __m128 __b)
|
||||
{
|
||||
return (__m128)wasm_f32x4_gt((v128_t)__a, (v128_t)__b);
|
||||
}
|
||||
|
||||
static __inline__ __m128 __attribute__((__always_inline__, __nodebug__))
|
||||
_mm_cmpgt_ss(__m128 __a, __m128 __b)
|
||||
{
|
||||
return _mm_move_ss(__a, _mm_cmpgt_ps(__a, __b));
|
||||
}
|
||||
|
||||
static __inline__ __m128 __attribute__((__always_inline__, __nodebug__, DIAGNOSE_SLOW)) _mm_cmpord_ps(__m128 __a, __m128 __b)
|
||||
{
|
||||
return (__m128)wasm_v128_and(wasm_f32x4_eq((v128_t)__a, (v128_t)__a),
|
||||
wasm_f32x4_eq((v128_t)__b, (v128_t)__b));
|
||||
}
|
||||
|
||||
static __inline__ __m128 __attribute__((__always_inline__, __nodebug__, DIAGNOSE_SLOW)) _mm_cmpord_ss(__m128 __a, __m128 __b)
|
||||
{
|
||||
return _mm_move_ss(__a, _mm_cmpord_ps(__a, __b));
|
||||
}
|
||||
|
||||
static __inline__ __m128 __attribute__((__always_inline__, __nodebug__, DIAGNOSE_SLOW)) _mm_cmpunord_ps(__m128 __a, __m128 __b)
|
||||
{
|
||||
return (__m128)wasm_v128_or(wasm_f32x4_ne((v128_t)__a, (v128_t)__a),
|
||||
wasm_f32x4_ne((v128_t)__b, (v128_t)__b));
|
||||
}
|
||||
|
||||
static __inline__ __m128 __attribute__((__always_inline__, __nodebug__, DIAGNOSE_SLOW)) _mm_cmpunord_ss(__m128 __a, __m128 __b)
|
||||
{
|
||||
return _mm_move_ss(__a, _mm_cmpunord_ps(__a, __b));
|
||||
}
|
||||
|
||||
static __inline__ __m128 __attribute__((__always_inline__, __nodebug__))
|
||||
_mm_and_ps(__m128 __a, __m128 __b)
|
||||
{
|
||||
return (__m128)wasm_v128_and((v128_t)__a, (v128_t)__b);
|
||||
}
|
||||
|
||||
static __inline__ __m128 __attribute__((__always_inline__, __nodebug__))
|
||||
_mm_andnot_ps(__m128 __a, __m128 __b)
|
||||
{
|
||||
return (__m128)wasm_v128_andnot((v128_t)__b, (v128_t)__a);
|
||||
}
|
||||
|
||||
static __inline__ __m128 __attribute__((__always_inline__, __nodebug__))
|
||||
_mm_or_ps(__m128 __a, __m128 __b)
|
||||
{
|
||||
return (__m128)wasm_v128_or((v128_t)__a, (v128_t)__b);
|
||||
}
|
||||
|
||||
static __inline__ __m128 __attribute__((__always_inline__, __nodebug__))
|
||||
_mm_xor_ps(__m128 __a, __m128 __b)
|
||||
{
|
||||
return (__m128)wasm_v128_xor((v128_t)__a, (v128_t)__b);
|
||||
}
|
||||
|
||||
static __inline__ __m128 __attribute__((__always_inline__, __nodebug__))
|
||||
_mm_cmpneq_ps(__m128 __a, __m128 __b)
|
||||
{
|
||||
return (__m128)wasm_f32x4_ne((v128_t)__a, (v128_t)__b);
|
||||
}
|
||||
|
||||
static __inline__ __m128 __attribute__((__always_inline__, __nodebug__))
|
||||
_mm_cmpneq_ss(__m128 __a, __m128 __b)
|
||||
{
|
||||
return _mm_move_ss(__a, _mm_cmpneq_ps(__a, __b));
|
||||
}
|
||||
|
||||
static __inline__ __m128 __attribute__((__always_inline__, __nodebug__))
|
||||
_mm_cmpnge_ps(__m128 __a, __m128 __b)
|
||||
{
|
||||
return (__m128)wasm_v128_not((v128_t)_mm_cmpge_ps(__a, __b));
|
||||
}
|
||||
|
||||
static __inline__ __m128 __attribute__((__always_inline__, __nodebug__))
|
||||
_mm_cmpnge_ss(__m128 __a, __m128 __b)
|
||||
{
|
||||
return _mm_move_ss(__a, _mm_cmpnge_ps(__a, __b));
|
||||
}
|
||||
|
||||
static __inline__ __m128 __attribute__((__always_inline__, __nodebug__))
|
||||
_mm_cmpngt_ps(__m128 __a, __m128 __b)
|
||||
{
|
||||
return (__m128)wasm_v128_not((v128_t)_mm_cmpgt_ps(__a, __b));
|
||||
}
|
||||
|
||||
static __inline__ __m128 __attribute__((__always_inline__, __nodebug__))
|
||||
_mm_cmpngt_ss(__m128 __a, __m128 __b)
|
||||
{
|
||||
return _mm_move_ss(__a, _mm_cmpngt_ps(__a, __b));
|
||||
}
|
||||
|
||||
static __inline__ __m128 __attribute__((__always_inline__, __nodebug__))
|
||||
_mm_cmpnle_ps(__m128 __a, __m128 __b)
|
||||
{
|
||||
return (__m128)wasm_v128_not((v128_t)_mm_cmple_ps(__a, __b));
|
||||
}
|
||||
|
||||
static __inline__ __m128 __attribute__((__always_inline__, __nodebug__))
|
||||
_mm_cmpnle_ss(__m128 __a, __m128 __b)
|
||||
{
|
||||
return _mm_move_ss(__a, _mm_cmpnle_ps(__a, __b));
|
||||
}
|
||||
|
||||
static __inline__ __m128 __attribute__((__always_inline__, __nodebug__))
|
||||
_mm_cmpnlt_ps(__m128 __a, __m128 __b)
|
||||
{
|
||||
return (__m128)wasm_v128_not((v128_t)_mm_cmplt_ps(__a, __b));
|
||||
}
|
||||
|
||||
static __inline__ __m128 __attribute__((__always_inline__, __nodebug__))
|
||||
_mm_cmpnlt_ss(__m128 __a, __m128 __b)
|
||||
{
|
||||
return _mm_move_ss(__a, _mm_cmpnlt_ps(__a, __b));
|
||||
}
|
||||
|
||||
static __inline__ int __attribute__((__always_inline__, __nodebug__, DIAGNOSE_SLOW))
|
||||
_mm_comieq_ss(__m128 __a, __m128 __b)
|
||||
{
|
||||
return wasm_f32x4_extract_lane((v128_t)__a, 0) == wasm_f32x4_extract_lane((v128_t)__b, 0);
|
||||
}
|
||||
|
||||
static __inline__ int __attribute__((__always_inline__, __nodebug__, DIAGNOSE_SLOW))
|
||||
_mm_comige_ss(__m128 __a, __m128 __b)
|
||||
{
|
||||
return wasm_f32x4_extract_lane((v128_t)__a, 0) >= wasm_f32x4_extract_lane((v128_t)__b, 0);
|
||||
}
|
||||
|
||||
static __inline__ int __attribute__((__always_inline__, __nodebug__, DIAGNOSE_SLOW))
|
||||
_mm_comigt_ss(__m128 __a, __m128 __b)
|
||||
{
|
||||
return wasm_f32x4_extract_lane((v128_t)__a, 0) > wasm_f32x4_extract_lane((v128_t)__b, 0);
|
||||
}
|
||||
|
||||
static __inline__ int __attribute__((__always_inline__, __nodebug__, DIAGNOSE_SLOW))
|
||||
_mm_comile_ss(__m128 __a, __m128 __b)
|
||||
{
|
||||
return wasm_f32x4_extract_lane((v128_t)__a, 0) <= wasm_f32x4_extract_lane((v128_t)__b, 0);
|
||||
}
|
||||
|
||||
static __inline__ int __attribute__((__always_inline__, __nodebug__, DIAGNOSE_SLOW))
|
||||
_mm_comilt_ss(__m128 __a, __m128 __b)
|
||||
{
|
||||
return wasm_f32x4_extract_lane((v128_t)__a, 0) < wasm_f32x4_extract_lane((v128_t)__b, 0);
|
||||
}
|
||||
|
||||
static __inline__ int __attribute__((__always_inline__, __nodebug__, DIAGNOSE_SLOW))
|
||||
_mm_comineq_ss(__m128 __a, __m128 __b)
|
||||
{
|
||||
return wasm_f32x4_extract_lane((v128_t)__a, 0) != wasm_f32x4_extract_lane((v128_t)__b, 0);
|
||||
}
|
||||
|
||||
static __inline__ int __attribute__((__always_inline__, __nodebug__, DIAGNOSE_SLOW))
|
||||
_mm_ucomieq_ss(__m128 __a, __m128 __b)
|
||||
{
|
||||
return wasm_f32x4_extract_lane((v128_t)__a, 0) == wasm_f32x4_extract_lane((v128_t)__b, 0);
|
||||
}
|
||||
|
||||
static __inline__ int __attribute__((__always_inline__, __nodebug__, DIAGNOSE_SLOW))
|
||||
_mm_ucomige_ss(__m128 __a, __m128 __b)
|
||||
{
|
||||
return wasm_f32x4_extract_lane((v128_t)__a, 0) >= wasm_f32x4_extract_lane((v128_t)__b, 0);
|
||||
}
|
||||
|
||||
static __inline__ int __attribute__((__always_inline__, __nodebug__, DIAGNOSE_SLOW))
|
||||
_mm_ucomigt_ss(__m128 __a, __m128 __b)
|
||||
{
|
||||
return wasm_f32x4_extract_lane((v128_t)__a, 0) > wasm_f32x4_extract_lane((v128_t)__b, 0);
|
||||
}
|
||||
|
||||
static __inline__ int __attribute__((__always_inline__, __nodebug__, DIAGNOSE_SLOW))
|
||||
_mm_ucomile_ss(__m128 __a, __m128 __b)
|
||||
{
|
||||
return wasm_f32x4_extract_lane((v128_t)__a, 0) <= wasm_f32x4_extract_lane((v128_t)__b, 0);
|
||||
}
|
||||
|
||||
static __inline__ int __attribute__((__always_inline__, __nodebug__, DIAGNOSE_SLOW))
|
||||
_mm_ucomilt_ss(__m128 __a, __m128 __b)
|
||||
{
|
||||
return wasm_f32x4_extract_lane((v128_t)__a, 0) < wasm_f32x4_extract_lane((v128_t)__b, 0);
|
||||
}
|
||||
|
||||
static __inline__ int __attribute__((__always_inline__, __nodebug__, DIAGNOSE_SLOW))
|
||||
_mm_ucomineq_ss(__m128 __a, __m128 __b)
|
||||
{
|
||||
return wasm_f32x4_extract_lane((v128_t)__a, 0) != wasm_f32x4_extract_lane((v128_t)__b, 0);
|
||||
}
|
||||
|
||||
static __inline__ __m128 __attribute__((__always_inline__, __nodebug__, DIAGNOSE_SLOW))
|
||||
_mm_cvtsi32_ss(__m128 __a, int __b)
|
||||
{
|
||||
__f32x4 __v = (__f32x4)__a;
|
||||
__v[0] = (float)__b;
|
||||
return (__m128)__v;
|
||||
}
|
||||
#define _mm_cvt_si2ss _mm_cvtsi32_ss
|
||||
|
||||
static __inline__ int __attribute__((__always_inline__, __nodebug__, DIAGNOSE_SLOW)) _mm_cvtss_si32(__m128 __a)
|
||||
{
|
||||
float e = ((__f32x4)__a)[0];
|
||||
if (e < 2147483648.0f && e >= -2147483648.0f && (lrint(e) != 0 || fabsf(e) < 2.f))
|
||||
return lrint(e);
|
||||
else
|
||||
return (int)0x80000000;
|
||||
}
|
||||
#define _mm_cvt_ss2si _mm_cvtss_si32
|
||||
|
||||
static __inline__ int __attribute__((__always_inline__, __nodebug__, DIAGNOSE_SLOW)) _mm_cvttss_si32(__m128 __a)
|
||||
{
|
||||
float e = ((__f32x4)__a)[0];
|
||||
if (e < 2147483648.0f && e >= -2147483648.0f && (lrint(e) != 0 || fabsf(e) < 2.f))
|
||||
return (int)e;
|
||||
else
|
||||
return (int)0x80000000;
|
||||
}
|
||||
#define _mm_cvtt_ss2si _mm_cvttss_si32
|
||||
|
||||
static __inline__ __m128 __attribute__((__always_inline__, __nodebug__, DIAGNOSE_SLOW))
|
||||
_mm_cvtsi64_ss(__m128 __a, long long __b)
|
||||
{
|
||||
__f32x4 __v = (__f32x4)__a;
|
||||
__v[0] = (float)__b;
|
||||
return (__m128)__v;
|
||||
}
|
||||
|
||||
static __inline__ long long __attribute__((__always_inline__, __nodebug__, DIAGNOSE_SLOW))
|
||||
_mm_cvtss_si64(__m128 __a)
|
||||
{
|
||||
float e = ((__f32x4)__a)[0];
|
||||
long long x = llrintf(e);
|
||||
if (e <= LLONG_MAX && e >= LLONG_MIN && (x != 0 || fabsf(e) < 2.f))
|
||||
return x;
|
||||
else
|
||||
return 0x8000000000000000LL;
|
||||
}
|
||||
|
||||
static __inline__ long long __attribute__((__always_inline__, __nodebug__, DIAGNOSE_SLOW))
|
||||
_mm_cvttss_si64(__m128 __a)
|
||||
{
|
||||
float e = ((__f32x4)__a)[0];
|
||||
long long x = llrintf(e);
|
||||
if (e <= LLONG_MAX && e >= LLONG_MIN && (x != 0 || fabsf(e) < 2.f))
|
||||
return (long long)e;
|
||||
else
|
||||
return 0x8000000000000000LL;
|
||||
}
|
||||
|
||||
static __inline__ float __attribute__((__always_inline__, __nodebug__))
|
||||
_mm_cvtss_f32(__m128 __a)
|
||||
{
|
||||
return (float)((__f32x4)__a)[0];
|
||||
}
|
||||
|
||||
#define _mm_malloc(__size, __align) memalign((__align), (__size))
|
||||
#define _mm_free free
|
||||
|
||||
static __inline__ __m128 __attribute__((__always_inline__, __nodebug__))
|
||||
_mm_undefined()
|
||||
{
|
||||
__m128 val;
|
||||
return val;
|
||||
}
|
||||
|
||||
static __inline__ __m128 __attribute__((__always_inline__, __nodebug__))
|
||||
_mm_undefined_ps()
|
||||
{
|
||||
__m128 val;
|
||||
return val;
|
||||
}
|
||||
|
||||
#define _MM_EXCEPT_MASK 0x003f
|
||||
#define _MM_EXCEPT_INVALID 0x0001
|
||||
#define _MM_EXCEPT_DENORM 0x0002
|
||||
#define _MM_EXCEPT_DIV_ZERO 0x0004
|
||||
#define _MM_EXCEPT_OVERFLOW 0x0008
|
||||
#define _MM_EXCEPT_UNDERFLOW 0x0010
|
||||
#define _MM_EXCEPT_INEXACT 0x0020
|
||||
|
||||
#define _MM_MASK_MASK 0x1f80
|
||||
#define _MM_MASK_INVALID 0x0080
|
||||
#define _MM_MASK_DENORM 0x0100
|
||||
#define _MM_MASK_DIV_ZERO 0x0200
|
||||
#define _MM_MASK_OVERFLOW 0x0400
|
||||
#define _MM_MASK_UNDERFLOW 0x0800
|
||||
#define _MM_MASK_INEXACT 0x1000
|
||||
|
||||
#define _MM_ROUND_MASK 0x6000
|
||||
#define _MM_ROUND_NEAREST 0x0000
|
||||
#define _MM_ROUND_DOWN 0x2000
|
||||
#define _MM_ROUND_UP 0x4000
|
||||
#define _MM_ROUND_TOWARD_ZERO 0x6000
|
||||
|
||||
#define _MM_FLUSH_ZERO_MASK 0x8000
|
||||
#define _MM_FLUSH_ZERO_ON 0x8000
|
||||
#define _MM_FLUSH_ZERO_OFF 0x0000
|
||||
|
||||
static __inline__ int __attribute__((__always_inline__, __nodebug__))
|
||||
_mm_getcsr()
|
||||
{
|
||||
return _MM_MASK_INEXACT | _MM_MASK_DENORM | _MM_MASK_DIV_ZERO | _MM_MASK_OVERFLOW | _MM_MASK_UNDERFLOW | _MM_MASK_INVALID
|
||||
| _MM_ROUND_NEAREST | _MM_FLUSH_ZERO_OFF;
|
||||
}
|
||||
|
||||
#define _MM_GET_EXCEPTION_STATE() (_mm_getcsr() & _MM_EXCEPT_MASK)
|
||||
#define _MM_GET_EXCEPTION_MASK() (_mm_getcsr() & _MM_MASK_MASK)
|
||||
#define _MM_GET_ROUNDING_MODE() (_mm_getcsr() & _MM_ROUND_MASK)
|
||||
#define _MM_GET_FLUSH_ZERO_MODE() (_mm_getcsr() & _MM_FLUSH_ZERO_MASK)
|
||||
|
||||
// Unavailable functions:
|
||||
// void _MM_SET_EXCEPTION_STATE(unsigned int __a);
|
||||
// void _MM_SET_EXCEPTION_MASK(unsigned int __a);
|
||||
// void _MM_GET_ROUNDING_MODE(unsigned int __a);
|
||||
// void _MM_GET_FLUSH_ZERO_MODE(unsigned int __a);
|
||||
|
||||
#endif /* __emscripten_xmmintrin_h__ */
|
||||
@@ -0,0 +1,327 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
#[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
|
||||
mod shim;
|
||||
|
||||
use libwebp_sys as ffi;
|
||||
use std::ffi::CStr;
|
||||
use std::ptr;
|
||||
use wasm_bindgen::prelude::*;
|
||||
|
||||
const DEMUX_ABI: i32 = ffi::WEBP_DEMUX_ABI_VERSION as i32;
|
||||
const MUX_ABI: i32 = ffi::WEBP_MUX_ABI_VERSION as i32;
|
||||
const ENCODER_ABI: i32 = ffi::WEBP_ENCODER_ABI_VERSION as i32;
|
||||
const DECODER_ABI: i32 = ffi::WEBP_DECODER_ABI_VERSION as i32;
|
||||
const LOSSLESS_FORMAT: i32 = 2;
|
||||
|
||||
fn webp_data(bytes: &[u8]) -> ffi::WebPData {
|
||||
ffi::WebPData {
|
||||
bytes: bytes.as_ptr(),
|
||||
size: bytes.len(),
|
||||
}
|
||||
}
|
||||
|
||||
#[wasm_bindgen]
|
||||
pub fn webp_probe(bytes: &[u8]) -> Result<Box<[u32]>, JsError> {
|
||||
let data = webp_data(bytes);
|
||||
let dmux = unsafe { ffi::WebPDemuxInternal(&data, 0, ptr::null_mut(), DEMUX_ABI) };
|
||||
if dmux.is_null() {
|
||||
return Err(JsError::new("webp demux failed"));
|
||||
}
|
||||
let get = |feature| unsafe { ffi::WebPDemuxGetI(dmux, feature) };
|
||||
let width = get(ffi::WebPFormatFeature::WEBP_FF_CANVAS_WIDTH);
|
||||
let height = get(ffi::WebPFormatFeature::WEBP_FF_CANVAS_HEIGHT);
|
||||
let frame_count = get(ffi::WebPFormatFeature::WEBP_FF_FRAME_COUNT);
|
||||
let loop_count = get(ffi::WebPFormatFeature::WEBP_FF_LOOP_COUNT);
|
||||
let flags = get(ffi::WebPFormatFeature::WEBP_FF_FORMAT_FLAGS);
|
||||
let has_alpha = flags & ffi::WebPFeatureFlags::ALPHA_FLAG as u32 != 0;
|
||||
let all_lossless = unsafe { all_frames_lossless(dmux) };
|
||||
unsafe { ffi::WebPDemuxDelete(dmux) };
|
||||
Ok(Box::new([
|
||||
width,
|
||||
height,
|
||||
frame_count,
|
||||
loop_count,
|
||||
has_alpha as u32,
|
||||
all_lossless as u32,
|
||||
]))
|
||||
}
|
||||
|
||||
unsafe fn all_frames_lossless(dmux: *const ffi::WebPDemuxer) -> bool {
|
||||
unsafe {
|
||||
let mut iter: ffi::WebPIterator = std::mem::zeroed();
|
||||
if ffi::WebPDemuxGetFrame(dmux, 1, &mut iter) == 0 {
|
||||
return false;
|
||||
}
|
||||
let mut lossless = true;
|
||||
loop {
|
||||
let mut features: ffi::WebPBitstreamFeatures = std::mem::zeroed();
|
||||
let status = ffi::WebPGetFeaturesInternal(
|
||||
iter.fragment.bytes,
|
||||
iter.fragment.size,
|
||||
&mut features,
|
||||
DECODER_ABI,
|
||||
);
|
||||
if status != ffi::VP8StatusCode::VP8_STATUS_OK || features.format != LOSSLESS_FORMAT {
|
||||
lossless = false;
|
||||
break;
|
||||
}
|
||||
if ffi::WebPDemuxNextFrame(&mut iter) == 0 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
ffi::WebPDemuxReleaseIterator(&mut iter);
|
||||
lossless
|
||||
}
|
||||
}
|
||||
|
||||
#[wasm_bindgen]
|
||||
pub struct AnimDecoder {
|
||||
dec: *mut ffi::WebPAnimDecoder,
|
||||
_bytes: Box<[u8]>,
|
||||
frame: *mut u8,
|
||||
timestamp: i32,
|
||||
}
|
||||
|
||||
#[wasm_bindgen]
|
||||
impl AnimDecoder {
|
||||
#[wasm_bindgen(constructor)]
|
||||
pub fn new(bytes: Box<[u8]>) -> Result<AnimDecoder, JsError> {
|
||||
let dec = unsafe {
|
||||
let mut options: ffi::WebPAnimDecoderOptions = std::mem::zeroed();
|
||||
if ffi::WebPAnimDecoderOptionsInitInternal(&mut options, DEMUX_ABI) == 0 {
|
||||
return Err(JsError::new("webp decoder options init failed"));
|
||||
}
|
||||
options.color_mode = ffi::WEBP_CSP_MODE::MODE_RGBA;
|
||||
options.use_threads = 0;
|
||||
let data = webp_data(&bytes);
|
||||
ffi::WebPAnimDecoderNewInternal(&data, &options, DEMUX_ABI)
|
||||
};
|
||||
if dec.is_null() {
|
||||
return Err(JsError::new("webp decoder init failed"));
|
||||
}
|
||||
Ok(AnimDecoder {
|
||||
dec,
|
||||
_bytes: bytes,
|
||||
frame: ptr::null_mut(),
|
||||
timestamp: 0,
|
||||
})
|
||||
}
|
||||
|
||||
#[wasm_bindgen(js_name = next)]
|
||||
pub fn next_frame(&mut self) -> Result<bool, JsError> {
|
||||
if unsafe { ffi::WebPAnimDecoderHasMoreFrames(self.dec) } == 0 {
|
||||
return Ok(false);
|
||||
}
|
||||
let mut frame = ptr::null_mut();
|
||||
let mut timestamp = 0;
|
||||
if unsafe { ffi::WebPAnimDecoderGetNext(self.dec, &mut frame, &mut timestamp) } == 0 {
|
||||
return Err(JsError::new("webp frame decode failed"));
|
||||
}
|
||||
self.frame = frame;
|
||||
self.timestamp = timestamp;
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
#[wasm_bindgen(getter)]
|
||||
pub fn frame_ptr(&self) -> u32 {
|
||||
self.frame as usize as u32
|
||||
}
|
||||
|
||||
#[wasm_bindgen(getter)]
|
||||
pub fn end_timestamp(&self) -> i32 {
|
||||
self.timestamp
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for AnimDecoder {
|
||||
fn drop(&mut self) {
|
||||
unsafe { ffi::WebPAnimDecoderDelete(self.dec) };
|
||||
}
|
||||
}
|
||||
|
||||
#[wasm_bindgen]
|
||||
pub struct AnimEncoder {
|
||||
enc: *mut ffi::WebPAnimEncoder,
|
||||
width: u32,
|
||||
height: u32,
|
||||
lossless: bool,
|
||||
}
|
||||
|
||||
#[wasm_bindgen]
|
||||
impl AnimEncoder {
|
||||
#[wasm_bindgen(constructor)]
|
||||
pub fn new(
|
||||
width: u32,
|
||||
height: u32,
|
||||
loop_count: u32,
|
||||
lossless: bool,
|
||||
) -> Result<AnimEncoder, JsError> {
|
||||
let (kmin, kmax) = if lossless { (9, 17) } else { (4, 5) };
|
||||
let enc = unsafe {
|
||||
let mut options: ffi::WebPAnimEncoderOptions = std::mem::zeroed();
|
||||
if ffi::WebPAnimEncoderOptionsInitInternal(&mut options, MUX_ABI) == 0 {
|
||||
return Err(JsError::new("webp encoder options init failed"));
|
||||
}
|
||||
options.anim_params.loop_count = loop_count.min(i32::MAX as u32) as i32;
|
||||
options.anim_params.bgcolor = 0;
|
||||
options.minimize_size = 0;
|
||||
options.allow_mixed = 0;
|
||||
options.kmin = kmin;
|
||||
options.kmax = kmax;
|
||||
ffi::WebPAnimEncoderNewInternal(width as i32, height as i32, &options, MUX_ABI)
|
||||
};
|
||||
if enc.is_null() {
|
||||
return Err(JsError::new("webp encoder init failed"));
|
||||
}
|
||||
Ok(AnimEncoder {
|
||||
enc,
|
||||
width,
|
||||
height,
|
||||
lossless,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn add(
|
||||
&mut self,
|
||||
rgba: &[u8],
|
||||
start_ms: i32,
|
||||
quality: f32,
|
||||
method: i32,
|
||||
) -> Result<(), JsError> {
|
||||
if rgba.len() as u64 != u64::from(self.width) * u64::from(self.height) * 4 {
|
||||
return Err(JsError::new("webp frame size mismatch"));
|
||||
}
|
||||
let added = unsafe {
|
||||
let mut config: ffi::WebPConfig = std::mem::zeroed();
|
||||
if ffi::WebPConfigInitInternal(
|
||||
&mut config,
|
||||
ffi::WebPPreset::WEBP_PRESET_DEFAULT,
|
||||
quality,
|
||||
ENCODER_ABI,
|
||||
) == 0
|
||||
{
|
||||
return Err(JsError::new("webp config init failed"));
|
||||
}
|
||||
config.quality = quality;
|
||||
config.method = method;
|
||||
config.lossless = self.lossless as i32;
|
||||
config.thread_level = 0;
|
||||
config.alpha_quality = 100;
|
||||
config.exact = 0;
|
||||
config.use_sharp_yuv = 0;
|
||||
let mut picture: ffi::WebPPicture = std::mem::zeroed();
|
||||
if ffi::WebPPictureInitInternal(&mut picture, ENCODER_ABI) == 0 {
|
||||
return Err(JsError::new("webp picture init failed"));
|
||||
}
|
||||
picture.width = self.width as i32;
|
||||
picture.height = self.height as i32;
|
||||
picture.use_argb = 1;
|
||||
if ffi::WebPPictureImportRGBA(&mut picture, rgba.as_ptr(), self.width as i32 * 4) == 0 {
|
||||
ffi::WebPPictureFree(&mut picture);
|
||||
return Err(JsError::new("webp picture import failed"));
|
||||
}
|
||||
let added = ffi::WebPAnimEncoderAdd(self.enc, &mut picture, start_ms, &config);
|
||||
ffi::WebPPictureFree(&mut picture);
|
||||
added
|
||||
};
|
||||
if added == 0 {
|
||||
return Err(self.error("webp frame encode failed"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn finish(&mut self, end_ms: i32) -> Result<Vec<u8>, JsError> {
|
||||
if unsafe { ffi::WebPAnimEncoderAdd(self.enc, ptr::null_mut(), end_ms, ptr::null()) } == 0 {
|
||||
return Err(self.error("webp encoder flush failed"));
|
||||
}
|
||||
let mut data = ffi::WebPData::default();
|
||||
if unsafe { ffi::WebPAnimEncoderAssemble(self.enc, &mut data) } == 0 {
|
||||
return Err(self.error("webp assemble failed"));
|
||||
}
|
||||
let out = unsafe { std::slice::from_raw_parts(data.bytes, data.size) }.to_vec();
|
||||
unsafe { ffi::WebPFree(data.bytes as *mut _) };
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn error(&self, what: &str) -> JsError {
|
||||
let message = unsafe { ffi::WebPAnimEncoderGetError(self.enc) };
|
||||
if message.is_null() {
|
||||
return JsError::new(what);
|
||||
}
|
||||
let detail = unsafe { CStr::from_ptr(message) }.to_string_lossy();
|
||||
JsError::new(&format!("{what}: {detail}"))
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for AnimEncoder {
|
||||
fn drop(&mut self) {
|
||||
unsafe { ffi::WebPAnimEncoderDelete(self.enc) };
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn frame(width: u32, height: u32, seed: u8) -> Vec<u8> {
|
||||
(0..width * height)
|
||||
.flat_map(|index| {
|
||||
let value = (index as u8).wrapping_mul(seed);
|
||||
[
|
||||
value,
|
||||
seed,
|
||||
value ^ seed,
|
||||
if index % 3 == 0 { 0 } else { 255 },
|
||||
]
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn encode(lossless: bool) -> Vec<u8> {
|
||||
let mut encoder = AnimEncoder::new(16, 8, 3, lossless).expect("encoder");
|
||||
let delays = [40, 70, 10];
|
||||
let mut start = 0;
|
||||
for (index, delay) in delays.iter().enumerate() {
|
||||
encoder
|
||||
.add(&frame(16, 8, index as u8 + 1), start, 75.0, 4)
|
||||
.expect("add");
|
||||
start += delay;
|
||||
}
|
||||
encoder.finish(start).expect("finish")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn probe_reports_canvas_frames_loop_alpha_and_lossless() {
|
||||
let lossless = encode(true);
|
||||
assert_eq!(
|
||||
&*webp_probe(&lossless).expect("probe"),
|
||||
&[16, 8, 3, 3, 1, 1]
|
||||
);
|
||||
let lossy = encode(false);
|
||||
let probe = webp_probe(&lossy).expect("probe");
|
||||
assert_eq!(&probe[..5], &[16, 8, 3, 3, 1]);
|
||||
assert_eq!(probe[5], 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lossless_round_trip_keeps_pixels_and_end_timestamps() {
|
||||
let bytes = encode(true);
|
||||
let mut decoder = AnimDecoder::new(bytes.into_boxed_slice()).expect("decoder");
|
||||
let mut ends = Vec::new();
|
||||
let mut index = 0u8;
|
||||
while decoder.next_frame().expect("next") {
|
||||
let pixels = unsafe { std::slice::from_raw_parts(decoder.frame, 16 * 8 * 4) };
|
||||
let expected = frame(16, 8, index + 1);
|
||||
for (got, want) in pixels.chunks(4).zip(expected.chunks(4)) {
|
||||
assert_eq!(got[3], want[3]);
|
||||
if want[3] != 0 {
|
||||
assert_eq!(got, want);
|
||||
}
|
||||
}
|
||||
ends.push(decoder.end_timestamp());
|
||||
index += 1;
|
||||
}
|
||||
assert_eq!(ends, [40, 110, 120]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use std::alloc::{Layout, alloc, alloc_zeroed, dealloc};
|
||||
use std::ffi::{c_int, c_void};
|
||||
|
||||
const ALIGN: usize = 16;
|
||||
|
||||
unsafe fn shim_alloc(size: usize, zeroed: bool) -> *mut c_void {
|
||||
let Some(full) = size.checked_add(ALIGN) else {
|
||||
return std::ptr::null_mut();
|
||||
};
|
||||
let Ok(layout) = Layout::from_size_align(full, ALIGN) else {
|
||||
return std::ptr::null_mut();
|
||||
};
|
||||
unsafe {
|
||||
let ptr = if zeroed {
|
||||
alloc_zeroed(layout)
|
||||
} else {
|
||||
alloc(layout)
|
||||
};
|
||||
if ptr.is_null() {
|
||||
return std::ptr::null_mut();
|
||||
}
|
||||
ptr.cast::<usize>().write(full);
|
||||
ptr.add(ALIGN).cast()
|
||||
}
|
||||
}
|
||||
|
||||
#[unsafe(no_mangle)]
|
||||
pub unsafe extern "C" fn fluxwebp_shim_malloc(size: usize) -> *mut c_void {
|
||||
unsafe { shim_alloc(size, false) }
|
||||
}
|
||||
|
||||
#[unsafe(no_mangle)]
|
||||
pub unsafe extern "C" fn fluxwebp_shim_calloc(nmemb: usize, size: usize) -> *mut c_void {
|
||||
match nmemb.checked_mul(size) {
|
||||
Some(total) => unsafe { shim_alloc(total, true) },
|
||||
None => std::ptr::null_mut(),
|
||||
}
|
||||
}
|
||||
|
||||
#[unsafe(no_mangle)]
|
||||
pub unsafe extern "C" fn fluxwebp_shim_free(ptr: *mut c_void) {
|
||||
if ptr.is_null() {
|
||||
return;
|
||||
}
|
||||
unsafe {
|
||||
let base = ptr.cast::<u8>().sub(ALIGN);
|
||||
let full = base.cast::<usize>().read();
|
||||
dealloc(base, Layout::from_size_align_unchecked(full, ALIGN));
|
||||
}
|
||||
}
|
||||
|
||||
#[unsafe(no_mangle)]
|
||||
pub unsafe extern "C" fn fluxwebp_shim_qsort(
|
||||
base: *mut c_void,
|
||||
n: usize,
|
||||
size: usize,
|
||||
compar: unsafe extern "C" fn(*const c_void, *const c_void) -> c_int,
|
||||
) {
|
||||
if n < 2 || size == 0 {
|
||||
return;
|
||||
}
|
||||
unsafe {
|
||||
let bytes = std::slice::from_raw_parts_mut(base.cast::<u8>(), n * size);
|
||||
let copy = bytes.to_vec();
|
||||
let mut idx: Vec<usize> = (0..n).collect();
|
||||
idx.sort_by(|&a, &b| {
|
||||
let r = compar(
|
||||
copy.as_ptr().add(a * size).cast(),
|
||||
copy.as_ptr().add(b * size).cast(),
|
||||
);
|
||||
r.cmp(&0)
|
||||
});
|
||||
for (dst, &src) in idx.iter().enumerate() {
|
||||
bytes[dst * size..dst * size + size]
|
||||
.copy_from_slice(©[src * size..src * size + size]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "C" fn fluxwebp_shim_pow(x: f64, y: f64) -> f64 {
|
||||
x.powf(y)
|
||||
}
|
||||
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "C" fn fluxwebp_shim_log(x: f64) -> f64 {
|
||||
x.ln()
|
||||
}
|
||||
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "C" fn fluxwebp_shim_log10(x: f64) -> f64 {
|
||||
x.log10()
|
||||
}
|
||||
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "C" fn fluxwebp_shim_expf(x: f32) -> f32 {
|
||||
x.exp()
|
||||
}
|
||||
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "C" fn fluxwebp_shim_logf(x: f32) -> f32 {
|
||||
x.ln()
|
||||
}
|
||||
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "C" fn fluxwebp_shim_round(x: f64) -> f64 {
|
||||
x.round()
|
||||
}
|
||||
@@ -11,6 +11,18 @@ const FONT_LICENSE_FILES = [
|
||||
{source: 'LICENSE-IBM-PLEX.txt', asset: 'assets/fonts-LICENSE-IBM-PLEX.txt'},
|
||||
];
|
||||
|
||||
const WASM_LICENSE_FILES = [
|
||||
{source: 'libfluxcore/NOTICE.md', asset: 'assets/libfluxcore-NOTICE.txt'},
|
||||
{source: 'libfluxcore/LICENSE-ZSTD.txt', asset: 'assets/libfluxcore-LICENSE-ZSTD.txt'},
|
||||
{source: 'libfluxcore/LICENSE-ZSTD-SYS.txt', asset: 'assets/libfluxcore-LICENSE-ZSTD-SYS.txt'},
|
||||
{source: 'libfluxcore/LICENSE-ZSTD-RS.txt', asset: 'assets/libfluxcore-LICENSE-ZSTD-RS.txt'},
|
||||
{source: 'libfluxwebp/NOTICE.md', asset: 'assets/libfluxwebp-NOTICE.txt'},
|
||||
{source: 'libfluxwebp/LICENSE-LIBWEBP.txt', asset: 'assets/libfluxwebp-LICENSE-LIBWEBP.txt'},
|
||||
{source: 'libfluxwebp/PATENTS-LIBWEBP.txt', asset: 'assets/libfluxwebp-PATENTS-LIBWEBP.txt'},
|
||||
{source: 'libfluxwebp/LICENSE-LIBWEBP-SYS.txt', asset: 'assets/libfluxwebp-LICENSE-LIBWEBP-SYS.txt'},
|
||||
{source: 'libfluxwebp/simd/LICENSE', asset: 'assets/libfluxwebp-LICENSE-EMSCRIPTEN.txt'},
|
||||
];
|
||||
|
||||
function resolveStaticCdnEndpoint(staticCdnEndpoint) {
|
||||
const value = staticCdnEndpoint?.trim().replace(/\/+$/, '');
|
||||
return value || STATIC_CDN_ENDPOINT_PLACEHOLDER;
|
||||
@@ -23,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',
|
||||
@@ -31,6 +44,7 @@ function generateManifest(staticCdnEndpoint) {
|
||||
categories: ['social', 'communication'],
|
||||
lang: 'en',
|
||||
scope: '/',
|
||||
scope_extensions: [],
|
||||
icons: [
|
||||
{
|
||||
src: `${cdn}/web/android-chrome-192x192.png`,
|
||||
@@ -86,17 +100,18 @@ export class StaticFilesPlugin {
|
||||
constructor(options = {}) {
|
||||
this.staticCdnEndpoint = options.staticCdnEndpoint;
|
||||
this.fontsDir = options.fontsDir;
|
||||
this.wasmCratesDir = options.wasmCratesDir;
|
||||
}
|
||||
|
||||
emitFontLicenses(compilation) {
|
||||
if (!this.fontsDir) {
|
||||
emitLicenseFiles(compilation, dir, files, subject) {
|
||||
if (!dir) {
|
||||
return;
|
||||
}
|
||||
for (const {source, asset} of FONT_LICENSE_FILES) {
|
||||
const sourcePath = path.join(this.fontsDir, source);
|
||||
for (const {source, asset} of files) {
|
||||
const sourcePath = path.join(dir, source);
|
||||
if (!fs.existsSync(sourcePath)) {
|
||||
throw new Error(
|
||||
`StaticFilesPlugin: ${sourcePath} is missing. The bundled fonts may not be redistributed without it.`,
|
||||
`StaticFilesPlugin: ${sourcePath} is missing. The bundled ${subject} may not be redistributed without it.`,
|
||||
);
|
||||
}
|
||||
compilation.emitAsset(asset, new sources.RawSource(fs.readFileSync(sourcePath)));
|
||||
@@ -117,7 +132,8 @@ export class StaticFilesPlugin {
|
||||
new sources.RawSource(generateBrowserConfig(this.staticCdnEndpoint)),
|
||||
);
|
||||
compilation.emitAsset('robots.txt', new sources.RawSource(generateRobotsTxt()));
|
||||
this.emitFontLicenses(compilation);
|
||||
this.emitLicenseFiles(compilation, this.fontsDir, FONT_LICENSE_FILES, 'fonts');
|
||||
this.emitLicenseFiles(compilation, this.wasmCratesDir, WASM_LICENSE_FILES, 'WebAssembly modules');
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -418,6 +418,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 +479,7 @@ function resolveForwardDestinationOption(
|
||||
return Object.freeze({
|
||||
channel,
|
||||
destination,
|
||||
detail: null,
|
||||
detail: formatGroupDMDetail(channel),
|
||||
disableReason: resolveAgeRestrictedDisableReason(channel, mediaNeeds, i18n),
|
||||
displayName: ChannelUtils.getDMDisplayName(channel),
|
||||
key,
|
||||
|
||||
@@ -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,607 @@
|
||||
// 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 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) {
|
||||
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,119 @@
|
||||
// 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 {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 {compareShallow, reaction} from 'mobx';
|
||||
|
||||
const logger = new Logger('DomainMigrationTrigger');
|
||||
|
||||
const ONE_SHOT_ROUTE_PREFIXES: ReadonlyArray<string> = [
|
||||
Routes.RESET_PASSWORD,
|
||||
Routes.VERIFY_EMAIL,
|
||||
Routes.AUTHORIZE_IP,
|
||||
Routes.EMAIL_REVERT,
|
||||
Routes.OAUTH_AUTHORIZE,
|
||||
Routes.SSO_CALLBACK,
|
||||
Routes.PREMIUM_CALLBACK,
|
||||
Routes.AGE_VERIFICATION_CALLBACK,
|
||||
Routes.CONNECTION_CALLBACK,
|
||||
];
|
||||
|
||||
let started = false;
|
||||
let navigating = false;
|
||||
|
||||
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;
|
||||
}
|
||||
reaction(
|
||||
() => ({enabled: DomainMigrationRollout.enabled, voiceActive: isVoiceActive()}),
|
||||
({enabled}) => {
|
||||
evaluateSource(side, enabled).catch((err) => {
|
||||
logger.warn('Domain migration trigger failed:', err);
|
||||
});
|
||||
},
|
||||
{fireImmediately: true, equals: compareShallow},
|
||||
);
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import * as Modal from '@app/features/app/components/dialogs/Modal';
|
||||
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';
|
||||
@@ -43,6 +47,8 @@ export const PasskeyNameModal = observer(({onSubmit}: {onSubmit: (name: string)
|
||||
} catch (error) {
|
||||
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)});
|
||||
}
|
||||
|
||||
@@ -126,6 +126,10 @@ const SudoVerificationModal: React.FC = observer(() => {
|
||||
setWebAuthnError(i18n._(PASSKEYS_REQUIRE_A_SIGNED_MACOS_BUNDLE_WITH_A_DESCRIPTOR));
|
||||
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));
|
||||
}
|
||||
};
|
||||
|
||||
+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.',
|
||||
@@ -164,6 +176,28 @@ export const AuthLoginLayout = observer(function AuthLoginLayout({
|
||||
});
|
||||
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 +361,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
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -19,7 +19,9 @@ import {
|
||||
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 {useCallback, useMemo, useRef, useState} from 'react';
|
||||
|
||||
const logger = Logger.create('useLoginFlow');
|
||||
@@ -87,6 +89,7 @@ export function useLoginFormController({
|
||||
onRequireMfa,
|
||||
onRequireIpAuthorization,
|
||||
}: LoginFormControllerOptions) {
|
||||
const {i18n} = useLingui();
|
||||
const [isPasskeyLoading, setIsPasskeyLoading] = useState(false);
|
||||
const {form, isLoading, fieldErrors, error} = useAuthForm({
|
||||
initialValues: {email: '', password: ''},
|
||||
@@ -129,6 +132,10 @@ export function useLoginFormController({
|
||||
return;
|
||||
}
|
||||
logger.error('Passkey login failed', err);
|
||||
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 +144,7 @@ export function useLoginFormController({
|
||||
} finally {
|
||||
setIsPasskeyLoading(false);
|
||||
}
|
||||
}, [inviteCode, onLoginSuccess, redirectPath, handleDesktopPasskeyHandoff]);
|
||||
}, [inviteCode, onLoginSuccess, redirectPath, handleDesktopPasskeyHandoff, i18n]);
|
||||
return {
|
||||
form,
|
||||
isLoading,
|
||||
@@ -161,6 +168,7 @@ interface MfaControllerOptions {
|
||||
}
|
||||
|
||||
export function useMfaController({ticket, methods, inviteCode, onLoginSuccess}: MfaControllerOptions) {
|
||||
const {i18n} = useLingui();
|
||||
const [isWebAuthnLoading, setIsWebAuthnLoading] = useState(false);
|
||||
const {form, isLoading, fieldErrors} = useAuthForm({
|
||||
initialValues: {code: ''},
|
||||
@@ -193,10 +201,13 @@ export function useMfaController({ticket, methods, inviteCode, onLoginSuccess}:
|
||||
await onLoginSuccess?.(response);
|
||||
} catch (error) {
|
||||
logger.error('WebAuthn MFA failed', error);
|
||||
if (error instanceof WebAuthnUtils.PasskeyDomainUnsupportedError) {
|
||||
ToastCommands.error(i18n._(WebAuthnUtils.PASSKEY_DOMAIN_UNSUPPORTED_DESCRIPTOR));
|
||||
}
|
||||
} finally {
|
||||
setIsWebAuthnLoading(false);
|
||||
}
|
||||
}, [inviteCode, onLoginSuccess, ticket]);
|
||||
}, [inviteCode, onLoginSuccess, ticket, i18n]);
|
||||
const supports = useMemo(
|
||||
() => ({totp: methods.totp, webauthn: methods.webauthn, backupCodes: methods.backupCodes}),
|
||||
[methods.totp, methods.webauthn, methods.backupCodes],
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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}));
|
||||
}
|
||||
|
||||
@@ -20,6 +20,10 @@ export function hasRichEmbedContent(embed: RichEmbedContentFields): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
export function embedAllowsMarkdown(embed: Pick<MessageEmbed, 'type'>): boolean {
|
||||
return embed.type === MessageEmbedTypes.RICH || embed.type === MessageEmbedTypes.BLUESKY;
|
||||
}
|
||||
|
||||
type MediaOnlyEmbedFields = RichEmbedContentFields & Pick<MessageEmbed, 'image' | 'thumbnail' | 'video' | 'audio'>;
|
||||
|
||||
export function isMediaOnlyEmbed(embed: MediaOnlyEmbedFields): boolean {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user