mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-10 04:32:34 +09:00
Compare commits
30
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
01f53a168d | ||
|
|
336b8b7dcd | ||
|
|
48d0034239 | ||
|
|
677ef8491e | ||
|
|
6a6119ed1e | ||
|
|
931327d1dc | ||
|
|
858a2d9e2b | ||
|
|
841fb7af41 | ||
|
|
08e65d41c0 | ||
|
|
f76c4dc041 | ||
|
|
f1f8ba2031 | ||
|
|
5ab8d745c0 | ||
|
|
ff62bc89a4 | ||
|
|
838bbdb5ec | ||
|
|
1c36a59b2c | ||
|
|
6730a242db | ||
|
|
e62ae77643 | ||
|
|
f4f39e6a89 | ||
|
|
00bf74cef5 | ||
|
|
c1c45d835f | ||
|
|
bbfe809bef | ||
|
|
e0843ac4f5 | ||
|
|
43741cdad8 | ||
|
|
b8e3807262 | ||
|
|
3304f01a84 | ||
|
|
2ba463235b | ||
|
|
15136fed59 | ||
|
|
6013581dd9 | ||
|
|
7a91f128e9 | ||
|
|
963ffc5550 |
@@ -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": {
|
||||
@@ -15199,7 +15225,8 @@
|
||||
"maxItems": 1000,
|
||||
"type": "array",
|
||||
"items": {"type": "string", "pattern": "^\\d{1,20}$"}
|
||||
}
|
||||
},
|
||||
"relay_consent_accepted": {"type": "boolean"}
|
||||
}
|
||||
},
|
||||
"VoiceNoiseSuppressionConfigUpdateRequest": {
|
||||
@@ -15267,6 +15294,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": {
|
||||
@@ -15291,7 +15358,16 @@
|
||||
"maxItems": 1000,
|
||||
"type": "array",
|
||||
"items": {"type": "string", "pattern": "^\\d{1,20}$"}
|
||||
}
|
||||
},
|
||||
"relay_consent_accepted": {"default": false, "type": "boolean"},
|
||||
"relay_consent_accepted_at": {
|
||||
"default": null,
|
||||
"nullable": true,
|
||||
"type": "string",
|
||||
"format": "date-time",
|
||||
"pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z))$"
|
||||
},
|
||||
"relay_consent_accepted_by": {"default": null, "nullable": true, "type": "string", "pattern": "^\\d{1,20}$"}
|
||||
},
|
||||
"required": [
|
||||
"enabled",
|
||||
@@ -15299,7 +15375,10 @@
|
||||
"rollout_basis_points",
|
||||
"rollout_salt",
|
||||
"included_user_ids",
|
||||
"excluded_user_ids"
|
||||
"excluded_user_ids",
|
||||
"relay_consent_accepted",
|
||||
"relay_consent_accepted_at",
|
||||
"relay_consent_accepted_by"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
@@ -15645,9 +15724,10 @@
|
||||
"id": {"type": "string", "description": "The credential ID"},
|
||||
"name": {"type": "string", "description": "User-assigned name for the credential"},
|
||||
"created_at": {"type": "string", "description": "When the credential was registered"},
|
||||
"last_used_at": {"nullable": true, "description": "When the credential was last used", "type": "string"}
|
||||
"last_used_at": {"nullable": true, "description": "When the credential was last used", "type": "string"},
|
||||
"rp_id": {"type": "string", "description": "Relying party ID the passkey belongs to"}
|
||||
},
|
||||
"required": ["id", "name", "created_at", "last_used_at"],
|
||||
"required": ["id", "name", "created_at", "last_used_at", "rp_id"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"VoiceServerAdminResponse": {
|
||||
|
||||
@@ -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 {
|
||||
@@ -549,6 +552,9 @@ pub struct PushServiceDeliveryConfigResponse {
|
||||
pub rollout_salt: String,
|
||||
pub included_user_ids: Vec<String>,
|
||||
pub excluded_user_ids: Vec<String>,
|
||||
pub relay_consent_accepted: bool,
|
||||
pub relay_consent_accepted_at: Option<String>,
|
||||
pub relay_consent_accepted_by: Option<String>,
|
||||
}
|
||||
|
||||
impl Default for PushServiceDeliveryConfigResponse {
|
||||
@@ -560,6 +566,9 @@ impl Default for PushServiceDeliveryConfigResponse {
|
||||
rollout_salt: PUSH_SERVICE_DELIVERY_DEFAULT_SALT.to_owned(),
|
||||
included_user_ids: Vec::new(),
|
||||
excluded_user_ids: Vec::new(),
|
||||
relay_consent_accepted: false,
|
||||
relay_consent_accepted_at: None,
|
||||
relay_consent_accepted_by: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -576,6 +585,54 @@ pub struct PushServiceDeliveryConfigUpdateRequest {
|
||||
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 relay_consent_accepted: Option<bool>,
|
||||
}
|
||||
|
||||
#[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)]
|
||||
@@ -696,6 +753,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 +1092,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 +1123,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 +1159,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",
|
||||
)?,
|
||||
@@ -675,6 +680,49 @@ fn build_push_service_delivery_update(
|
||||
.unwrap_or_default(),
|
||||
"Excluded user IDs",
|
||||
)?),
|
||||
relay_consent_accepted: Some(
|
||||
form.bool_value("push_service_delivery_relay_consent_accepted"),
|
||||
),
|
||||
}),
|
||||
..Default::default()
|
||||
})
|
||||
}
|
||||
|
||||
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()
|
||||
})
|
||||
@@ -1601,6 +1649,115 @@ 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_push_service_delivery_update_reads_the_relay_consent_checkbox() {
|
||||
let unchecked = MultiValueForm::parse(b"_csrf=token");
|
||||
assert_eq!(
|
||||
build_push_service_delivery_update(&unchecked)
|
||||
.expect("valid form")
|
||||
.push_service_delivery
|
||||
.expect("push service delivery update")
|
||||
.relay_consent_accepted,
|
||||
Some(false)
|
||||
);
|
||||
|
||||
let checked =
|
||||
MultiValueForm::parse(b"_csrf=token&push_service_delivery_relay_consent_accepted=true");
|
||||
assert_eq!(
|
||||
build_push_service_delivery_update(&checked)
|
||||
.expect("valid form")
|
||||
.push_service_delivery
|
||||
.expect("push service delivery update")
|
||||
.relay_consent_accepted,
|
||||
Some(true)
|
||||
);
|
||||
}
|
||||
|
||||
#[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))
|
||||
@@ -1189,6 +1191,14 @@ fn push_service_delivery_section(
|
||||
};
|
||||
let included_user_ids = push_service_delivery.included_user_ids.join("\n");
|
||||
let excluded_user_ids = push_service_delivery.excluded_user_ids.join("\n");
|
||||
let relay_consent_stamp = match (
|
||||
push_service_delivery.relay_consent_accepted_at.as_deref(),
|
||||
push_service_delivery.relay_consent_accepted_by.as_deref(),
|
||||
) {
|
||||
(Some(at), Some(by)) => Some(format!("Accepted {at} by user {by}")),
|
||||
(Some(at), None) => Some(format!("Accepted {at}")),
|
||||
_ => None,
|
||||
};
|
||||
section_card_with_description(
|
||||
"Push Service Delivery",
|
||||
"Routes push notification delivery for the selected accounts through the push service. \
|
||||
@@ -1217,6 +1227,28 @@ fn push_service_delivery_section(
|
||||
effect at all."
|
||||
}
|
||||
|
||||
h3 class="text-sm font-semibold text-neutral-900" { "Managed relay consent" }
|
||||
(checkbox(
|
||||
"push_service_delivery_relay_consent_accepted",
|
||||
"true",
|
||||
"Accept the push relay supplemental privacy notice",
|
||||
push_service_delivery.relay_consent_accepted,
|
||||
true,
|
||||
))
|
||||
p class="text-xs text-neutral-500" {
|
||||
"Required only for the official mobile apps, whose notifications travel \
|
||||
through Fluxer's relay to Apple and Google. Until this is accepted those \
|
||||
notifications are dropped. Self-hosted UnifiedPush and ntfy endpoints \
|
||||
never reach the relay and are unaffected. "
|
||||
a href="https://fluxer.com/push-relay" target="_blank" rel="noreferrer"
|
||||
class="text-neutral-900 underline decoration-neutral-300 hover:text-neutral-600 hover:decoration-neutral-500" {
|
||||
"Read the notice"
|
||||
}
|
||||
}
|
||||
@if let Some(stamp) = relay_consent_stamp {
|
||||
p class="text-xs text-neutral-500" { (stamp) }
|
||||
}
|
||||
|
||||
h3 class="text-sm font-semibold text-neutral-900" { "Rollout" }
|
||||
(number_field(
|
||||
"push_service_delivery_rollout_basis_points",
|
||||
@@ -1286,6 +1318,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 +2094,51 @@ 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 push_service_delivery_section_shows_the_relay_consent_toggle() {
|
||||
let accepted = PushServiceDeliveryConfigResponse {
|
||||
relay_consent_accepted: true,
|
||||
relay_consent_accepted_at: Some("2026-09-27T10:11:12.000Z".to_owned()),
|
||||
relay_consent_accepted_by: Some("1130650140672000000".to_owned()),
|
||||
..PushServiceDeliveryConfigResponse::default()
|
||||
};
|
||||
let markup = push_service_delivery_section("/admin", "csrf", &accepted).into_string();
|
||||
assert!(markup.contains("name=\"push_service_delivery_relay_consent_accepted\""));
|
||||
assert!(markup.contains("https://fluxer.com/push-relay"));
|
||||
assert!(markup.contains("Accepted 2026-09-27T10:11:12.000Z by user 1130650140672000000"));
|
||||
|
||||
let unaccepted = push_service_delivery_section(
|
||||
"/admin",
|
||||
"csrf",
|
||||
&PushServiceDeliveryConfigResponse::default(),
|
||||
)
|
||||
.into_string();
|
||||
assert!(unaccepted.contains("name=\"push_service_delivery_relay_consent_accepted\""));
|
||||
assert!(!unaccepted.contains("Accepted "));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn voice_noise_suppression_section_flags_a_list_at_its_cap() {
|
||||
let voice_noise_suppression = VoiceNoiseSuppressionConfigResponse {
|
||||
|
||||
@@ -415,7 +415,21 @@ fn deserialize_instance_config_response_with_unknown_keys() {
|
||||
"rollout_basis_points": 5000,
|
||||
"rollout_salt": "push-service-delivery-v1",
|
||||
"included_user_ids": ["1500000000000000002"],
|
||||
"excluded_user_ids": []
|
||||
"excluded_user_ids": [],
|
||||
"relay_consent_accepted": true,
|
||||
"relay_consent_accepted_at": "2026-09-27T10:11:12.000Z",
|
||||
"relay_consent_accepted_by": "1130650140672000000"
|
||||
},
|
||||
"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": {
|
||||
@@ -546,6 +560,14 @@ 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!(resp.push_service_delivery.relay_consent_accepted);
|
||||
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 +578,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(
|
||||
@@ -577,6 +600,71 @@ fn deserialize_instance_config_response_with_unknown_keys() {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deserialize_push_service_delivery_relay_consent() {
|
||||
let accepted: types::PushServiceDeliveryConfigResponse = serde_json::from_str(
|
||||
r#"{
|
||||
"enabled": true,
|
||||
"config_version": 3,
|
||||
"rollout_basis_points": 5000,
|
||||
"rollout_salt": "push-service-delivery-v1",
|
||||
"included_user_ids": [],
|
||||
"excluded_user_ids": [],
|
||||
"relay_consent_accepted": true,
|
||||
"relay_consent_accepted_at": "2026-09-27T10:11:12.000Z",
|
||||
"relay_consent_accepted_by": "1130650140672000000"
|
||||
}"#,
|
||||
)
|
||||
.expect("an accepted relay consent must deserialize");
|
||||
|
||||
assert!(accepted.relay_consent_accepted);
|
||||
assert_eq!(
|
||||
accepted.relay_consent_accepted_at.as_deref(),
|
||||
Some("2026-09-27T10:11:12.000Z")
|
||||
);
|
||||
assert_eq!(
|
||||
accepted.relay_consent_accepted_by.as_deref(),
|
||||
Some("1130650140672000000")
|
||||
);
|
||||
|
||||
let legacy: types::PushServiceDeliveryConfigResponse = serde_json::from_str(
|
||||
r#"{
|
||||
"enabled": true,
|
||||
"config_version": 3,
|
||||
"rollout_basis_points": 5000,
|
||||
"rollout_salt": "push-service-delivery-v1",
|
||||
"included_user_ids": [],
|
||||
"excluded_user_ids": []
|
||||
}"#,
|
||||
)
|
||||
.expect("a response written before relay consent must still deserialize");
|
||||
|
||||
assert!(!legacy.relay_consent_accepted);
|
||||
assert!(legacy.relay_consent_accepted_at.is_none());
|
||||
assert!(legacy.relay_consent_accepted_by.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serialize_push_service_delivery_update_omits_an_unset_relay_consent() {
|
||||
let without = types::PushServiceDeliveryConfigUpdateRequest {
|
||||
enabled: Some(true),
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(
|
||||
serde_json::to_value(&without).unwrap(),
|
||||
serde_json::json!({"enabled": true})
|
||||
);
|
||||
|
||||
let with = types::PushServiceDeliveryConfigUpdateRequest {
|
||||
relay_consent_accepted: Some(true),
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(
|
||||
serde_json::to_value(&with).unwrap(),
|
||||
serde_json::json!({"relay_consent_accepted": true})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deserialize_search_reports_response() {
|
||||
let json = r#"{
|
||||
@@ -851,7 +939,8 @@ fn deserialize_webauthn_credentials_response() {
|
||||
"id": "credential-a",
|
||||
"name": "YubiKey",
|
||||
"created_at": "2026-05-26T12:00:00.000Z",
|
||||
"last_used_at": null
|
||||
"last_used_at": null,
|
||||
"rp_id": "fluxer.com"
|
||||
},
|
||||
{
|
||||
"id": "credential-b",
|
||||
|
||||
@@ -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,8 +34,13 @@ 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 {
|
||||
type PushServiceDeliveryConfig,
|
||||
PushServiceDeliveryConfigSchema,
|
||||
type PushServiceDeliveryConfigUpdateRequest,
|
||||
} from '@fluxer/schema/src/domains/admin/PushServiceDeliverySchemas';
|
||||
import {VoiceNoiseSuppressionConfigSchema} from '@fluxer/schema/src/domains/admin/VoiceNoiseSuppressionSchemas';
|
||||
import {UserIdParam} from '@fluxer/schema/src/domains/common/CommonParamSchemas';
|
||||
import {ExperimentDeliveryConfigSchema} from '@fluxer/schema/src/domains/experiment/ExperimentSchemas';
|
||||
@@ -65,6 +70,7 @@ async function buildInstanceConfigResponse(): Promise<InstanceConfigResponse> {
|
||||
gatewayRollout,
|
||||
voiceNoiseSuppression,
|
||||
pushServiceDelivery,
|
||||
domainMigration,
|
||||
experimentDelivery,
|
||||
registrationConfig,
|
||||
registrationUrls,
|
||||
@@ -74,6 +80,7 @@ async function buildInstanceConfigResponse(): Promise<InstanceConfigResponse> {
|
||||
instanceConfigRepository.getGatewayRolloutConfig(),
|
||||
instanceConfigRepository.getVoiceNoiseSuppressionConfig(),
|
||||
instanceConfigRepository.getPushServiceDeliveryConfig(),
|
||||
instanceConfigRepository.getDomainMigrationConfig(),
|
||||
instanceConfigRepository.getExperimentDeliveryConfig(),
|
||||
instanceConfigRepository.getRegistrationConfig(),
|
||||
instanceConfigRepository.getRegistrationUrlsForAdmin(),
|
||||
@@ -106,6 +113,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,
|
||||
@@ -190,6 +198,20 @@ async function grantSetupCompleterAdminACL(ctx: Context<HonoEnv>): Promise<boole
|
||||
return true;
|
||||
}
|
||||
|
||||
function relayConsentStamp(
|
||||
current: PushServiceDeliveryConfig,
|
||||
patch: Partial<PushServiceDeliveryConfigUpdateRequest>,
|
||||
adminUserId: string,
|
||||
): Partial<PushServiceDeliveryConfig> {
|
||||
const accepted = patch.relay_consent_accepted;
|
||||
if (accepted === undefined || accepted === current.relay_consent_accepted) {
|
||||
return {};
|
||||
}
|
||||
return accepted
|
||||
? {relay_consent_accepted_at: new Date().toISOString(), relay_consent_accepted_by: adminUserId}
|
||||
: {relay_consent_accepted_at: null, relay_consent_accepted_by: null};
|
||||
}
|
||||
|
||||
function listSuppliedSections(data: InstanceConfigUpdateRequest): string | undefined {
|
||||
const sections = Object.entries(data)
|
||||
.filter(([, value]) => value != null)
|
||||
@@ -272,14 +294,28 @@ export function InstanceConfigAdminController(app: HonoApp) {
|
||||
if (data.push_service_delivery) {
|
||||
const patch = omitUndefinedFields(data.push_service_delivery);
|
||||
if (Object.keys(patch).length > 0) {
|
||||
const adminUserId = ctx.get('adminUserId').toString();
|
||||
const landed = await instanceConfigRepository.updatePushServiceDeliveryConfig((current) =>
|
||||
PushServiceDeliveryConfigSchema.parse({
|
||||
...current,
|
||||
...patch,
|
||||
...relayConsentStamp(current, patch, adminUserId),
|
||||
config_version: current.config_version + 1,
|
||||
}),
|
||||
);
|
||||
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,
|
||||
}),
|
||||
);
|
||||
await getPushServiceDeliveryConfigPublisher().publish(landed);
|
||||
}
|
||||
}
|
||||
if (data.experiment_delivery) {
|
||||
|
||||
@@ -8,12 +8,14 @@ import * as AuthEmail from '@app/api/auth/AuthEmail';
|
||||
import * as AuthMfa from '@app/api/auth/AuthMfa';
|
||||
import * as AuthSession from '@app/api/auth/AuthSession';
|
||||
import * as AuthUtility from '@app/api/auth/AuthUtility';
|
||||
import {visibleWebAuthnCredentials} from '@app/api/auth/services/PasskeyRelyingParty';
|
||||
import {createPasswordResetToken, createUserID, type UserID} from '@app/api/BrandedTypes';
|
||||
import type {UserRow} from '@app/api/database/types/UserTypes';
|
||||
import {Logger} from '@app/api/Logger';
|
||||
import {getInstanceConfigRepository} from '@app/api/middleware/ServiceSingletons';
|
||||
import type {IRiskHistoryRepository} from '@app/api/risk/HistoricalOutcomeRepository';
|
||||
import type {HistoricalOutcomeCode} from '@app/api/risk/RiskHistoryTypes';
|
||||
import {mapWebAuthnCredentialToResponse} from '@app/api/user/UserMappers';
|
||||
import {resolveAssignedTraits} from '@app/api/user/UserTraits';
|
||||
import {getIpAddressReverse, getLocationLabelFromIp} from '@app/api/utils/IpUtils';
|
||||
import {resolveSessionClientInfo} from '@app/api/utils/SessionClientIdentity';
|
||||
@@ -545,7 +547,7 @@ export class AdminUserSecurityService {
|
||||
if (!user) {
|
||||
throw new UnknownUserError();
|
||||
}
|
||||
const credentials = await userRepository.listWebAuthnCredentials(userId);
|
||||
const credentials = visibleWebAuthnCredentials(await userRepository.listWebAuthnCredentials(userId));
|
||||
await auditService.createAuditLog({
|
||||
adminUserId,
|
||||
targetType: 'user',
|
||||
@@ -554,12 +556,9 @@ export class AdminUserSecurityService {
|
||||
auditLogReason,
|
||||
metadata: new Map([['credential_count', credentials.length.toString()]]),
|
||||
});
|
||||
return credentials.map((cred) => ({
|
||||
id: cred.credentialId,
|
||||
name: cred.name,
|
||||
created_at: cred.createdAt.toISOString(),
|
||||
last_used_at: cred.lastUsedAt?.toISOString() ?? null,
|
||||
}));
|
||||
return credentials.map((cred) =>
|
||||
mapWebAuthnCredentialToResponse(cred, this.deps.apiContext.services.config.auth.passkeys.rpId),
|
||||
);
|
||||
}
|
||||
|
||||
async deleteWebAuthnCredential(
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import type {TestAccount} from '@app/api/auth/tests/AuthTestUtils';
|
||||
import {createTestAccount, setUserACLs} from '@app/api/auth/tests/AuthTestUtils';
|
||||
import {PushServiceDeliveryConfigPublisher} from '@app/api/instance/PushServiceDeliveryConfigPublisher';
|
||||
import type {ApiTestHarness} from '@app/api/test/ApiTestHarness';
|
||||
import {createApiTestHarness} from '@app/api/test/ApiTestHarness';
|
||||
import {createBuilder} from '@app/api/test/TestRequestBuilder';
|
||||
import {AdminACLs} from '@fluxer/constants/src/AdminACLs';
|
||||
import type {InstanceConfigResponse} from '@fluxer/schema/src/domains/admin/AdminSchemas';
|
||||
import {afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi} from 'vitest';
|
||||
|
||||
describe('push relay supplemental notice consent', () => {
|
||||
let harness: ApiTestHarness;
|
||||
|
||||
beforeAll(async () => {
|
||||
harness = await createApiTestHarness();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await harness.reset();
|
||||
vi.spyOn(PushServiceDeliveryConfigPublisher.prototype, 'publish').mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await harness.shutdown();
|
||||
});
|
||||
|
||||
const createAdmin = async (): Promise<TestAccount> =>
|
||||
await setUserACLs(harness, await createTestAccount(harness), [
|
||||
AdminACLs.AUTHENTICATE,
|
||||
AdminACLs.INSTANCE_CONFIG_VIEW,
|
||||
AdminACLs.INSTANCE_CONFIG_UPDATE,
|
||||
]);
|
||||
|
||||
const patchConfig = (admin: TestAccount, body: Record<string, unknown>) =>
|
||||
createBuilder<InstanceConfigResponse>(harness, admin.token).patch('/admin/instance/config').body(body);
|
||||
|
||||
const readConfig = (admin: TestAccount) =>
|
||||
createBuilder<InstanceConfigResponse>(harness, admin.token).get('/admin/instance/config');
|
||||
|
||||
it('reads back as unaccepted before an operator agrees', async () => {
|
||||
const admin = await createAdmin();
|
||||
|
||||
const config = await readConfig(admin).execute();
|
||||
|
||||
expect(config.push_service_delivery).toMatchObject({
|
||||
relay_consent_accepted: false,
|
||||
relay_consent_accepted_at: null,
|
||||
relay_consent_accepted_by: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('stamps the acting admin and the acceptance time when consent is given', async () => {
|
||||
const admin = await createAdmin();
|
||||
|
||||
const updated = await patchConfig(admin, {push_service_delivery: {relay_consent_accepted: true}}).execute();
|
||||
|
||||
expect(updated.push_service_delivery.relay_consent_accepted).toBe(true);
|
||||
expect(updated.push_service_delivery.relay_consent_accepted_by).toBe(admin.userId);
|
||||
expect(Date.parse(updated.push_service_delivery.relay_consent_accepted_at ?? '')).not.toBeNaN();
|
||||
});
|
||||
|
||||
it('keeps the first acceptance stamp when a later patch changes only the rollout', async () => {
|
||||
const admin = await createAdmin();
|
||||
const accepted = await patchConfig(admin, {push_service_delivery: {relay_consent_accepted: true}}).execute();
|
||||
|
||||
const rolledOut = await patchConfig(admin, {
|
||||
push_service_delivery: {enabled: true, rollout_basis_points: 2500},
|
||||
}).execute();
|
||||
|
||||
expect(rolledOut.push_service_delivery).toMatchObject({
|
||||
enabled: true,
|
||||
rollout_basis_points: 2500,
|
||||
relay_consent_accepted: true,
|
||||
relay_consent_accepted_at: accepted.push_service_delivery.relay_consent_accepted_at,
|
||||
relay_consent_accepted_by: admin.userId,
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps the stamp untouched when consent is re-sent unchanged', async () => {
|
||||
const admin = await createAdmin();
|
||||
const accepted = await patchConfig(admin, {push_service_delivery: {relay_consent_accepted: true}}).execute();
|
||||
|
||||
const resent = await patchConfig(admin, {push_service_delivery: {relay_consent_accepted: true}}).execute();
|
||||
|
||||
expect(resent.push_service_delivery.relay_consent_accepted_at).toBe(
|
||||
accepted.push_service_delivery.relay_consent_accepted_at,
|
||||
);
|
||||
});
|
||||
|
||||
it('clears the stamp when an operator withdraws consent', async () => {
|
||||
const admin = await createAdmin();
|
||||
await patchConfig(admin, {push_service_delivery: {relay_consent_accepted: true}}).execute();
|
||||
|
||||
const withdrawn = await patchConfig(admin, {push_service_delivery: {relay_consent_accepted: false}}).execute();
|
||||
|
||||
expect(withdrawn.push_service_delivery).toMatchObject({
|
||||
relay_consent_accepted: false,
|
||||
relay_consent_accepted_at: null,
|
||||
relay_consent_accepted_by: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('ignores an acceptance stamp supplied by the caller', async () => {
|
||||
const admin = await createAdmin();
|
||||
|
||||
const updated = await patchConfig(admin, {
|
||||
push_service_delivery: {
|
||||
relay_consent_accepted: true,
|
||||
relay_consent_accepted_at: '2020-01-01T00:00:00.000Z',
|
||||
relay_consent_accepted_by: '1500000000000000009',
|
||||
},
|
||||
}).execute();
|
||||
|
||||
expect(updated.push_service_delivery.relay_consent_accepted_at).not.toBe('2020-01-01T00:00:00.000Z');
|
||||
expect(updated.push_service_delivery.relay_consent_accepted_by).toBe(admin.userId);
|
||||
});
|
||||
|
||||
it('publishes the consent to the delivery services', async () => {
|
||||
const admin = await createAdmin();
|
||||
const publish = vi.mocked(PushServiceDeliveryConfigPublisher.prototype.publish);
|
||||
|
||||
await patchConfig(admin, {push_service_delivery: {relay_consent_accepted: true}}).execute();
|
||||
|
||||
expect(publish).toHaveBeenCalledWith(expect.objectContaining({relay_consent_accepted: true}));
|
||||
});
|
||||
});
|
||||
@@ -3,6 +3,8 @@
|
||||
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 {PasskeyBridgeController} from '@app/api/auth/PasskeyBridgeController';
|
||||
import {BlueskyOAuthController} from '@app/api/bluesky/BlueskyOAuthController';
|
||||
import {Config} from '@app/api/Config';
|
||||
import {ChannelController} from '@app/api/channel/ChannelController';
|
||||
@@ -46,6 +48,8 @@ export function registerControllers(routes: HonoApp, config: APIConfig): void {
|
||||
GeolocationController(routes);
|
||||
registerAdminControllers(routes);
|
||||
AuthController(routes);
|
||||
OriginHandoffController(routes);
|
||||
PasskeyBridgeController(routes);
|
||||
AttachmentController(routes);
|
||||
ChannelController(routes);
|
||||
ConnectionController(routes);
|
||||
|
||||
@@ -447,7 +447,7 @@ export function AuthController(app: HonoApp) {
|
||||
'Retrieve WebAuthn authentication challenge and options for passwordless login with biometrics or security keys.',
|
||||
}),
|
||||
async (ctx) => {
|
||||
return ctx.json(await ctx.get('authRequestService').getWebAuthnAuthenticationOptions());
|
||||
return ctx.json(await ctx.get('authRequestService').getWebAuthnAuthenticationOptions(ctx.req.header('origin')));
|
||||
},
|
||||
);
|
||||
app.post(
|
||||
@@ -490,7 +490,9 @@ export function AuthController(app: HonoApp) {
|
||||
'Retrieve WebAuthn challenge and options for multi-factor authentication. Requires the MFA ticket from initial login.',
|
||||
}),
|
||||
async (ctx) => {
|
||||
return ctx.json(await ctx.get('authRequestService').getWebAuthnMfaOptions(ctx.req.valid('json')));
|
||||
return ctx.json(
|
||||
await ctx.get('authRequestService').getWebAuthnMfaOptions(ctx.req.valid('json'), ctx.req.header('origin')),
|
||||
);
|
||||
},
|
||||
);
|
||||
app.post(
|
||||
@@ -602,6 +604,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);
|
||||
},
|
||||
|
||||
@@ -23,6 +23,7 @@ import type {InviteService} from '@app/api/invite/InviteService';
|
||||
import {Logger} from '@app/api/Logger';
|
||||
import {createRequestCache} from '@app/api/middleware/RequestCacheMiddleware';
|
||||
import {getInstanceConfigRepository} from '@app/api/middleware/ServiceSingletons';
|
||||
import type {AuthSession as AuthSessionModel} from '@app/api/models/AuthSession';
|
||||
import type {User} from '@app/api/models/User';
|
||||
import {lookupGeoip} from '@app/api/utils/IpUtils';
|
||||
import {createRateLimitError} from '@app/api/utils/RateLimitUtils';
|
||||
@@ -353,7 +354,7 @@ export async function login(
|
||||
const MFA_TICKET_MAX_ATTEMPTS = 5;
|
||||
const MFA_USER_MAX_ATTEMPTS = 10;
|
||||
|
||||
async function consumeMfaAttempt(
|
||||
export async function consumeMfaAttempt(
|
||||
ctx: ApiContext,
|
||||
{userId, ticket, field}: {userId: string; ticket: string; field: string},
|
||||
): Promise<void> {
|
||||
@@ -381,7 +382,7 @@ export async function loginMfaTotp(
|
||||
ctx: ApiContext,
|
||||
{code, ticket, request}: LoginMfaTotpParams,
|
||||
): Promise<LoginTokenResult> {
|
||||
const {users, cache, rateLimit} = ctx.services;
|
||||
const {users, cache} = ctx.services;
|
||||
const userId = await cache.get<string>(`mfa-ticket:${ticket}`);
|
||||
if (!userId) {
|
||||
throw InputValidationError.fromCode('ticket', ValidationErrorCodes.SESSION_TIMEOUT);
|
||||
@@ -405,21 +406,36 @@ export async function loginMfaTotp(
|
||||
if (!isValid) {
|
||||
throw InputValidationError.fromCode('code', ValidationErrorCodes.INVALID_CODE);
|
||||
}
|
||||
const [token] = await completeMfaLogin(ctx, user, ticket, request);
|
||||
return {user_id: user.id.toString(), token};
|
||||
}
|
||||
|
||||
export async function createLoginSession(
|
||||
ctx: ApiContext,
|
||||
user: User,
|
||||
request: Request,
|
||||
): Promise<[token: string, AuthSessionModel]> {
|
||||
return AuthSession.createAuthSession(ctx, {user, origin: AuthSession.resolveSessionOrigin(ctx, request)});
|
||||
}
|
||||
|
||||
export async function completeMfaLogin(
|
||||
ctx: ApiContext,
|
||||
user: User,
|
||||
ticket: string,
|
||||
request: Request,
|
||||
): Promise<[token: string, AuthSessionModel]> {
|
||||
const {cache, rateLimit} = ctx.services;
|
||||
await cache.delete(`mfa-ticket:${ticket}`);
|
||||
await rateLimit.resetLimit(`mfa:ticket:${ticket}`);
|
||||
await rateLimit.resetLimit(`mfa:user:${user.id}`);
|
||||
const [token] = await AuthSession.createAuthSession(ctx, {
|
||||
user,
|
||||
origin: AuthSession.resolveSessionOrigin(ctx, request),
|
||||
});
|
||||
return {user_id: user.id.toString(), token};
|
||||
return createLoginSession(ctx, user, request);
|
||||
}
|
||||
|
||||
export async function loginMfaWebAuthn(
|
||||
ctx: ApiContext,
|
||||
{response, challenge, ticket, request}: LoginMfaWebAuthnParams,
|
||||
): Promise<LoginTokenResult> {
|
||||
const {users, cache, rateLimit} = ctx.services;
|
||||
const {users, cache} = ctx.services;
|
||||
const userId = await cache.get<string>(`mfa-ticket:${ticket}`);
|
||||
if (!userId) {
|
||||
throw InputValidationError.fromCode('ticket', ValidationErrorCodes.SESSION_TIMEOUT);
|
||||
@@ -434,13 +450,7 @@ export async function loginMfaWebAuthn(
|
||||
}
|
||||
await consumeMfaAttempt(ctx, {userId: user.id.toString(), ticket, field: 'ticket'});
|
||||
await AuthMfa.verifyWebAuthnAuthentication(ctx, user.id, response, challenge, 'mfa', ticket);
|
||||
await cache.delete(`mfa-ticket:${ticket}`);
|
||||
await rateLimit.resetLimit(`mfa:ticket:${ticket}`);
|
||||
await rateLimit.resetLimit(`mfa:user:${user.id}`);
|
||||
const [token] = await AuthSession.createAuthSession(ctx, {
|
||||
user,
|
||||
origin: AuthSession.resolveSessionOrigin(ctx, request),
|
||||
});
|
||||
const [token] = await completeMfaLogin(ctx, user, ticket, request);
|
||||
return {user_id: user.id.toString(), token};
|
||||
}
|
||||
|
||||
|
||||
+269
-155
@@ -3,13 +3,20 @@
|
||||
import {timingSafeEqual} from 'node:crypto';
|
||||
import type {ApiContext} from '@app/api/ApiContext';
|
||||
import * as AuthUtility from '@app/api/auth/AuthUtility';
|
||||
import {
|
||||
type CredentialRpSelection,
|
||||
effectiveRpId,
|
||||
originRpId,
|
||||
selectCredentialRp,
|
||||
visibleWebAuthnCredentials,
|
||||
} from '@app/api/auth/services/PasskeyRelyingParty';
|
||||
import {deriveSudoMethods, userHasMfa, userHasSudoCapability} from '@app/api/auth/services/SudoMethods';
|
||||
import {createUserID, type UserID} from '@app/api/BrandedTypes';
|
||||
import {Logger} from '@app/api/Logger';
|
||||
import type {MfaBackupCode} from '@app/api/models/MfaBackupCode';
|
||||
import type {User} from '@app/api/models/User';
|
||||
import type {WebAuthnCredential} from '@app/api/models/WebAuthnCredential';
|
||||
import {mapUserToPrivateResponse} from '@app/api/user/UserMappers';
|
||||
import {mapUserToPrivateResponse, mapWebAuthnCredentialToResponse} from '@app/api/user/UserMappers';
|
||||
import {TotpGenerator} from '@app/api/utils/TotpGenerator';
|
||||
import {UserAuthenticatorTypes} from '@fluxer/constants/src/UserConstants';
|
||||
import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes';
|
||||
@@ -22,7 +29,12 @@ import {PasskeyAuthenticationFailedError} from '@fluxer/errors/src/domains/auth/
|
||||
import {UnknownWebAuthnCredentialError} from '@fluxer/errors/src/domains/auth/UnknownWebAuthnCredentialError';
|
||||
import {WebAuthnCredentialLimitReachedError} from '@fluxer/errors/src/domains/auth/WebAuthnCredentialLimitReachedError';
|
||||
import {InputValidationError} from '@fluxer/errors/src/domains/core/InputValidationError';
|
||||
import type {AuthenticationResponseJSON, RegistrationResponseJSON} from '@simplewebauthn/server';
|
||||
import type {
|
||||
AuthenticationResponseJSON,
|
||||
PublicKeyCredentialCreationOptionsJSON,
|
||||
PublicKeyCredentialRequestOptionsJSON,
|
||||
RegistrationResponseJSON,
|
||||
} from '@simplewebauthn/server';
|
||||
import {
|
||||
generateAuthenticationOptions,
|
||||
generateRegistrationOptions,
|
||||
@@ -33,7 +45,41 @@ import {
|
||||
} from '@simplewebauthn/server';
|
||||
import {ms, seconds} from 'itty-time';
|
||||
|
||||
type WebAuthnChallengeContext = 'registration' | 'discoverable' | 'mfa' | 'sudo';
|
||||
type WebAuthnChallengeContext = 'registration' | 'discoverable' | 'mfa' | 'sudo' | 'bridge' | 'migration_registration';
|
||||
|
||||
interface WebAuthnChallengeEntry {
|
||||
context: WebAuthnChallengeContext;
|
||||
userId?: string;
|
||||
ticket?: string;
|
||||
rpId?: string;
|
||||
credentialIds?: Array<string> | null;
|
||||
}
|
||||
|
||||
interface WebAuthnChallengeScope {
|
||||
rpId: string;
|
||||
credentialIds: Array<string> | null;
|
||||
}
|
||||
|
||||
interface WebAuthnAuthenticationOptionsParams {
|
||||
selection: CredentialRpSelection | {rpId: string; credentials: null};
|
||||
context: WebAuthnChallengeContext;
|
||||
userId?: UserID;
|
||||
ticket?: string;
|
||||
}
|
||||
|
||||
interface WebAuthnRegistrationOptionsParams {
|
||||
rpId: string;
|
||||
context: WebAuthnChallengeContext;
|
||||
excludeCredentials: Array<WebAuthnCredential>;
|
||||
}
|
||||
|
||||
interface VerifiedWebAuthnRegistration {
|
||||
credentialId: string;
|
||||
publicKey: Buffer;
|
||||
counter: bigint;
|
||||
transports: Set<string> | null;
|
||||
rpId: string;
|
||||
}
|
||||
|
||||
interface SudoMfaVerificationParams {
|
||||
userId: UserID;
|
||||
@@ -122,37 +168,117 @@ export async function verifyMfaCode(ctx: ApiContext, params: VerifyMfaCodeParams
|
||||
return false;
|
||||
}
|
||||
|
||||
export async function generateWebAuthnRegistrationOptions(ctx: ApiContext, userId: UserID) {
|
||||
function toCredentialDescriptor(credential: WebAuthnCredential) {
|
||||
return {
|
||||
id: credential.credentialId,
|
||||
transports: credential.transports
|
||||
? (Array.from(credential.transports) as Array<'usb' | 'nfc' | 'ble' | 'internal' | 'cable' | 'hybrid'>)
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export function storedRpId(ctx: ApiContext, rpId: string): string | null {
|
||||
return rpId === ctx.services.config.auth.passkeys.rpId ? null : rpId;
|
||||
}
|
||||
|
||||
export async function createWebAuthnRegistrationOptions(
|
||||
ctx: ApiContext,
|
||||
userId: UserID,
|
||||
{rpId, context, excludeCredentials}: WebAuthnRegistrationOptionsParams,
|
||||
): Promise<PublicKeyCredentialCreationOptionsJSON> {
|
||||
const {users, config} = ctx.services;
|
||||
const user = await users.findUniqueAssert(userId);
|
||||
const existingCredentials = await users.listWebAuthnCredentials(userId);
|
||||
if (existingCredentials.length >= 10) {
|
||||
throw new WebAuthnCredentialLimitReachedError();
|
||||
}
|
||||
const options = await generateRegistrationOptions({
|
||||
rpName: config.auth.passkeys.rpName,
|
||||
rpID: config.auth.passkeys.rpId,
|
||||
rpID: rpId,
|
||||
userID: new TextEncoder().encode(user.id.toString()),
|
||||
userName: user.username!,
|
||||
userDisplayName: user.username!,
|
||||
attestationType: 'none',
|
||||
supportedAlgorithmIDs: [-8, -7, -257],
|
||||
excludeCredentials: existingCredentials.map((cred) => ({
|
||||
id: cred.credentialId,
|
||||
transports: cred.transports
|
||||
? (Array.from(cred.transports) as Array<'usb' | 'nfc' | 'ble' | 'internal' | 'cable' | 'hybrid'>)
|
||||
: undefined,
|
||||
})),
|
||||
excludeCredentials: excludeCredentials.map(toCredentialDescriptor),
|
||||
authenticatorSelection: {
|
||||
residentKey: 'preferred',
|
||||
requireResidentKey: false,
|
||||
userVerification: 'preferred',
|
||||
},
|
||||
});
|
||||
await saveWebAuthnChallenge(ctx, options.challenge, {context: 'registration', userId});
|
||||
await saveWebAuthnChallenge(ctx, options.challenge, {context, userId, rpId, credentialIds: null});
|
||||
return options;
|
||||
}
|
||||
|
||||
export async function generateWebAuthnRegistrationOptions(
|
||||
ctx: ApiContext,
|
||||
userId: UserID,
|
||||
origin: string | null | undefined,
|
||||
): Promise<PublicKeyCredentialCreationOptionsJSON> {
|
||||
const existingCredentials = await ctx.services.users.listWebAuthnCredentials(userId);
|
||||
if (visibleWebAuthnCredentials(existingCredentials).length >= 10) {
|
||||
throw new WebAuthnCredentialLimitReachedError();
|
||||
}
|
||||
return createWebAuthnRegistrationOptions(ctx, userId, {
|
||||
rpId: originRpId(ctx, origin),
|
||||
context: 'registration',
|
||||
excludeCredentials: existingCredentials,
|
||||
});
|
||||
}
|
||||
|
||||
export async function verifyWebAuthnRegistrationResponse(
|
||||
ctx: ApiContext,
|
||||
userId: UserID,
|
||||
response: RegistrationResponseJSON,
|
||||
expectedChallenge: string,
|
||||
context: WebAuthnChallengeContext,
|
||||
expectedOrigin: Array<string> = ctx.services.config.auth.passkeys.allowedOrigins,
|
||||
): Promise<VerifiedWebAuthnRegistration> {
|
||||
const {config} = ctx.services;
|
||||
const {rpId} = await consumeWebAuthnChallenge(ctx, expectedChallenge, context, {userId});
|
||||
const responseObj = response as {id?: string; response?: {transports?: Array<string>}};
|
||||
const transports = responseObj.response?.transports ? new Set(responseObj.response.transports) : null;
|
||||
if (config.dev.testModeEnabled) {
|
||||
const credentialId = responseObj.id ?? `test-credential:${userId.toString()}:${Date.now()}`;
|
||||
return {credentialId, publicKey: Buffer.from(`test-public-key:${credentialId}`), counter: 0n, transports, rpId};
|
||||
}
|
||||
let verification: VerifiedRegistrationResponse;
|
||||
try {
|
||||
verification = await verifyRegistrationResponse({
|
||||
response,
|
||||
expectedChallenge,
|
||||
expectedOrigin,
|
||||
expectedRPID: rpId,
|
||||
requireUserVerification: false,
|
||||
supportedAlgorithmIDs: [-8, -7, -257],
|
||||
});
|
||||
} catch (error) {
|
||||
Logger.error({error, userId, expectedChallenge, rpId, expectedOrigin}, 'WebAuthn verification failed');
|
||||
throw new InvalidWebAuthnCredentialError();
|
||||
}
|
||||
if (!verification.verified || !verification.registrationInfo) {
|
||||
Logger.error(
|
||||
{userId, verified: verification.verified, hasRegistrationInfo: !!verification.registrationInfo},
|
||||
'WebAuthn verification result invalid',
|
||||
);
|
||||
throw new InvalidWebAuthnCredentialError();
|
||||
}
|
||||
const {credential} = verification.registrationInfo;
|
||||
let publicKeyBuffer: Buffer;
|
||||
let counterBigInt: bigint;
|
||||
try {
|
||||
publicKeyBuffer = Buffer.from(credential.publicKey);
|
||||
} catch (_error) {
|
||||
throw new InvalidWebAuthnPublicKeyFormatError();
|
||||
}
|
||||
try {
|
||||
if (credential.counter === undefined || credential.counter === null) {
|
||||
throw new Error('Counter value is undefined or null');
|
||||
}
|
||||
counterBigInt = BigInt(credential.counter);
|
||||
} catch (_error) {
|
||||
throw new InvalidWebAuthnCredentialCounterError();
|
||||
}
|
||||
return {credentialId: credential.id, publicKey: publicKeyBuffer, counter: counterBigInt, transports, rpId};
|
||||
}
|
||||
|
||||
export async function verifyWebAuthnRegistration(
|
||||
ctx: ApiContext,
|
||||
userId: UserID,
|
||||
@@ -160,85 +286,39 @@ export async function verifyWebAuthnRegistration(
|
||||
expectedChallenge: string,
|
||||
name: string,
|
||||
): Promise<void> {
|
||||
const {users, config} = ctx.services;
|
||||
const {users} = ctx.services;
|
||||
const existingCredentials = await users.listWebAuthnCredentials(userId);
|
||||
await consumeWebAuthnChallenge(ctx, expectedChallenge, 'registration', {userId});
|
||||
if (existingCredentials.length >= 10) {
|
||||
if (visibleWebAuthnCredentials(existingCredentials).length >= 10) {
|
||||
throw new WebAuthnCredentialLimitReachedError();
|
||||
}
|
||||
if (config.dev.testModeEnabled) {
|
||||
const responseObj = response as {id?: string; response?: {transports?: Array<string>}};
|
||||
const credentialId = responseObj.id ?? `test-credential:${userId.toString()}:${Date.now()}`;
|
||||
const publicKeyBuffer = Buffer.from(`test-public-key:${credentialId}`);
|
||||
await users.createWebAuthnCredential(
|
||||
userId,
|
||||
credentialId,
|
||||
publicKeyBuffer,
|
||||
0n,
|
||||
responseObj.response?.transports ? new Set(responseObj.response.transports) : null,
|
||||
name,
|
||||
);
|
||||
} else {
|
||||
const expectedOrigin = config.auth.passkeys.allowedOrigins;
|
||||
const rpID = config.auth.passkeys.rpId;
|
||||
let verification: VerifiedRegistrationResponse;
|
||||
try {
|
||||
verification = await verifyRegistrationResponse({
|
||||
response,
|
||||
expectedChallenge,
|
||||
expectedOrigin,
|
||||
expectedRPID: rpID,
|
||||
requireUserVerification: false,
|
||||
supportedAlgorithmIDs: [-8, -7, -257],
|
||||
});
|
||||
} catch (error) {
|
||||
Logger.error({error, userId, expectedChallenge, rpID, expectedOrigin}, 'WebAuthn verification failed');
|
||||
throw new InvalidWebAuthnCredentialError();
|
||||
}
|
||||
if (!verification.verified || !verification.registrationInfo) {
|
||||
Logger.error(
|
||||
{userId, verified: verification.verified, hasRegistrationInfo: !!verification.registrationInfo},
|
||||
'WebAuthn verification result invalid',
|
||||
);
|
||||
throw new InvalidWebAuthnCredentialError();
|
||||
}
|
||||
const {credential} = verification.registrationInfo;
|
||||
let publicKeyBuffer: Buffer;
|
||||
let counterBigInt: bigint;
|
||||
try {
|
||||
publicKeyBuffer = Buffer.from(credential.publicKey);
|
||||
} catch (_error) {
|
||||
throw new InvalidWebAuthnPublicKeyFormatError();
|
||||
}
|
||||
try {
|
||||
if (credential.counter === undefined || credential.counter === null) {
|
||||
throw new Error('Counter value is undefined or null');
|
||||
}
|
||||
counterBigInt = BigInt(credential.counter);
|
||||
} catch (_error) {
|
||||
throw new InvalidWebAuthnCredentialCounterError();
|
||||
}
|
||||
const responseObj = response as {response?: {transports?: Array<string>}};
|
||||
await users.createWebAuthnCredential(
|
||||
userId,
|
||||
credential.id,
|
||||
publicKeyBuffer,
|
||||
counterBigInt,
|
||||
responseObj.response?.transports ? new Set(responseObj.response.transports) : null,
|
||||
name,
|
||||
);
|
||||
}
|
||||
const verified = await verifyWebAuthnRegistrationResponse(ctx, userId, response, expectedChallenge, 'registration');
|
||||
await users.createWebAuthnCredential(
|
||||
userId,
|
||||
verified.credentialId,
|
||||
verified.publicKey,
|
||||
verified.counter,
|
||||
verified.transports,
|
||||
name,
|
||||
storedRpId(ctx, verified.rpId),
|
||||
);
|
||||
await dispatchWebAuthnCredentialsUpdate(ctx, userId);
|
||||
}
|
||||
|
||||
export async function deleteWebAuthnCredential(ctx: ApiContext, userId: UserID, credentialId: string): Promise<void> {
|
||||
const {users, gateway, botMfaMirror} = ctx.services;
|
||||
const credential = await users.getWebAuthnCredential(userId, credentialId);
|
||||
if (!credential) {
|
||||
if (!credential || credential.supersededBy !== null) {
|
||||
throw new UnknownWebAuthnCredentialError();
|
||||
}
|
||||
await users.deleteWebAuthnCredential(userId, credentialId);
|
||||
const remainingCredentials = await users.listWebAuthnCredentials(userId);
|
||||
const remaining = await users.listWebAuthnCredentials(userId);
|
||||
const remainingCredentials = visibleWebAuthnCredentials(remaining);
|
||||
const orphanedTwins = remaining.filter(
|
||||
(cred) => cred.supersededBy === credentialId || (cred.supersededBy !== null && remainingCredentials.length === 0),
|
||||
);
|
||||
for (const twin of orphanedTwins) {
|
||||
await users.deleteWebAuthnCredential(userId, twin.credentialId);
|
||||
}
|
||||
if (remainingCredentials.length === 0) {
|
||||
const user = await users.findUniqueAssert(userId);
|
||||
if (user.authenticatorTypes.has(UserAuthenticatorTypes.WEBAUTHN)) {
|
||||
@@ -298,37 +378,66 @@ export async function renameWebAuthnCredential(
|
||||
): Promise<void> {
|
||||
const {users} = ctx.services;
|
||||
const credential = await users.getWebAuthnCredential(userId, credentialId);
|
||||
if (!credential) {
|
||||
if (!credential || credential.supersededBy !== null) {
|
||||
throw new UnknownWebAuthnCredentialError();
|
||||
}
|
||||
await users.updateWebAuthnCredentialName(userId, credentialId, name);
|
||||
await dispatchWebAuthnCredentialsUpdate(ctx, userId);
|
||||
}
|
||||
|
||||
async function dispatchWebAuthnCredentialsUpdate(ctx: ApiContext, userId: UserID): Promise<void> {
|
||||
const {users, gateway} = ctx.services;
|
||||
export async function dispatchWebAuthnCredentialsUpdate(ctx: ApiContext, userId: UserID): Promise<void> {
|
||||
const {users, gateway, config} = ctx.services;
|
||||
const credentials = await users.listWebAuthnCredentials(userId);
|
||||
await gateway.dispatchPresence({
|
||||
userId,
|
||||
event: 'WEBAUTHN_CREDENTIALS_UPDATE',
|
||||
data: credentials.map((cred: WebAuthnCredential) => ({
|
||||
id: cred.credentialId,
|
||||
name: cred.name,
|
||||
created_at: cred.createdAt.toISOString(),
|
||||
last_used_at: cred.lastUsedAt?.toISOString() ?? null,
|
||||
})),
|
||||
data: visibleWebAuthnCredentials(credentials).map((cred) =>
|
||||
mapWebAuthnCredentialToResponse(cred, config.auth.passkeys.rpId),
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
export async function generateWebAuthnAuthenticationOptionsDiscoverable(ctx: ApiContext) {
|
||||
export async function generateWebAuthnAuthenticationOptions(
|
||||
ctx: ApiContext,
|
||||
{selection, context, userId, ticket}: WebAuthnAuthenticationOptionsParams,
|
||||
): Promise<PublicKeyCredentialRequestOptionsJSON> {
|
||||
const options = await generateAuthenticationOptions({
|
||||
rpID: ctx.services.config.auth.passkeys.rpId,
|
||||
userVerification: 'required',
|
||||
rpID: selection.rpId,
|
||||
allowCredentials: selection.credentials?.map(toCredentialDescriptor),
|
||||
userVerification: selection.credentials === null ? 'required' : 'discouraged',
|
||||
});
|
||||
await saveWebAuthnChallenge(ctx, options.challenge, {
|
||||
context,
|
||||
userId,
|
||||
ticket,
|
||||
rpId: selection.rpId,
|
||||
credentialIds: selection.credentials?.map((cred) => cred.credentialId) ?? null,
|
||||
});
|
||||
await saveWebAuthnChallenge(ctx, options.challenge, {context: 'discoverable'});
|
||||
return options;
|
||||
}
|
||||
|
||||
function selectCredentialRpOrThrow(
|
||||
ctx: ApiContext,
|
||||
origin: string | null | undefined,
|
||||
credentials: Array<WebAuthnCredential>,
|
||||
): CredentialRpSelection {
|
||||
const selection = selectCredentialRp(ctx, origin, credentials);
|
||||
if (selection.credentials.length === 0) {
|
||||
throw new NoPasskeysRegisteredError();
|
||||
}
|
||||
return selection;
|
||||
}
|
||||
|
||||
export async function generateWebAuthnAuthenticationOptionsDiscoverable(
|
||||
ctx: ApiContext,
|
||||
origin: string | null | undefined,
|
||||
): Promise<PublicKeyCredentialRequestOptionsJSON> {
|
||||
return generateWebAuthnAuthenticationOptions(ctx, {
|
||||
selection: {rpId: originRpId(ctx, origin), credentials: null},
|
||||
context: 'discoverable',
|
||||
});
|
||||
}
|
||||
|
||||
export async function verifyWebAuthnAuthenticationDiscoverable(
|
||||
ctx: ApiContext,
|
||||
response: AuthenticationResponseJSON,
|
||||
@@ -344,29 +453,24 @@ export async function verifyWebAuthnAuthenticationDiscoverable(
|
||||
return users.findUniqueAssert(userId);
|
||||
}
|
||||
|
||||
export async function generateWebAuthnAuthenticationOptionsForMfa(ctx: ApiContext, ticket: string) {
|
||||
const {users, cache, config} = ctx.services;
|
||||
export async function generateWebAuthnAuthenticationOptionsForMfa(
|
||||
ctx: ApiContext,
|
||||
ticket: string,
|
||||
origin: string | null | undefined,
|
||||
): Promise<PublicKeyCredentialRequestOptionsJSON> {
|
||||
const {users, cache} = ctx.services;
|
||||
const userIdStr = await cache.get<string>(`mfa-ticket:${ticket}`);
|
||||
if (!userIdStr) {
|
||||
throw InputValidationError.fromCode('ticket', ValidationErrorCodes.SESSION_TIMEOUT);
|
||||
}
|
||||
const userId = createUserID(BigInt(userIdStr));
|
||||
const credentials = await users.listWebAuthnCredentials(userId);
|
||||
if (credentials.length === 0) {
|
||||
throw new NoPasskeysRegisteredError();
|
||||
}
|
||||
const options = await generateAuthenticationOptions({
|
||||
rpID: config.auth.passkeys.rpId,
|
||||
allowCredentials: credentials.map((cred) => ({
|
||||
id: cred.credentialId,
|
||||
transports: cred.transports
|
||||
? (Array.from(cred.transports) as Array<'usb' | 'nfc' | 'ble' | 'internal' | 'cable' | 'hybrid'>)
|
||||
: undefined,
|
||||
})),
|
||||
userVerification: 'discouraged',
|
||||
return generateWebAuthnAuthenticationOptions(ctx, {
|
||||
selection: selectCredentialRpOrThrow(ctx, origin, credentials),
|
||||
context: 'mfa',
|
||||
userId,
|
||||
ticket,
|
||||
});
|
||||
await saveWebAuthnChallenge(ctx, options.challenge, {context: 'mfa', userId, ticket});
|
||||
return options;
|
||||
}
|
||||
|
||||
export async function verifyWebAuthnAuthentication(
|
||||
@@ -376,21 +480,26 @@ export async function verifyWebAuthnAuthentication(
|
||||
expectedChallenge: string,
|
||||
context: WebAuthnChallengeContext = 'mfa',
|
||||
ticket?: string,
|
||||
): Promise<void> {
|
||||
expectedOrigin: Array<string> = ctx.services.config.auth.passkeys.allowedOrigins,
|
||||
): Promise<WebAuthnCredential> {
|
||||
const {users, config} = ctx.services;
|
||||
await consumeWebAuthnChallenge(ctx, expectedChallenge, context, {userId, ticket});
|
||||
const scope = await consumeWebAuthnChallenge(ctx, expectedChallenge, context, {userId, ticket});
|
||||
const credentialId = (response as {id: string}).id;
|
||||
const credential = await users.getWebAuthnCredential(userId, credentialId);
|
||||
if (!credential) {
|
||||
throw new PasskeyAuthenticationFailedError();
|
||||
}
|
||||
if (
|
||||
effectiveRpId(ctx, credential) !== scope.rpId ||
|
||||
(scope.credentialIds !== null && !scope.credentialIds.includes(credentialId))
|
||||
) {
|
||||
throw new PasskeyAuthenticationFailedError();
|
||||
}
|
||||
if (config.dev.testModeEnabled) {
|
||||
await users.updateWebAuthnCredentialCounter(userId, credentialId, credential.counter + 1n);
|
||||
await users.updateWebAuthnCredentialLastUsed(userId, credentialId);
|
||||
return;
|
||||
return credential;
|
||||
}
|
||||
const expectedOrigin = config.auth.passkeys.allowedOrigins;
|
||||
const rpID = config.auth.passkeys.rpId;
|
||||
let verification: VerifiedAuthenticationResponse;
|
||||
try {
|
||||
let publicKeyUint8Array: Uint8Array<ArrayBuffer>;
|
||||
@@ -405,15 +514,12 @@ export async function verifyWebAuthnAuthentication(
|
||||
response,
|
||||
expectedChallenge,
|
||||
expectedOrigin,
|
||||
expectedRPID: rpID,
|
||||
requireUserVerification: requiresWebAuthnUserVerification(context),
|
||||
expectedRPID: scope.rpId,
|
||||
requireUserVerification: requiresWebAuthnUserVerification(context, scope),
|
||||
credential: {
|
||||
id: credential.credentialId,
|
||||
...toCredentialDescriptor(credential),
|
||||
publicKey: publicKeyUint8Array,
|
||||
counter: Number(credential.counter),
|
||||
transports: credential.transports
|
||||
? (Array.from(credential.transports) as Array<'usb' | 'nfc' | 'ble' | 'internal' | 'cable' | 'hybrid'>)
|
||||
: undefined,
|
||||
},
|
||||
});
|
||||
} catch (_error) {
|
||||
@@ -434,31 +540,25 @@ export async function verifyWebAuthnAuthentication(
|
||||
}
|
||||
await users.updateWebAuthnCredentialCounter(userId, credentialId, newCounter);
|
||||
await users.updateWebAuthnCredentialLastUsed(userId, credentialId);
|
||||
return credential;
|
||||
}
|
||||
|
||||
export async function generateWebAuthnOptionsForSudo(ctx: ApiContext, userId: UserID) {
|
||||
const {users, config} = ctx.services;
|
||||
const credentials = await users.listWebAuthnCredentials(userId);
|
||||
if (credentials.length === 0) {
|
||||
throw new NoPasskeysRegisteredError();
|
||||
}
|
||||
const options = await generateAuthenticationOptions({
|
||||
rpID: config.auth.passkeys.rpId,
|
||||
allowCredentials: credentials.map((cred) => ({
|
||||
id: cred.credentialId,
|
||||
transports: cred.transports
|
||||
? (Array.from(cred.transports) as Array<'usb' | 'nfc' | 'ble' | 'internal' | 'cable' | 'hybrid'>)
|
||||
: undefined,
|
||||
})),
|
||||
userVerification: 'discouraged',
|
||||
export async function generateWebAuthnOptionsForSudo(
|
||||
ctx: ApiContext,
|
||||
userId: UserID,
|
||||
origin: string | null | undefined,
|
||||
): Promise<PublicKeyCredentialRequestOptionsJSON> {
|
||||
const credentials = await ctx.services.users.listWebAuthnCredentials(userId);
|
||||
return generateWebAuthnAuthenticationOptions(ctx, {
|
||||
selection: selectCredentialRpOrThrow(ctx, origin, credentials),
|
||||
context: 'sudo',
|
||||
userId,
|
||||
});
|
||||
await saveWebAuthnChallenge(ctx, options.challenge, {context: 'sudo', userId});
|
||||
return options;
|
||||
}
|
||||
|
||||
const SUDO_MFA_USER_MAX_ATTEMPTS = 10;
|
||||
|
||||
async function consumeSudoMfaAttempt(ctx: ApiContext, userId: UserID): Promise<void> {
|
||||
export async function consumeSudoMfaAttempt(ctx: ApiContext, userId: UserID): Promise<void> {
|
||||
const {rateLimit} = ctx.services;
|
||||
const userLimit = await rateLimit.checkLimit({
|
||||
identifier: `sudo-mfa:user:${userId}`,
|
||||
@@ -535,20 +635,33 @@ function webAuthnChallengeCacheKey(challenge: string): string {
|
||||
return `webauthn:challenge:${challenge}`;
|
||||
}
|
||||
|
||||
function requiresWebAuthnUserVerification(context: WebAuthnChallengeContext): boolean {
|
||||
return context === 'discoverable';
|
||||
function requiresWebAuthnUserVerification(context: WebAuthnChallengeContext, scope: WebAuthnChallengeScope): boolean {
|
||||
return context === 'discoverable' || (context === 'bridge' && scope.credentialIds === null);
|
||||
}
|
||||
|
||||
async function saveWebAuthnChallenge(
|
||||
ctx: ApiContext,
|
||||
challenge: string,
|
||||
entry: {context: WebAuthnChallengeContext; userId?: UserID; ticket?: string},
|
||||
entry: {
|
||||
context: WebAuthnChallengeContext;
|
||||
userId?: UserID;
|
||||
ticket?: string;
|
||||
rpId: string;
|
||||
credentialIds: Array<string> | null;
|
||||
},
|
||||
): Promise<void> {
|
||||
await ctx.services.cache.set(
|
||||
webAuthnChallengeCacheKey(challenge),
|
||||
{context: entry.context, userId: entry.userId?.toString(), ticket: entry.ticket},
|
||||
seconds('5 minutes'),
|
||||
);
|
||||
const value: WebAuthnChallengeEntry = {
|
||||
context: entry.context,
|
||||
userId: entry.userId?.toString(),
|
||||
ticket: entry.ticket,
|
||||
rpId: entry.rpId,
|
||||
credentialIds: entry.credentialIds,
|
||||
};
|
||||
await ctx.services.cache.set(webAuthnChallengeCacheKey(challenge), value, seconds('5 minutes'));
|
||||
}
|
||||
|
||||
export async function deleteWebAuthnChallenge(ctx: ApiContext, challenge: string): Promise<void> {
|
||||
await ctx.services.cache.delete(webAuthnChallengeCacheKey(challenge));
|
||||
}
|
||||
|
||||
async function consumeWebAuthnChallenge(
|
||||
@@ -556,10 +669,8 @@ async function consumeWebAuthnChallenge(
|
||||
challenge: string,
|
||||
expectedContext: WebAuthnChallengeContext,
|
||||
{userId, ticket}: {userId?: UserID; ticket?: string} = {},
|
||||
): Promise<void> {
|
||||
const {cache} = ctx.services;
|
||||
const key = webAuthnChallengeCacheKey(challenge);
|
||||
const cached = await cache.get<{context: WebAuthnChallengeContext; userId?: string; ticket?: string}>(key);
|
||||
): Promise<WebAuthnChallengeScope> {
|
||||
const cached = await ctx.services.cache.getAndDelete<WebAuthnChallengeEntry>(webAuthnChallengeCacheKey(challenge));
|
||||
const challengeMatches =
|
||||
cached &&
|
||||
cached.context === expectedContext &&
|
||||
@@ -581,11 +692,14 @@ async function consumeWebAuthnChallenge(
|
||||
);
|
||||
throw createChallengeError(expectedContext);
|
||||
}
|
||||
await cache.delete(key);
|
||||
return {
|
||||
rpId: cached.rpId ?? ctx.services.config.auth.passkeys.rpId,
|
||||
credentialIds: cached.credentialIds ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
function createChallengeError(context: WebAuthnChallengeContext) {
|
||||
if (context === 'registration') {
|
||||
if (context === 'registration' || context === 'migration_registration') {
|
||||
return new InvalidWebAuthnCredentialError();
|
||||
}
|
||||
return new PasskeyAuthenticationFailedError();
|
||||
|
||||
@@ -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 {
|
||||
@@ -272,21 +280,18 @@ export class AuthRequestService {
|
||||
return {completed: false};
|
||||
}
|
||||
|
||||
async getWebAuthnAuthenticationOptions() {
|
||||
return AuthMfa.generateWebAuthnAuthenticationOptionsDiscoverable(this.apiContext);
|
||||
async getWebAuthnAuthenticationOptions(origin: string | undefined) {
|
||||
return AuthMfa.generateWebAuthnAuthenticationOptionsDiscoverable(this.apiContext, origin);
|
||||
}
|
||||
|
||||
async authenticateWebAuthnDiscoverable({data, request}: AuthWebAuthnAuthenticateRequest) {
|
||||
const user = await AuthMfa.verifyWebAuthnAuthenticationDiscoverable(this.apiContext, data.response, data.challenge);
|
||||
const [token] = await AuthSession.createAuthSession(this.apiContext, {
|
||||
user,
|
||||
origin: AuthSession.resolveSessionOrigin(this.apiContext, request),
|
||||
});
|
||||
const [token] = await AuthLogin.createLoginSession(this.apiContext, user, request);
|
||||
return {token, user_id: user.id.toString(), user: mapUserToPartialResponse(user)};
|
||||
}
|
||||
|
||||
async getWebAuthnMfaOptions({ticket}: MfaTicketRequest) {
|
||||
return AuthMfa.generateWebAuthnAuthenticationOptionsForMfa(this.apiContext, ticket);
|
||||
async getWebAuthnMfaOptions({ticket}: MfaTicketRequest, origin: string | undefined) {
|
||||
return AuthMfa.generateWebAuthnAuthenticationOptionsForMfa(this.apiContext, ticket, origin);
|
||||
}
|
||||
|
||||
async loginMfaWebAuthn({data, request}: AuthWebAuthnMfaRequest): Promise<AuthTokenWithUserIdResponse> {
|
||||
@@ -305,7 +310,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 +348,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);
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {
|
||||
cancelPasskeyBridge,
|
||||
completePasskeyBridge,
|
||||
getPasskeyBridgeOptions,
|
||||
redeemPasskeyBridgeLogin,
|
||||
redeemPasskeyBridgeSudo,
|
||||
startPasskeyBridgeLogin,
|
||||
startPasskeyBridgeSudo,
|
||||
} from '@app/api/auth/services/PasskeyBridgeService';
|
||||
import {DefaultUserOnly, LoginRequired} from '@app/api/middleware/AuthMiddleware';
|
||||
import {LocalAuthMiddleware} from '@app/api/middleware/LocalAuthMiddleware';
|
||||
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 {
|
||||
PasskeyBridgeCeremonyIdParam,
|
||||
PasskeyBridgeCompleteRequest,
|
||||
PasskeyBridgeFinishResponse,
|
||||
PasskeyBridgeLoginRedeemResponse,
|
||||
PasskeyBridgeLoginStartRequest,
|
||||
PasskeyBridgeOptionsResponse,
|
||||
PasskeyBridgeRedeemRequest,
|
||||
PasskeyBridgeStartResponse,
|
||||
PasskeyBridgeSudoRedeemResponse,
|
||||
PasskeyBridgeSudoStartRequest,
|
||||
} from '@fluxer/schema/src/domains/auth/PasskeyBridgeSchemas';
|
||||
|
||||
export function PasskeyBridgeController(app: HonoApp) {
|
||||
app.post(
|
||||
'/auth/passkey-bridge',
|
||||
LocalAuthMiddleware,
|
||||
RateLimitMiddleware(RateLimitConfigs.AUTH_PASSKEY_BRIDGE_START),
|
||||
Validator('json', PasskeyBridgeLoginStartRequest),
|
||||
OpenAPI({
|
||||
operationId: 'start_passkey_bridge_login',
|
||||
summary: 'Start passkey bridge sign in',
|
||||
responseSchema: PasskeyBridgeStartResponse,
|
||||
statusCode: 200,
|
||||
security: [],
|
||||
tags: ['Auth'],
|
||||
description:
|
||||
'Start a sign in or two-factor ceremony for a passkey that belongs to the paired first-party origin. Only available on the official instance from the new origin.',
|
||||
}),
|
||||
async (ctx) => {
|
||||
return ctx.json(
|
||||
await startPasskeyBridgeLogin(ctx.get('apiContext'), ctx.req.header('origin'), ctx.req.valid('json')),
|
||||
);
|
||||
},
|
||||
);
|
||||
app.post(
|
||||
'/users/@me/passkey-bridge',
|
||||
RateLimitMiddleware(RateLimitConfigs.USER_PASSKEY_BRIDGE_START),
|
||||
LoginRequired,
|
||||
DefaultUserOnly,
|
||||
Validator('json', PasskeyBridgeSudoStartRequest),
|
||||
OpenAPI({
|
||||
operationId: 'start_passkey_bridge_sudo',
|
||||
summary: 'Start passkey bridge sudo verification',
|
||||
responseSchema: PasskeyBridgeStartResponse,
|
||||
statusCode: 200,
|
||||
security: ['bearerToken', 'sessionToken'],
|
||||
tags: ['Users'],
|
||||
description:
|
||||
'Start a sudo verification ceremony for a passkey that belongs to the paired first-party origin. Only available on the official instance from the new origin.',
|
||||
}),
|
||||
async (ctx) => {
|
||||
return ctx.json(
|
||||
await startPasskeyBridgeSudo(
|
||||
ctx.get('apiContext'),
|
||||
ctx.req.header('origin'),
|
||||
ctx.get('user').id,
|
||||
ctx.req.valid('json'),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
app.post(
|
||||
'/auth/passkey-bridge/:ceremony_id/options',
|
||||
RateLimitMiddleware(RateLimitConfigs.AUTH_PASSKEY_BRIDGE_CEREMONY),
|
||||
Validator('param', PasskeyBridgeCeremonyIdParam),
|
||||
OpenAPI({
|
||||
operationId: 'get_passkey_bridge_options',
|
||||
summary: 'Get passkey bridge options',
|
||||
responseSchema: PasskeyBridgeOptionsResponse,
|
||||
statusCode: 200,
|
||||
security: [],
|
||||
tags: ['Auth'],
|
||||
description:
|
||||
'Issue WebAuthn authentication options for a pending passkey bridge ceremony. The request must come from the origin that runs the ceremony.',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const {ceremony_id} = ctx.req.valid('param');
|
||||
return ctx.json(await getPasskeyBridgeOptions(ctx.get('apiContext'), ceremony_id, ctx.req.header('origin')));
|
||||
},
|
||||
);
|
||||
app.post(
|
||||
'/auth/passkey-bridge/:ceremony_id/complete',
|
||||
RateLimitMiddleware(RateLimitConfigs.AUTH_PASSKEY_BRIDGE_CEREMONY),
|
||||
Validator('param', PasskeyBridgeCeremonyIdParam),
|
||||
Validator('json', PasskeyBridgeCompleteRequest),
|
||||
OpenAPI({
|
||||
operationId: 'complete_passkey_bridge',
|
||||
summary: 'Complete passkey bridge',
|
||||
responseSchema: PasskeyBridgeFinishResponse,
|
||||
statusCode: 200,
|
||||
security: [],
|
||||
tags: ['Auth'],
|
||||
description:
|
||||
'Verify the WebAuthn response for a pending passkey bridge ceremony. A failed verification leaves the ceremony pending so it can be retried.',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const {ceremony_id} = ctx.req.valid('param');
|
||||
return ctx.json(
|
||||
await completePasskeyBridge(
|
||||
ctx.get('apiContext'),
|
||||
ceremony_id,
|
||||
ctx.req.header('origin'),
|
||||
ctx.req.valid('json'),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
app.post(
|
||||
'/auth/passkey-bridge/:ceremony_id/cancel',
|
||||
RateLimitMiddleware(RateLimitConfigs.AUTH_PASSKEY_BRIDGE_CEREMONY),
|
||||
Validator('param', PasskeyBridgeCeremonyIdParam),
|
||||
OpenAPI({
|
||||
operationId: 'cancel_passkey_bridge',
|
||||
summary: 'Cancel passkey bridge',
|
||||
responseSchema: PasskeyBridgeFinishResponse,
|
||||
statusCode: 200,
|
||||
security: [],
|
||||
tags: ['Auth'],
|
||||
description: 'Cancel a passkey bridge ceremony that has not completed.',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const {ceremony_id} = ctx.req.valid('param');
|
||||
return ctx.json(await cancelPasskeyBridge(ctx.get('apiContext'), ceremony_id, ctx.req.header('origin')));
|
||||
},
|
||||
);
|
||||
app.post(
|
||||
'/auth/passkey-bridge/:ceremony_id/redeem',
|
||||
LocalAuthMiddleware,
|
||||
RateLimitMiddleware(RateLimitConfigs.AUTH_PASSKEY_BRIDGE_REDEEM),
|
||||
Validator('param', PasskeyBridgeCeremonyIdParam),
|
||||
Validator('json', PasskeyBridgeRedeemRequest),
|
||||
OpenAPI({
|
||||
operationId: 'redeem_passkey_bridge_login',
|
||||
summary: 'Redeem passkey bridge sign in',
|
||||
responseSchema: PasskeyBridgeLoginRedeemResponse,
|
||||
statusCode: 200,
|
||||
security: [],
|
||||
tags: ['Auth'],
|
||||
description:
|
||||
'Redeem a finished sign in or two-factor passkey bridge ceremony once. Requires the nonce kept by the starting page and the completion code handed back when the ceremony finished.',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const {ceremony_id} = ctx.req.valid('param');
|
||||
return ctx.json(
|
||||
await redeemPasskeyBridgeLogin(
|
||||
ctx.get('apiContext'),
|
||||
ceremony_id,
|
||||
ctx.req.header('origin'),
|
||||
ctx.req.valid('json'),
|
||||
ctx.req.raw,
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
app.post(
|
||||
'/users/@me/passkey-bridge/:ceremony_id/redeem',
|
||||
RateLimitMiddleware(RateLimitConfigs.USER_PASSKEY_BRIDGE_REDEEM),
|
||||
LoginRequired,
|
||||
DefaultUserOnly,
|
||||
Validator('param', PasskeyBridgeCeremonyIdParam),
|
||||
Validator('json', PasskeyBridgeRedeemRequest),
|
||||
OpenAPI({
|
||||
operationId: 'redeem_passkey_bridge_sudo',
|
||||
summary: 'Redeem passkey bridge sudo verification',
|
||||
responseSchema: PasskeyBridgeSudoRedeemResponse,
|
||||
statusCode: 200,
|
||||
security: ['bearerToken', 'sessionToken'],
|
||||
tags: ['Users'],
|
||||
description:
|
||||
'Redeem a finished sudo passkey bridge ceremony once for a sudo mode token. Requires the nonce kept by the starting page and the completion code handed back when the ceremony finished.',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const {ceremony_id} = ctx.req.valid('param');
|
||||
return ctx.json(
|
||||
await redeemPasskeyBridgeSudo(
|
||||
ctx.get('apiContext'),
|
||||
ceremony_id,
|
||||
ctx.req.header('origin'),
|
||||
ctx.req.valid('json'),
|
||||
ctx.get('user').id,
|
||||
ctx.get('authSession'),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -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,436 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {createHash, randomBytes, timingSafeEqual} from 'node:crypto';
|
||||
import type {ApiContext} from '@app/api/ApiContext';
|
||||
import * as AuthLogin from '@app/api/auth/AuthLogin';
|
||||
import * as AuthMfa from '@app/api/auth/AuthMfa';
|
||||
import * as AuthUtility from '@app/api/auth/AuthUtility';
|
||||
import {recordPendingPasskeyMigration} from '@app/api/auth/services/PasskeyMigrationService';
|
||||
import {
|
||||
effectiveRpId,
|
||||
isPasskeyMigrationActive,
|
||||
isPasskeyTargetOrigin,
|
||||
passkeyLegacyOriginFor,
|
||||
visibleWebAuthnCredentials,
|
||||
} from '@app/api/auth/services/PasskeyRelyingParty';
|
||||
import {getSudoModeService} from '@app/api/auth/services/SudoModeService';
|
||||
import {resolveWebAuthnSecondFactor} from '@app/api/auth/services/WebAuthnSecondFactor';
|
||||
import {createUserID, type UserID} from '@app/api/BrandedTypes';
|
||||
import type {AuthSession} from '@app/api/models/AuthSession';
|
||||
import type {User} from '@app/api/models/User';
|
||||
import type {WebAuthnCredential} from '@app/api/models/WebAuthnCredential';
|
||||
import {mapUserToPartialResponse} from '@app/api/user/UserMappers';
|
||||
import {PASSKEY_BRIDGE_PATH, PASSKEY_BRIDGE_RETURN_FRAGMENT_KEY} from '@fluxer/constants/src/PasskeyConstants';
|
||||
import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes';
|
||||
import {InvalidPasskeyBridgeNonceError} from '@fluxer/errors/src/domains/auth/InvalidPasskeyBridgeNonceError';
|
||||
import {MfaNotEnabledError} from '@fluxer/errors/src/domains/auth/MfaNotEnabledError';
|
||||
import {NoPasskeysRegisteredError} from '@fluxer/errors/src/domains/auth/NoPasskeysRegisteredError';
|
||||
import {PasskeyAuthenticationFailedError} from '@fluxer/errors/src/domains/auth/PasskeyAuthenticationFailedError';
|
||||
import {UnknownPasskeyBridgeError} from '@fluxer/errors/src/domains/auth/UnknownPasskeyBridgeError';
|
||||
import {InputValidationError} from '@fluxer/errors/src/domains/core/InputValidationError';
|
||||
import {InvalidApiOriginError} from '@fluxer/errors/src/domains/core/InvalidApiOriginError';
|
||||
import type {
|
||||
PasskeyBridgeCompleteRequest,
|
||||
PasskeyBridgeFinishResponse,
|
||||
PasskeyBridgeLoginRedeemResponse,
|
||||
PasskeyBridgeLoginStartRequest,
|
||||
PasskeyBridgeRedeemRequest,
|
||||
PasskeyBridgeRunner,
|
||||
PasskeyBridgeStartResponse,
|
||||
PasskeyBridgeSudoRedeemResponse,
|
||||
PasskeyBridgeSudoStartRequest,
|
||||
} from '@fluxer/schema/src/domains/auth/PasskeyBridgeSchemas';
|
||||
import type {PublicKeyCredentialRequestOptionsJSON} from '@simplewebauthn/server';
|
||||
import {ms, seconds} from 'itty-time';
|
||||
|
||||
type PasskeyBridgePurpose = 'login' | 'login_mfa' | 'sudo';
|
||||
|
||||
interface PasskeyBridgeRecord {
|
||||
purpose: PasskeyBridgePurpose;
|
||||
runner: PasskeyBridgeRunner;
|
||||
target_origin: string;
|
||||
ceremony_origin: string;
|
||||
nonce_hash: string;
|
||||
user_id: string | null;
|
||||
ticket: string | null;
|
||||
challenge: string | null;
|
||||
credential_id: string | null;
|
||||
cross_device: boolean;
|
||||
completion_code_hash: string | null;
|
||||
status: 'pending' | 'completed' | 'cancelled';
|
||||
created_at: number;
|
||||
expires_at: number;
|
||||
}
|
||||
|
||||
interface CompletedPasskeyBridge {
|
||||
record: PasskeyBridgeRecord;
|
||||
userId: UserID;
|
||||
}
|
||||
|
||||
const PASSKEY_BRIDGE_KEY_PREFIX = 'passkey_bridge:';
|
||||
const PASSKEY_BRIDGE_LOCK_PREFIX = 'passkey_bridge_lock:';
|
||||
const PASSKEY_BRIDGE_SECRET_BYTES = 32;
|
||||
|
||||
function sha256Hex(value: string): string {
|
||||
return createHash('sha256').update(value).digest('hex');
|
||||
}
|
||||
|
||||
function hashMatches(value: string, storedHash: string | null): boolean {
|
||||
if (storedHash === null) return false;
|
||||
const presented = Buffer.from(sha256Hex(value), 'hex');
|
||||
const stored = Buffer.from(storedHash, 'hex');
|
||||
return presented.length === stored.length && timingSafeEqual(presented, stored);
|
||||
}
|
||||
|
||||
function createSecret(): string {
|
||||
return randomBytes(PASSKEY_BRIDGE_SECRET_BYTES).toString('base64url');
|
||||
}
|
||||
|
||||
function passkeyBridgeKey(ceremonyId: string): string {
|
||||
return `${PASSKEY_BRIDGE_KEY_PREFIX}${sha256Hex(ceremonyId)}`;
|
||||
}
|
||||
|
||||
async function writeRecord(ctx: ApiContext, ceremonyId: string, record: PasskeyBridgeRecord): Promise<void> {
|
||||
const ttlSeconds = Math.floor((record.expires_at - Date.now()) / 1000);
|
||||
if (ttlSeconds <= 0) {
|
||||
throw new UnknownPasskeyBridgeError();
|
||||
}
|
||||
await ctx.services.cache.set(passkeyBridgeKey(ceremonyId), record, ttlSeconds);
|
||||
}
|
||||
|
||||
function assertCeremonyOrigin(
|
||||
ctx: ApiContext,
|
||||
record: PasskeyBridgeRecord,
|
||||
origin: string | undefined,
|
||||
expectedOrigin: string,
|
||||
): void {
|
||||
if (origin !== expectedOrigin || !isPasskeyTargetOrigin(ctx, record.target_origin)) {
|
||||
throw new InvalidApiOriginError();
|
||||
}
|
||||
}
|
||||
|
||||
async function mutateRecord<T>(
|
||||
ctx: ApiContext,
|
||||
ceremonyId: string,
|
||||
origin: string | undefined,
|
||||
mutate: (record: PasskeyBridgeRecord) => Promise<T>,
|
||||
): Promise<T> {
|
||||
const {cache} = ctx.services;
|
||||
const lockKey = `${PASSKEY_BRIDGE_LOCK_PREFIX}${sha256Hex(ceremonyId)}`;
|
||||
const lockToken = await cache.acquireLock(lockKey, seconds('10 seconds'));
|
||||
if (!lockToken) {
|
||||
throw new UnknownPasskeyBridgeError();
|
||||
}
|
||||
try {
|
||||
const record = await cache.get<PasskeyBridgeRecord>(passkeyBridgeKey(ceremonyId));
|
||||
if (!record) {
|
||||
throw new UnknownPasskeyBridgeError();
|
||||
}
|
||||
assertCeremonyOrigin(ctx, record, origin, record.ceremony_origin);
|
||||
return await mutate(record);
|
||||
} finally {
|
||||
await cache.releaseLock(lockKey, lockToken);
|
||||
}
|
||||
}
|
||||
|
||||
async function requireMfaTicketUser(ctx: ApiContext, ticket: string, expectedUserId?: string): Promise<User> {
|
||||
const userId = await ctx.services.cache.get<string>(`mfa-ticket:${ticket}`);
|
||||
if (!userId || (expectedUserId !== undefined && userId !== expectedUserId)) {
|
||||
throw InputValidationError.fromCode('ticket', ValidationErrorCodes.SESSION_TIMEOUT);
|
||||
}
|
||||
const user = await ctx.services.users.findUniqueAssert(createUserID(BigInt(userId)));
|
||||
AuthUtility.assertNonBotUser(ctx, user);
|
||||
return user;
|
||||
}
|
||||
|
||||
async function requireLegacyCredentials(ctx: ApiContext, userId: UserID): Promise<Array<WebAuthnCredential>> {
|
||||
const legacyRpId = ctx.services.config.auth.passkeys.rpId;
|
||||
const credentials = visibleWebAuthnCredentials(await ctx.services.users.listWebAuthnCredentials(userId)).filter(
|
||||
(credential) => effectiveRpId(ctx, credential) === legacyRpId,
|
||||
);
|
||||
if (credentials.length === 0) {
|
||||
throw new NoPasskeysRegisteredError();
|
||||
}
|
||||
return credentials;
|
||||
}
|
||||
|
||||
function assertBridgeStartOrigin(ctx: ApiContext, origin: string | undefined): string {
|
||||
if (!origin || !isPasskeyTargetOrigin(ctx, origin)) {
|
||||
throw new InvalidApiOriginError();
|
||||
}
|
||||
return origin;
|
||||
}
|
||||
|
||||
async function startPasskeyBridge(
|
||||
ctx: ApiContext,
|
||||
origin: string,
|
||||
fields: Pick<PasskeyBridgeRecord, 'purpose' | 'runner' | 'nonce_hash' | 'user_id' | 'ticket'>,
|
||||
): Promise<PasskeyBridgeStartResponse> {
|
||||
const ceremonyId = createSecret();
|
||||
const createdAt = Date.now();
|
||||
const ceremonyOrigin = fields.runner === 'page' ? passkeyLegacyOriginFor(origin) : origin;
|
||||
await writeRecord(ctx, ceremonyId, {
|
||||
...fields,
|
||||
target_origin: origin,
|
||||
ceremony_origin: ceremonyOrigin,
|
||||
challenge: null,
|
||||
credential_id: null,
|
||||
cross_device: false,
|
||||
completion_code_hash: null,
|
||||
status: 'pending',
|
||||
created_at: createdAt,
|
||||
expires_at: createdAt + (fields.purpose === 'login_mfa' ? ms('5 minutes') : ms('10 minutes')),
|
||||
});
|
||||
return {
|
||||
ceremony_id: ceremonyId,
|
||||
bridge_url: fields.runner === 'page' ? `${ceremonyOrigin}${PASSKEY_BRIDGE_PATH}#${ceremonyId}` : null,
|
||||
};
|
||||
}
|
||||
|
||||
export async function startPasskeyBridgeLogin(
|
||||
ctx: ApiContext,
|
||||
origin: string | undefined,
|
||||
data: PasskeyBridgeLoginStartRequest,
|
||||
): Promise<PasskeyBridgeStartResponse> {
|
||||
const targetOrigin = assertBridgeStartOrigin(ctx, origin);
|
||||
let userId: string | null = null;
|
||||
if (data.purpose === 'login_mfa') {
|
||||
const user = await requireMfaTicketUser(ctx, data.ticket!);
|
||||
if (!(await resolveWebAuthnSecondFactor(ctx, user))) {
|
||||
throw new MfaNotEnabledError();
|
||||
}
|
||||
await requireLegacyCredentials(ctx, user.id);
|
||||
userId = user.id.toString();
|
||||
}
|
||||
return startPasskeyBridge(ctx, targetOrigin, {
|
||||
purpose: data.purpose,
|
||||
runner: data.runner,
|
||||
nonce_hash: data.nonce_hash,
|
||||
user_id: userId,
|
||||
ticket: data.ticket ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
export async function startPasskeyBridgeSudo(
|
||||
ctx: ApiContext,
|
||||
origin: string | undefined,
|
||||
userId: UserID,
|
||||
data: PasskeyBridgeSudoStartRequest,
|
||||
): Promise<PasskeyBridgeStartResponse> {
|
||||
const targetOrigin = assertBridgeStartOrigin(ctx, origin);
|
||||
await requireLegacyCredentials(ctx, userId);
|
||||
return startPasskeyBridge(ctx, targetOrigin, {
|
||||
purpose: 'sudo',
|
||||
runner: data.runner,
|
||||
nonce_hash: data.nonce_hash,
|
||||
user_id: userId.toString(),
|
||||
ticket: null,
|
||||
});
|
||||
}
|
||||
|
||||
export async function getPasskeyBridgeOptions(
|
||||
ctx: ApiContext,
|
||||
ceremonyId: string,
|
||||
origin: string | undefined,
|
||||
): Promise<{options: PublicKeyCredentialRequestOptionsJSON}> {
|
||||
return mutateRecord(ctx, ceremonyId, origin, async (record) => {
|
||||
if (record.status !== 'pending') {
|
||||
throw new UnknownPasskeyBridgeError();
|
||||
}
|
||||
const legacyRpId = ctx.services.config.auth.passkeys.rpId;
|
||||
const userId = record.user_id === null ? undefined : createUserID(BigInt(record.user_id));
|
||||
const options = await AuthMfa.generateWebAuthnAuthenticationOptions(ctx, {
|
||||
selection: {
|
||||
rpId: legacyRpId,
|
||||
credentials: userId === undefined ? null : await requireLegacyCredentials(ctx, userId),
|
||||
},
|
||||
context: 'bridge',
|
||||
userId,
|
||||
});
|
||||
if (record.challenge !== null) {
|
||||
await AuthMfa.deleteWebAuthnChallenge(ctx, record.challenge);
|
||||
}
|
||||
await writeRecord(ctx, ceremonyId, {...record, challenge: options.challenge});
|
||||
return {options};
|
||||
});
|
||||
}
|
||||
|
||||
function buildReturnUrl(record: PasskeyBridgeRecord, ceremonyId: string, completionCode: string): string {
|
||||
return `${record.target_origin}${PASSKEY_BRIDGE_PATH}#${PASSKEY_BRIDGE_RETURN_FRAGMENT_KEY}=${ceremonyId}.${completionCode}`;
|
||||
}
|
||||
|
||||
async function finishRecord(
|
||||
ctx: ApiContext,
|
||||
ceremonyId: string,
|
||||
record: PasskeyBridgeRecord,
|
||||
): Promise<PasskeyBridgeFinishResponse> {
|
||||
const completionCode = createSecret();
|
||||
await writeRecord(ctx, ceremonyId, {...record, completion_code_hash: sha256Hex(completionCode)});
|
||||
if (record.runner === 'native') {
|
||||
return {return_url: null, completion_code: completionCode};
|
||||
}
|
||||
return {return_url: buildReturnUrl(record, ceremonyId, completionCode), completion_code: null};
|
||||
}
|
||||
|
||||
export async function completePasskeyBridge(
|
||||
ctx: ApiContext,
|
||||
ceremonyId: string,
|
||||
origin: string | undefined,
|
||||
data: PasskeyBridgeCompleteRequest,
|
||||
): Promise<PasskeyBridgeFinishResponse> {
|
||||
return mutateRecord(ctx, ceremonyId, origin, async (record) => {
|
||||
if (record.status !== 'pending') {
|
||||
throw new UnknownPasskeyBridgeError();
|
||||
}
|
||||
const {users} = ctx.services;
|
||||
const credentialId = data.response.id;
|
||||
const userId =
|
||||
record.user_id === null
|
||||
? await users.getUserIdByCredentialId(credentialId)
|
||||
: createUserID(BigInt(record.user_id));
|
||||
const credential = userId === null ? null : await users.getWebAuthnCredential(userId, credentialId);
|
||||
if (
|
||||
userId === null ||
|
||||
record.challenge === null ||
|
||||
credential === null ||
|
||||
credential.supersededBy !== null ||
|
||||
effectiveRpId(ctx, credential) !== ctx.services.config.auth.passkeys.rpId
|
||||
) {
|
||||
throw new PasskeyAuthenticationFailedError();
|
||||
}
|
||||
if (record.purpose === 'login_mfa') {
|
||||
await requireMfaTicketUser(ctx, record.ticket!, record.user_id!);
|
||||
await AuthLogin.consumeMfaAttempt(ctx, {userId: record.user_id!, ticket: record.ticket!, field: 'ticket'});
|
||||
} else if (record.purpose === 'sudo') {
|
||||
await AuthMfa.consumeSudoMfaAttempt(ctx, userId);
|
||||
}
|
||||
await AuthMfa.verifyWebAuthnAuthentication(ctx, userId, data.response, record.challenge, 'bridge', undefined, [
|
||||
record.ceremony_origin,
|
||||
]);
|
||||
return finishRecord(ctx, ceremonyId, {
|
||||
...record,
|
||||
status: 'completed',
|
||||
user_id: userId.toString(),
|
||||
credential_id: credentialId,
|
||||
cross_device: data.response.authenticatorAttachment === 'cross-platform',
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export async function cancelPasskeyBridge(
|
||||
ctx: ApiContext,
|
||||
ceremonyId: string,
|
||||
origin: string | undefined,
|
||||
): Promise<PasskeyBridgeFinishResponse> {
|
||||
return mutateRecord(ctx, ceremonyId, origin, async (record) => {
|
||||
if (record.status === 'completed') {
|
||||
throw new UnknownPasskeyBridgeError();
|
||||
}
|
||||
return finishRecord(ctx, ceremonyId, {...record, status: 'cancelled'});
|
||||
});
|
||||
}
|
||||
|
||||
function assertRedeemable(
|
||||
record: PasskeyBridgeRecord | null,
|
||||
purposes: ReadonlyArray<PasskeyBridgePurpose>,
|
||||
expectedUserId: UserID | null,
|
||||
): asserts record is PasskeyBridgeRecord {
|
||||
if (
|
||||
!record ||
|
||||
!purposes.includes(record.purpose) ||
|
||||
(expectedUserId !== null && record.user_id !== expectedUserId.toString()) ||
|
||||
record.status === 'pending'
|
||||
) {
|
||||
throw new UnknownPasskeyBridgeError();
|
||||
}
|
||||
}
|
||||
|
||||
async function redeemPasskeyBridge(
|
||||
ctx: ApiContext,
|
||||
ceremonyId: string,
|
||||
origin: string | undefined,
|
||||
data: PasskeyBridgeRedeemRequest,
|
||||
purposes: ReadonlyArray<PasskeyBridgePurpose>,
|
||||
expectedUserId: UserID | null,
|
||||
): Promise<CompletedPasskeyBridge | null> {
|
||||
const {cache} = ctx.services;
|
||||
const key = passkeyBridgeKey(ceremonyId);
|
||||
const record = await cache.get<PasskeyBridgeRecord>(key);
|
||||
if (!record) {
|
||||
throw new UnknownPasskeyBridgeError();
|
||||
}
|
||||
assertCeremonyOrigin(ctx, record, origin, record.target_origin);
|
||||
assertRedeemable(record, purposes, expectedUserId);
|
||||
if (!hashMatches(data.nonce, record.nonce_hash) || !hashMatches(data.completion_code, record.completion_code_hash)) {
|
||||
await cache.delete(key);
|
||||
throw new InvalidPasskeyBridgeNonceError();
|
||||
}
|
||||
const taken = await cache.getAndDelete<PasskeyBridgeRecord>(key);
|
||||
assertRedeemable(taken, purposes, expectedUserId);
|
||||
if (!hashMatches(data.nonce, taken.nonce_hash) || !hashMatches(data.completion_code, taken.completion_code_hash)) {
|
||||
throw new InvalidPasskeyBridgeNonceError();
|
||||
}
|
||||
if (taken.status === 'cancelled') {
|
||||
return null;
|
||||
}
|
||||
return {record: taken, userId: createUserID(BigInt(taken.user_id!))};
|
||||
}
|
||||
|
||||
async function recordMigrationIfActive(
|
||||
ctx: ApiContext,
|
||||
origin: string | undefined,
|
||||
completed: CompletedPasskeyBridge,
|
||||
authSession: AuthSession | undefined,
|
||||
): Promise<void> {
|
||||
if (!authSession || !(await isPasskeyMigrationActive(ctx, origin))) return;
|
||||
await recordPendingPasskeyMigration(ctx, authSession, {
|
||||
user_id: completed.userId.toString(),
|
||||
credential_id: completed.record.credential_id!,
|
||||
cross_device: completed.record.cross_device,
|
||||
});
|
||||
}
|
||||
|
||||
export async function redeemPasskeyBridgeLogin(
|
||||
ctx: ApiContext,
|
||||
ceremonyId: string,
|
||||
origin: string | undefined,
|
||||
data: PasskeyBridgeRedeemRequest,
|
||||
request: Request,
|
||||
): Promise<PasskeyBridgeLoginRedeemResponse> {
|
||||
const completed = await redeemPasskeyBridge(ctx, ceremonyId, origin, data, ['login', 'login_mfa'], null);
|
||||
if (!completed) {
|
||||
return {status: 'cancelled'};
|
||||
}
|
||||
let token: string;
|
||||
let authSession: AuthSession;
|
||||
let user: User;
|
||||
if (completed.record.purpose === 'login_mfa') {
|
||||
user = await requireMfaTicketUser(ctx, completed.record.ticket!, completed.record.user_id!);
|
||||
if (!(await resolveWebAuthnSecondFactor(ctx, user))) {
|
||||
throw new MfaNotEnabledError();
|
||||
}
|
||||
[token, authSession] = await AuthLogin.completeMfaLogin(ctx, user, completed.record.ticket!, request);
|
||||
} else {
|
||||
user = await ctx.services.users.findUniqueAssert(completed.userId);
|
||||
[token, authSession] = await AuthLogin.createLoginSession(ctx, user, request);
|
||||
}
|
||||
await recordMigrationIfActive(ctx, origin, completed, authSession);
|
||||
return {status: 'completed', token, user_id: user.id.toString(), user: mapUserToPartialResponse(user)};
|
||||
}
|
||||
|
||||
export async function redeemPasskeyBridgeSudo(
|
||||
ctx: ApiContext,
|
||||
ceremonyId: string,
|
||||
origin: string | undefined,
|
||||
data: PasskeyBridgeRedeemRequest,
|
||||
userId: UserID,
|
||||
authSession: AuthSession | undefined,
|
||||
): Promise<PasskeyBridgeSudoRedeemResponse> {
|
||||
const completed = await redeemPasskeyBridge(ctx, ceremonyId, origin, data, ['sudo'], userId);
|
||||
if (!completed) {
|
||||
return {status: 'cancelled'};
|
||||
}
|
||||
const sudoToken = await getSudoModeService().generateSudoToken(userId);
|
||||
await recordMigrationIfActive(ctx, origin, completed, authSession);
|
||||
return {status: 'completed', sudo_token: sudoToken};
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import type {ApiContext} from '@app/api/ApiContext';
|
||||
import * as AuthMfa from '@app/api/auth/AuthMfa';
|
||||
import {
|
||||
effectiveRpId,
|
||||
isPasskeyTargetOrigin,
|
||||
visibleWebAuthnCredentials,
|
||||
} from '@app/api/auth/services/PasskeyRelyingParty';
|
||||
import type {UserID} from '@app/api/BrandedTypes';
|
||||
import type {AuthSession} from '@app/api/models/AuthSession';
|
||||
import type {WebAuthnCredential} from '@app/api/models/WebAuthnCredential';
|
||||
import {PASSKEY_MIGRATION_RP_ID} from '@fluxer/constants/src/PasskeyConstants';
|
||||
import {UnknownPasskeyMigrationError} from '@fluxer/errors/src/domains/auth/UnknownPasskeyMigrationError';
|
||||
import type {
|
||||
PasskeyMigrationCompleteRequest,
|
||||
PasskeyMigrationResponse,
|
||||
} from '@fluxer/schema/src/domains/auth/PasskeyMigrationSchemas';
|
||||
import type {PublicKeyCredentialCreationOptionsJSON} from '@simplewebauthn/server';
|
||||
import {seconds} from 'itty-time';
|
||||
|
||||
const PASSKEY_MIGRATION_KEY_PREFIX = 'passkey_migration:';
|
||||
|
||||
interface PendingPasskeyMigration {
|
||||
user_id: string;
|
||||
credential_id: string;
|
||||
cross_device: boolean;
|
||||
}
|
||||
|
||||
interface LivePasskeyMigration {
|
||||
key: string;
|
||||
pending: PendingPasskeyMigration;
|
||||
credential: WebAuthnCredential;
|
||||
}
|
||||
|
||||
function passkeyMigrationKey(authSession: AuthSession): string {
|
||||
return `${PASSKEY_MIGRATION_KEY_PREFIX}${authSession.sessionIdHash.toString('base64url')}`;
|
||||
}
|
||||
|
||||
function isLegacyVisibleCredential(ctx: ApiContext, credential: WebAuthnCredential): boolean {
|
||||
return credential.supersededBy === null && effectiveRpId(ctx, credential) === ctx.services.config.auth.passkeys.rpId;
|
||||
}
|
||||
|
||||
export async function recordPendingPasskeyMigration(
|
||||
ctx: ApiContext,
|
||||
authSession: AuthSession,
|
||||
pending: PendingPasskeyMigration,
|
||||
): Promise<void> {
|
||||
await ctx.services.cache.set(passkeyMigrationKey(authSession), pending, seconds('5 minutes'));
|
||||
}
|
||||
|
||||
async function loadLivePasskeyMigration(
|
||||
ctx: ApiContext,
|
||||
userId: UserID,
|
||||
authSession: AuthSession | undefined,
|
||||
): Promise<LivePasskeyMigration | null> {
|
||||
if (!authSession) return null;
|
||||
const {cache, users} = ctx.services;
|
||||
const key = passkeyMigrationKey(authSession);
|
||||
const pending = await cache.get<PendingPasskeyMigration>(key);
|
||||
if (!pending) return null;
|
||||
const credential =
|
||||
pending.user_id === userId.toString() ? await users.getWebAuthnCredential(userId, pending.credential_id) : null;
|
||||
if (credential === null || !isLegacyVisibleCredential(ctx, credential)) {
|
||||
await cache.delete(key);
|
||||
return null;
|
||||
}
|
||||
return {key, pending, credential};
|
||||
}
|
||||
|
||||
async function requireLivePasskeyMigration(
|
||||
ctx: ApiContext,
|
||||
userId: UserID,
|
||||
authSession: AuthSession | undefined,
|
||||
origin: string | undefined,
|
||||
): Promise<LivePasskeyMigration> {
|
||||
const live = isPasskeyTargetOrigin(ctx, origin) ? await loadLivePasskeyMigration(ctx, userId, authSession) : null;
|
||||
if (!live) {
|
||||
throw new UnknownPasskeyMigrationError();
|
||||
}
|
||||
return live;
|
||||
}
|
||||
|
||||
async function takeLivePasskeyMigration(ctx: ApiContext, userId: UserID, key: string): Promise<WebAuthnCredential> {
|
||||
const pending = await ctx.services.cache.getAndDelete<PendingPasskeyMigration>(key);
|
||||
if (!pending || pending.user_id !== userId.toString()) {
|
||||
throw new UnknownPasskeyMigrationError();
|
||||
}
|
||||
const credential = await ctx.services.users.getWebAuthnCredential(userId, pending.credential_id);
|
||||
if (credential === null || !isLegacyVisibleCredential(ctx, credential)) {
|
||||
throw new UnknownPasskeyMigrationError();
|
||||
}
|
||||
return credential;
|
||||
}
|
||||
|
||||
function visibleTargetCredentials(ctx: ApiContext, credentials: Array<WebAuthnCredential>): Array<WebAuthnCredential> {
|
||||
return visibleWebAuthnCredentials(credentials).filter(
|
||||
(credential) => effectiveRpId(ctx, credential) === PASSKEY_MIGRATION_RP_ID,
|
||||
);
|
||||
}
|
||||
|
||||
export async function getPasskeyMigration(
|
||||
ctx: ApiContext,
|
||||
userId: UserID,
|
||||
authSession: AuthSession | undefined,
|
||||
): Promise<PasskeyMigrationResponse> {
|
||||
const live = await loadLivePasskeyMigration(ctx, userId, authSession);
|
||||
if (!live) return {pending: null};
|
||||
return {
|
||||
pending: {
|
||||
credential_id: live.credential.credentialId,
|
||||
name: live.credential.name,
|
||||
cross_device: live.pending.cross_device,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function getPasskeyMigrationRegistrationOptions(
|
||||
ctx: ApiContext,
|
||||
userId: UserID,
|
||||
authSession: AuthSession | undefined,
|
||||
origin: string | undefined,
|
||||
): Promise<PublicKeyCredentialCreationOptionsJSON> {
|
||||
const live = await requireLivePasskeyMigration(ctx, userId, authSession, origin);
|
||||
const credentials = await ctx.services.users.listWebAuthnCredentials(userId);
|
||||
const options = await AuthMfa.createWebAuthnRegistrationOptions(ctx, userId, {
|
||||
rpId: PASSKEY_MIGRATION_RP_ID,
|
||||
context: 'migration_registration',
|
||||
excludeCredentials: visibleTargetCredentials(ctx, credentials),
|
||||
});
|
||||
if (live.pending.cross_device) {
|
||||
options.hints = ['hybrid', 'security-key'];
|
||||
}
|
||||
return options;
|
||||
}
|
||||
|
||||
export async function completePasskeyMigration(
|
||||
ctx: ApiContext,
|
||||
userId: UserID,
|
||||
authSession: AuthSession | undefined,
|
||||
origin: string | undefined,
|
||||
data: PasskeyMigrationCompleteRequest,
|
||||
): Promise<void> {
|
||||
const {users} = ctx.services;
|
||||
const live = await requireLivePasskeyMigration(ctx, userId, authSession, origin);
|
||||
const verified = await AuthMfa.verifyWebAuthnRegistrationResponse(
|
||||
ctx,
|
||||
userId,
|
||||
data.response,
|
||||
data.challenge,
|
||||
'migration_registration',
|
||||
[origin!],
|
||||
);
|
||||
const legacy = await takeLivePasskeyMigration(ctx, userId, live.key);
|
||||
await users.createWebAuthnCredential(
|
||||
userId,
|
||||
verified.credentialId,
|
||||
verified.publicKey,
|
||||
verified.counter,
|
||||
verified.transports,
|
||||
legacy.name,
|
||||
AuthMfa.storedRpId(ctx, verified.rpId),
|
||||
);
|
||||
await users.setWebAuthnCredentialSupersededBy(userId, legacy.credentialId, verified.credentialId);
|
||||
await AuthMfa.dispatchWebAuthnCredentialsUpdate(ctx, userId);
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import type {ApiContext} from '@app/api/ApiContext';
|
||||
import {getInstanceConfigRepository} from '@app/api/middleware/ServiceSingletons';
|
||||
import type {WebAuthnCredential} from '@app/api/models/WebAuthnCredential';
|
||||
import {PASSKEY_MIGRATION_RP_ID} from '@fluxer/constants/src/PasskeyConstants';
|
||||
|
||||
const PASSKEY_TARGET_TO_LEGACY_ORIGIN: ReadonlyMap<string, string> = new Map([
|
||||
['https://fluxer.com', 'https://web.fluxer.app'],
|
||||
['https://canary.fluxer.com', 'https://web.canary.fluxer.app'],
|
||||
]);
|
||||
|
||||
export interface CredentialRpSelection {
|
||||
rpId: string;
|
||||
credentials: Array<WebAuthnCredential>;
|
||||
}
|
||||
|
||||
export function isPasskeyTargetOrigin(ctx: ApiContext, origin: string | null | undefined): boolean {
|
||||
if (ctx.services.config.instance.selfHosted || !origin) return false;
|
||||
return PASSKEY_TARGET_TO_LEGACY_ORIGIN.has(origin);
|
||||
}
|
||||
|
||||
export function passkeyLegacyOriginFor(targetOrigin: string): string {
|
||||
return PASSKEY_TARGET_TO_LEGACY_ORIGIN.get(targetOrigin)!;
|
||||
}
|
||||
|
||||
export function effectiveRpId(ctx: ApiContext, credential: WebAuthnCredential): string {
|
||||
return credential.rpId ?? ctx.services.config.auth.passkeys.rpId;
|
||||
}
|
||||
|
||||
export function visibleWebAuthnCredentials(credentials: Array<WebAuthnCredential>): Array<WebAuthnCredential> {
|
||||
return credentials.filter((credential) => credential.supersededBy === null);
|
||||
}
|
||||
|
||||
export function originRpId(ctx: ApiContext, origin: string | null | undefined): string {
|
||||
return isPasskeyTargetOrigin(ctx, origin) ? PASSKEY_MIGRATION_RP_ID : ctx.services.config.auth.passkeys.rpId;
|
||||
}
|
||||
|
||||
export async function isPasskeyMigrationActive(ctx: ApiContext, origin: string | null | undefined): Promise<boolean> {
|
||||
if (!isPasskeyTargetOrigin(ctx, origin)) return false;
|
||||
const config = await getInstanceConfigRepository().getDomainMigrationConfig();
|
||||
return config.enabled;
|
||||
}
|
||||
|
||||
function credentialGroup(ctx: ApiContext, credentials: Array<WebAuthnCredential>, rpId: string): CredentialRpSelection {
|
||||
return {rpId, credentials: credentials.filter((credential) => effectiveRpId(ctx, credential) === rpId)};
|
||||
}
|
||||
|
||||
export function selectCredentialRp(
|
||||
ctx: ApiContext,
|
||||
origin: string | null | undefined,
|
||||
credentials: Array<WebAuthnCredential>,
|
||||
): CredentialRpSelection {
|
||||
const legacyRpId = ctx.services.config.auth.passkeys.rpId;
|
||||
const visible = visibleWebAuthnCredentials(credentials);
|
||||
if (isPasskeyTargetOrigin(ctx, origin)) {
|
||||
const target = credentialGroup(ctx, visible, PASSKEY_MIGRATION_RP_ID);
|
||||
return target.credentials.length > 0 ? target : credentialGroup(ctx, visible, legacyRpId);
|
||||
}
|
||||
const legacy = credentialGroup(ctx, credentials, legacyRpId);
|
||||
return legacy.credentials.length > 0 ? legacy : credentialGroup(ctx, visible, PASSKEY_MIGRATION_RP_ID);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,391 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {createHash} from 'node:crypto';
|
||||
import {
|
||||
createAuthHarness,
|
||||
createTestAccount,
|
||||
type LoginMfaResponse,
|
||||
loginUser,
|
||||
type TestAccount,
|
||||
} from '@app/api/auth/tests/AuthTestUtils';
|
||||
import {
|
||||
type BridgeNonce,
|
||||
createBridgeNonce,
|
||||
LEGACY_ORIGIN,
|
||||
LEGACY_RP_ID,
|
||||
registerPasskey,
|
||||
runNativeSudoBridge,
|
||||
setDomainMigration,
|
||||
TARGET_ORIGIN,
|
||||
} from '@app/api/auth/tests/PasskeyTestUtils';
|
||||
import {
|
||||
createAuthenticationResponse,
|
||||
createTotpSecret,
|
||||
createWebAuthnDevice,
|
||||
generateTotpCode,
|
||||
setWebAuthnTwoFactor,
|
||||
type WebAuthnAuthenticationOptions,
|
||||
type WebAuthnDevice,
|
||||
} from '@app/api/auth/tests/WebAuthnTestUtils';
|
||||
import {getConfig} from '@app/api/Config';
|
||||
import {getCacheService} from '@app/api/middleware/ServiceSingletons';
|
||||
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 type {
|
||||
PasskeyBridgeFinishResponse,
|
||||
PasskeyBridgeLoginRedeemResponse,
|
||||
PasskeyBridgeStartResponse,
|
||||
PasskeyBridgeSudoRedeemResponse,
|
||||
} from '@fluxer/schema/src/domains/auth/PasskeyBridgeSchemas';
|
||||
import {afterAll, afterEach, beforeAll, beforeEach, describe, expect, it} from 'vitest';
|
||||
|
||||
const SUDO_MODE_HEADER = 'X-Fluxer-Sudo-Mode-JWT';
|
||||
|
||||
interface StartedBridge {
|
||||
ceremonyId: string;
|
||||
bridgeUrl: string | null;
|
||||
nonce: BridgeNonce;
|
||||
}
|
||||
|
||||
describe('Passkey bridge', () => {
|
||||
let harness: ApiTestHarness;
|
||||
|
||||
beforeAll(async () => {
|
||||
harness = await createAuthHarness();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await harness.reset();
|
||||
await setDomainMigration(true);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
getConfig().instance.selfHosted = false;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await harness?.shutdown();
|
||||
});
|
||||
|
||||
async function createLegacyAccount(): Promise<{account: TestAccount; device: WebAuthnDevice}> {
|
||||
const account = await createTestAccount(harness);
|
||||
const device = createWebAuthnDevice();
|
||||
await registerPasskey(harness, account.token, device, {password: account.password}, 'Old');
|
||||
return {account, device};
|
||||
}
|
||||
|
||||
async function startLogin(body: Record<string, unknown> = {}, origin = TARGET_ORIGIN): Promise<StartedBridge> {
|
||||
const nonce = createBridgeNonce();
|
||||
const start = await createBuilderWithoutAuth<PasskeyBridgeStartResponse>(harness)
|
||||
.post('/auth/passkey-bridge')
|
||||
.header('origin', origin)
|
||||
.body({purpose: 'login', runner: 'page', nonce_hash: nonce.nonceHash, ...body})
|
||||
.execute();
|
||||
return {ceremonyId: start.ceremony_id, bridgeUrl: start.bridge_url, nonce};
|
||||
}
|
||||
|
||||
async function startSudo(token: string, runner: 'page' | 'native' = 'page'): Promise<StartedBridge> {
|
||||
const nonce = createBridgeNonce();
|
||||
const start = await createBuilder<PasskeyBridgeStartResponse>(harness, token)
|
||||
.post('/users/@me/passkey-bridge')
|
||||
.header('origin', TARGET_ORIGIN)
|
||||
.body({runner, nonce_hash: nonce.nonceHash})
|
||||
.execute();
|
||||
return {ceremonyId: start.ceremony_id, bridgeUrl: start.bridge_url, nonce};
|
||||
}
|
||||
|
||||
async function fetchOptions(ceremonyId: string, origin = LEGACY_ORIGIN): Promise<WebAuthnAuthenticationOptions> {
|
||||
const {options} = await createBuilderWithoutAuth<{options: WebAuthnAuthenticationOptions}>(harness)
|
||||
.post(`/auth/passkey-bridge/${ceremonyId}/options`)
|
||||
.header('origin', origin)
|
||||
.execute();
|
||||
return options;
|
||||
}
|
||||
|
||||
async function complete(
|
||||
ceremonyId: string,
|
||||
device: WebAuthnDevice,
|
||||
origin = LEGACY_ORIGIN,
|
||||
): Promise<PasskeyBridgeFinishResponse> {
|
||||
const options = await fetchOptions(ceremonyId, origin);
|
||||
return createBuilderWithoutAuth<PasskeyBridgeFinishResponse>(harness)
|
||||
.post(`/auth/passkey-bridge/${ceremonyId}/complete`)
|
||||
.header('origin', origin)
|
||||
.body({response: createAuthenticationResponse(device, options)})
|
||||
.execute();
|
||||
}
|
||||
|
||||
function completionCodeFrom(finish: PasskeyBridgeFinishResponse, ceremonyId: string): string {
|
||||
const url = new URL(finish.return_url!);
|
||||
const [id, code] = url.hash.slice('#passkey-bridge='.length).split('.');
|
||||
expect(id).toBe(ceremonyId);
|
||||
return code;
|
||||
}
|
||||
|
||||
function redeemLogin(ceremonyId: string, nonce: string, completionCode: string) {
|
||||
return createBuilderWithoutAuth<PasskeyBridgeLoginRedeemResponse>(harness)
|
||||
.post(`/auth/passkey-bridge/${ceremonyId}/redeem`)
|
||||
.header('origin', TARGET_ORIGIN)
|
||||
.body({nonce, completion_code: completionCode});
|
||||
}
|
||||
|
||||
it('refuses to start outside the new origin and on a self-hosted instance, whatever the switch', async () => {
|
||||
const nonce = createBridgeNonce();
|
||||
const body = {purpose: 'login', runner: 'native', nonce_hash: nonce.nonceHash};
|
||||
for (const origin of [LEGACY_ORIGIN, 'https://evil.example']) {
|
||||
await createBuilderWithoutAuth(harness)
|
||||
.post('/auth/passkey-bridge')
|
||||
.header('origin', origin)
|
||||
.body(body)
|
||||
.expect(HTTP_STATUS.FORBIDDEN, APIErrorCodes.INVALID_API_ORIGIN)
|
||||
.execute();
|
||||
}
|
||||
await createBuilderWithoutAuth(harness)
|
||||
.post('/auth/passkey-bridge')
|
||||
.body(body)
|
||||
.expect(HTTP_STATUS.FORBIDDEN, APIErrorCodes.INVALID_API_ORIGIN)
|
||||
.execute();
|
||||
getConfig().instance.selfHosted = true;
|
||||
await createBuilderWithoutAuth(harness)
|
||||
.post('/auth/passkey-bridge')
|
||||
.header('origin', TARGET_ORIGIN)
|
||||
.body(body)
|
||||
.expect(HTTP_STATUS.FORBIDDEN, APIErrorCodes.INVALID_API_ORIGIN)
|
||||
.execute();
|
||||
getConfig().instance.selfHosted = false;
|
||||
await setDomainMigration(false);
|
||||
await createBuilderWithoutAuth(harness)
|
||||
.post('/auth/passkey-bridge')
|
||||
.header('origin', TARGET_ORIGIN)
|
||||
.body(body)
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
});
|
||||
|
||||
it('runs the ceremony only on the paired origin and keeps going when the switch goes off', async () => {
|
||||
const {device} = await createLegacyAccount();
|
||||
const started = await startLogin();
|
||||
expect(started.bridgeUrl).toBe(`${LEGACY_ORIGIN}/passkey-bridge#${started.ceremonyId}`);
|
||||
await createBuilderWithoutAuth(harness)
|
||||
.post(`/auth/passkey-bridge/${started.ceremonyId}/options`)
|
||||
.header('origin', TARGET_ORIGIN)
|
||||
.expect(HTTP_STATUS.FORBIDDEN, APIErrorCodes.INVALID_API_ORIGIN)
|
||||
.execute();
|
||||
const options = await fetchOptions(started.ceremonyId);
|
||||
expect(options.rpId).toBe(LEGACY_RP_ID);
|
||||
expect(options.allowCredentials).toBeUndefined();
|
||||
expect(options.userVerification).toBe('required');
|
||||
await setDomainMigration(false);
|
||||
await createBuilderWithoutAuth(harness)
|
||||
.post(`/auth/passkey-bridge/${started.ceremonyId}/complete`)
|
||||
.header('origin', LEGACY_ORIGIN)
|
||||
.body({response: createAuthenticationResponse(device, options)})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
});
|
||||
|
||||
it('signs in through a page ceremony and always returns to the bridge page', async () => {
|
||||
const {account, device} = await createLegacyAccount();
|
||||
const started = await startLogin({
|
||||
return_path: '/api/v1/oauth2/authorize?prompt=none&redirect_uri=https://evil.example/cb',
|
||||
});
|
||||
const finish = await complete(started.ceremonyId, device);
|
||||
expect(finish.completion_code).toBeNull();
|
||||
const returnUrl = new URL(finish.return_url!);
|
||||
expect(`${returnUrl.origin}${returnUrl.pathname}${returnUrl.search}`).toBe(`${TARGET_ORIGIN}/passkey-bridge`);
|
||||
const code = completionCodeFrom(finish, started.ceremonyId);
|
||||
const redeemed = await redeemLogin(started.ceremonyId, started.nonce.nonce, code).execute();
|
||||
expect(redeemed.status).toBe('completed');
|
||||
if (redeemed.status !== 'completed') return;
|
||||
expect(redeemed.user_id).toBe(account.userId);
|
||||
const me = await createBuilder<{id: string}>(harness, redeemed.token).get('/users/@me').execute();
|
||||
expect(me.id).toBe(account.userId);
|
||||
await redeemLogin(started.ceremonyId, started.nonce.nonce, code)
|
||||
.expect(HTTP_STATUS.NOT_FOUND, APIErrorCodes.UNKNOWN_PASSKEY_BRIDGE)
|
||||
.execute();
|
||||
});
|
||||
|
||||
it('needs both the nonce and the completion code', async () => {
|
||||
const {device} = await createLegacyAccount();
|
||||
const started = await startLogin();
|
||||
const code = completionCodeFrom(await complete(started.ceremonyId, device), started.ceremonyId);
|
||||
const attacker = createBridgeNonce();
|
||||
await redeemLogin(started.ceremonyId, attacker.nonce, code)
|
||||
.expect(HTTP_STATUS.BAD_REQUEST, APIErrorCodes.INVALID_PASSKEY_BRIDGE_NONCE)
|
||||
.execute();
|
||||
await redeemLogin(started.ceremonyId, started.nonce.nonce, code)
|
||||
.expect(HTTP_STATUS.NOT_FOUND, APIErrorCodes.UNKNOWN_PASSKEY_BRIDGE)
|
||||
.execute();
|
||||
|
||||
const second = await startLogin();
|
||||
completionCodeFrom(await complete(second.ceremonyId, device), second.ceremonyId);
|
||||
await redeemLogin(second.ceremonyId, second.nonce.nonce, 'A'.repeat(43))
|
||||
.expect(HTTP_STATUS.BAD_REQUEST, APIErrorCodes.INVALID_PASSKEY_BRIDGE_NONCE)
|
||||
.execute();
|
||||
await redeemLogin(second.ceremonyId, second.nonce.nonce, 'A'.repeat(43))
|
||||
.expect(HTTP_STATUS.NOT_FOUND, APIErrorCodes.UNKNOWN_PASSKEY_BRIDGE)
|
||||
.execute();
|
||||
});
|
||||
|
||||
it('keeps a pending ceremony when redeemed early or verification fails', async () => {
|
||||
const {account, device} = await createLegacyAccount();
|
||||
const target = createWebAuthnDevice();
|
||||
await registerPasskey(harness, account.token, target, {password: account.password}, 'New', TARGET_ORIGIN);
|
||||
const started = await startLogin();
|
||||
await redeemLogin(started.ceremonyId, started.nonce.nonce, 'A'.repeat(43))
|
||||
.expect(HTTP_STATUS.NOT_FOUND, APIErrorCodes.UNKNOWN_PASSKEY_BRIDGE)
|
||||
.execute();
|
||||
const options = await fetchOptions(started.ceremonyId);
|
||||
await createBuilderWithoutAuth(harness)
|
||||
.post(`/auth/passkey-bridge/${started.ceremonyId}/complete`)
|
||||
.header('origin', LEGACY_ORIGIN)
|
||||
.body({response: createAuthenticationResponse(target, options)})
|
||||
.expect(HTTP_STATUS.UNAUTHORIZED, APIErrorCodes.PASSKEY_AUTHENTICATION_FAILED)
|
||||
.execute();
|
||||
const code = completionCodeFrom(await complete(started.ceremonyId, device), started.ceremonyId);
|
||||
const redeemed = await redeemLogin(started.ceremonyId, started.nonce.nonce, code).execute();
|
||||
expect(redeemed.status).toBe('completed');
|
||||
});
|
||||
|
||||
it('never lets a bridge challenge through the normal endpoints', async () => {
|
||||
const {device} = await createLegacyAccount();
|
||||
const started = await startLogin();
|
||||
const options = await fetchOptions(started.ceremonyId);
|
||||
await createBuilderWithoutAuth(harness)
|
||||
.post('/auth/webauthn/authenticate')
|
||||
.header('origin', LEGACY_ORIGIN)
|
||||
.body({response: createAuthenticationResponse(device, options), challenge: options.challenge})
|
||||
.expect(HTTP_STATUS.UNAUTHORIZED, APIErrorCodes.PASSKEY_AUTHENTICATION_FAILED)
|
||||
.execute();
|
||||
});
|
||||
|
||||
it('reports a cancelled ceremony and refuses to cancel a completed one', async () => {
|
||||
const {device} = await createLegacyAccount();
|
||||
const started = await startLogin();
|
||||
const cancelled = await createBuilderWithoutAuth<PasskeyBridgeFinishResponse>(harness)
|
||||
.post(`/auth/passkey-bridge/${started.ceremonyId}/cancel`)
|
||||
.header('origin', LEGACY_ORIGIN)
|
||||
.execute();
|
||||
const code = completionCodeFrom(cancelled, started.ceremonyId);
|
||||
expect(await redeemLogin(started.ceremonyId, started.nonce.nonce, code).execute()).toEqual({status: 'cancelled'});
|
||||
|
||||
const second = await startLogin();
|
||||
await complete(second.ceremonyId, device);
|
||||
await createBuilderWithoutAuth(harness)
|
||||
.post(`/auth/passkey-bridge/${second.ceremonyId}/cancel`)
|
||||
.header('origin', LEGACY_ORIGIN)
|
||||
.expect(HTTP_STATUS.NOT_FOUND, APIErrorCodes.UNKNOWN_PASSKEY_BRIDGE)
|
||||
.execute();
|
||||
});
|
||||
|
||||
it('completes two-factor sign in for the ticket holder', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const device = createWebAuthnDevice();
|
||||
const secret = createTotpSecret();
|
||||
await createBuilder(harness, account.token)
|
||||
.post('/users/@me/mfa/totp/enable')
|
||||
.body({secret, code: generateTotpCode(secret), password: account.password})
|
||||
.execute();
|
||||
await registerPasskey(
|
||||
harness,
|
||||
account.token,
|
||||
device,
|
||||
{mfa_method: 'totp', mfa_code: generateTotpCode(secret)},
|
||||
'Old',
|
||||
);
|
||||
await setWebAuthnTwoFactor(harness, account.token, true, {mfa_method: 'totp', mfa_code: generateTotpCode(secret)});
|
||||
const login = (await loginUser(harness, {email: account.email, password: account.password})) as LoginMfaResponse;
|
||||
const started = await startLogin({purpose: 'login_mfa', ticket: login.ticket});
|
||||
const options = await fetchOptions(started.ceremonyId);
|
||||
expect(options.allowCredentials?.map((cred) => cred.id)).toEqual([device.credentialId.toString('base64url')]);
|
||||
expect(options.userVerification).toBe('discouraged');
|
||||
const code = completionCodeFrom(await complete(started.ceremonyId, device), started.ceremonyId);
|
||||
const redeemed = await redeemLogin(started.ceremonyId, started.nonce.nonce, code).execute();
|
||||
expect(redeemed.status).toBe('completed');
|
||||
await createBuilderWithoutAuth(harness)
|
||||
.post('/auth/login/mfa/totp')
|
||||
.body({code: generateTotpCode(secret), ticket: login.ticket})
|
||||
.expect(HTTP_STATUS.BAD_REQUEST)
|
||||
.execute();
|
||||
});
|
||||
|
||||
it('issues a sudo token that passes a sudo-protected route', async () => {
|
||||
const {account, device} = await createLegacyAccount();
|
||||
const credentialId = device.credentialId.toString('base64url');
|
||||
await createBuilder(harness, account.token)
|
||||
.patch(`/users/@me/mfa/webauthn/credentials/${credentialId}`)
|
||||
.body({name: 'Renamed'})
|
||||
.expect(HTTP_STATUS.FORBIDDEN)
|
||||
.execute();
|
||||
const redeemed = await runNativeSudoBridge(harness, account.token, device);
|
||||
expect(redeemed.status).toBe('completed');
|
||||
if (redeemed.status !== 'completed') return;
|
||||
await createBuilder(harness, account.token)
|
||||
.patch(`/users/@me/mfa/webauthn/credentials/${credentialId}`)
|
||||
.header(SUDO_MODE_HEADER, redeemed.sudo_token)
|
||||
.body({name: 'Renamed'})
|
||||
.expect(HTTP_STATUS.NO_CONTENT)
|
||||
.execute();
|
||||
});
|
||||
|
||||
it('returns sudo page ceremonies to the bridge page on the new origin', async () => {
|
||||
const {account, device} = await createLegacyAccount();
|
||||
const started = await startSudo(account.token);
|
||||
const finish = await complete(started.ceremonyId, device);
|
||||
const returnUrl = new URL(finish.return_url!);
|
||||
expect(`${returnUrl.origin}${returnUrl.pathname}`).toBe(`${TARGET_ORIGIN}/passkey-bridge`);
|
||||
const code = completionCodeFrom(finish, started.ceremonyId);
|
||||
const redeemed = await createBuilder<PasskeyBridgeSudoRedeemResponse>(harness, account.token)
|
||||
.post(`/users/@me/passkey-bridge/${started.ceremonyId}/redeem`)
|
||||
.header('origin', TARGET_ORIGIN)
|
||||
.body({nonce: started.nonce.nonce, completion_code: code})
|
||||
.execute();
|
||||
expect(redeemed.status).toBe('completed');
|
||||
});
|
||||
|
||||
it('does not consume a ceremony redeemed on the wrong route or by another user', async () => {
|
||||
const {account, device} = await createLegacyAccount();
|
||||
const other = await createTestAccount(harness);
|
||||
const started = await startSudo(account.token, 'native');
|
||||
const options = await fetchOptions(started.ceremonyId, TARGET_ORIGIN);
|
||||
const finish = await createBuilderWithoutAuth<PasskeyBridgeFinishResponse>(harness)
|
||||
.post(`/auth/passkey-bridge/${started.ceremonyId}/complete`)
|
||||
.header('origin', TARGET_ORIGIN)
|
||||
.body({response: createAuthenticationResponse(device, options)})
|
||||
.execute();
|
||||
const body = {nonce: started.nonce.nonce, completion_code: finish.completion_code};
|
||||
await redeemLogin(started.ceremonyId, body.nonce, body.completion_code!)
|
||||
.expect(HTTP_STATUS.NOT_FOUND, APIErrorCodes.UNKNOWN_PASSKEY_BRIDGE)
|
||||
.execute();
|
||||
await createBuilder(harness, other.token)
|
||||
.post(`/users/@me/passkey-bridge/${started.ceremonyId}/redeem`)
|
||||
.header('origin', TARGET_ORIGIN)
|
||||
.body(body)
|
||||
.expect(HTTP_STATUS.NOT_FOUND, APIErrorCodes.UNKNOWN_PASSKEY_BRIDGE)
|
||||
.execute();
|
||||
const redeemed = await createBuilder<PasskeyBridgeSudoRedeemResponse>(harness, account.token)
|
||||
.post(`/users/@me/passkey-bridge/${started.ceremonyId}/redeem`)
|
||||
.header('origin', TARGET_ORIGIN)
|
||||
.body(body)
|
||||
.execute();
|
||||
expect(redeemed.status).toBe('completed');
|
||||
});
|
||||
|
||||
it('always stores the ceremony with an expiry', async () => {
|
||||
const {device} = await createLegacyAccount();
|
||||
const started = await startLogin();
|
||||
const key = `passkey_bridge:${createHash('sha256').update(started.ceremonyId).digest('hex')}`;
|
||||
const cache = getCacheService();
|
||||
const ttls = [await cache.ttl(key)];
|
||||
await fetchOptions(started.ceremonyId);
|
||||
ttls.push(await cache.ttl(key));
|
||||
await complete(started.ceremonyId, device);
|
||||
ttls.push(await cache.ttl(key));
|
||||
for (const ttl of ttls) {
|
||||
expect(ttl).toBeGreaterThan(0);
|
||||
expect(ttl).toBeLessThanOrEqual(600);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,260 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {createAuthHarness, createTestAccount, type TestAccount} from '@app/api/auth/tests/AuthTestUtils';
|
||||
import {
|
||||
LEGACY_RP_ID,
|
||||
listPasskeys,
|
||||
registerPasskey,
|
||||
runNativeSudoBridge,
|
||||
setDomainMigration,
|
||||
TARGET_ORIGIN,
|
||||
TARGET_RP_ID,
|
||||
} from '@app/api/auth/tests/PasskeyTestUtils';
|
||||
import {
|
||||
createAuthenticationResponse,
|
||||
createRegistrationResponse,
|
||||
createWebAuthnDevice,
|
||||
type WebAuthnAuthenticationOptions,
|
||||
type WebAuthnDevice,
|
||||
type WebAuthnRegistrationOptions,
|
||||
} from '@app/api/auth/tests/WebAuthnTestUtils';
|
||||
import {createUserID} from '@app/api/BrandedTypes';
|
||||
import {getUserRepository} from '@app/api/middleware/ServiceSingletons';
|
||||
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 type {PasskeyMigrationResponse} from '@fluxer/schema/src/domains/auth/PasskeyMigrationSchemas';
|
||||
import {afterAll, beforeAll, beforeEach, describe, expect, it} from 'vitest';
|
||||
|
||||
const MIGRATION_PATH = '/users/@me/mfa/webauthn/migration';
|
||||
const MIGRATION_OPTIONS_PATH = '/users/@me/mfa/webauthn/migration/registration-options';
|
||||
|
||||
function credentialIdOf(device: WebAuthnDevice): string {
|
||||
return device.credentialId.toString('base64url');
|
||||
}
|
||||
|
||||
interface RpcSessionResponse {
|
||||
data: {webauthn_credentials: Array<{id: string; rp_id: string}>};
|
||||
}
|
||||
|
||||
describe('Passkey migration', () => {
|
||||
let harness: ApiTestHarness;
|
||||
|
||||
beforeAll(async () => {
|
||||
harness = await createAuthHarness();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await harness.reset();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await harness?.shutdown();
|
||||
});
|
||||
|
||||
async function createAssignedAccount(): Promise<{account: TestAccount; legacy: WebAuthnDevice}> {
|
||||
const account = await createTestAccount(harness);
|
||||
const legacy = createWebAuthnDevice();
|
||||
await registerPasskey(harness, account.token, legacy, {password: account.password}, 'Laptop');
|
||||
await setDomainMigration(true, [account.userId]);
|
||||
return {account, legacy};
|
||||
}
|
||||
|
||||
async function getPending(token: string): Promise<PasskeyMigrationResponse['pending']> {
|
||||
const response = await createBuilder<PasskeyMigrationResponse>(harness, token).get(MIGRATION_PATH).execute();
|
||||
return response.pending;
|
||||
}
|
||||
|
||||
async function migrationOptions(token: string): Promise<WebAuthnRegistrationOptions> {
|
||||
return createBuilder<WebAuthnRegistrationOptions>(harness, token)
|
||||
.post(MIGRATION_OPTIONS_PATH)
|
||||
.header('origin', TARGET_ORIGIN)
|
||||
.execute();
|
||||
}
|
||||
|
||||
function completeMigration(token: string, device: WebAuthnDevice, options: WebAuthnRegistrationOptions) {
|
||||
return createBuilder(harness, token)
|
||||
.post(MIGRATION_PATH)
|
||||
.header('origin', TARGET_ORIGIN)
|
||||
.body({response: createRegistrationResponse(device, options, 'Laptop'), challenge: options.challenge});
|
||||
}
|
||||
|
||||
async function migrate(account: TestAccount, legacy: WebAuthnDevice): Promise<WebAuthnDevice> {
|
||||
await runNativeSudoBridge(harness, account.token, legacy);
|
||||
const target = createWebAuthnDevice();
|
||||
await completeMigration(account.token, target, await migrationOptions(account.token))
|
||||
.expect(HTTP_STATUS.NO_CONTENT)
|
||||
.execute();
|
||||
return target;
|
||||
}
|
||||
|
||||
async function discoverableLogin(device: WebAuthnDevice, origin?: string, status: number = HTTP_STATUS.OK) {
|
||||
const optionsBuilder = createBuilderWithoutAuth<WebAuthnAuthenticationOptions>(harness)
|
||||
.post('/auth/webauthn/authentication-options')
|
||||
.body(null);
|
||||
if (origin) optionsBuilder.header('origin', origin);
|
||||
const options = await optionsBuilder.execute();
|
||||
const builder = createBuilderWithoutAuth(harness)
|
||||
.post('/auth/webauthn/authenticate')
|
||||
.body({response: createAuthenticationResponse(device, options), challenge: options.challenge})
|
||||
.expect(status);
|
||||
if (origin) builder.header('origin', origin);
|
||||
await builder.execute();
|
||||
}
|
||||
|
||||
it('records a pending update for any account on the new origin while the switch is on', async () => {
|
||||
const unassigned = await createTestAccount(harness);
|
||||
const unassignedDevice = createWebAuthnDevice();
|
||||
await registerPasskey(harness, unassigned.token, unassignedDevice, {password: unassigned.password}, 'Laptop');
|
||||
await setDomainMigration(false);
|
||||
expect((await runNativeSudoBridge(harness, unassigned.token, unassignedDevice)).status).toBe('completed');
|
||||
expect(await getPending(unassigned.token)).toBeNull();
|
||||
|
||||
await setDomainMigration(true);
|
||||
expect((await runNativeSudoBridge(harness, unassigned.token, unassignedDevice)).status).toBe('completed');
|
||||
expect(await getPending(unassigned.token)).toEqual({
|
||||
credential_id: credentialIdOf(unassignedDevice),
|
||||
name: 'Laptop',
|
||||
cross_device: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('needs a pending update and the new origin for registration options', async () => {
|
||||
const {account, legacy} = await createAssignedAccount();
|
||||
await createBuilder(harness, account.token)
|
||||
.post(MIGRATION_OPTIONS_PATH)
|
||||
.header('origin', TARGET_ORIGIN)
|
||||
.expect(HTTP_STATUS.NOT_FOUND, APIErrorCodes.UNKNOWN_PASSKEY_MIGRATION)
|
||||
.execute();
|
||||
await runNativeSudoBridge(harness, account.token, legacy);
|
||||
await createBuilder(harness, account.token)
|
||||
.post(MIGRATION_OPTIONS_PATH)
|
||||
.expect(HTTP_STATUS.NOT_FOUND, APIErrorCodes.UNKNOWN_PASSKEY_MIGRATION)
|
||||
.execute();
|
||||
const options = await migrationOptions(account.token);
|
||||
expect(options.rp.id).toBe(TARGET_RP_ID);
|
||||
});
|
||||
|
||||
it('replaces the passkey under the same name and hides the old one', async () => {
|
||||
const {account, legacy} = await createAssignedAccount();
|
||||
const target = await migrate(account, legacy);
|
||||
const credentials = await listPasskeys(harness, account.token);
|
||||
expect(credentials).toEqual([
|
||||
expect.objectContaining({id: credentialIdOf(target), name: 'Laptop', rp_id: TARGET_RP_ID}),
|
||||
]);
|
||||
const old = await getUserRepository().getWebAuthnCredential(
|
||||
createUserID(BigInt(account.userId)),
|
||||
credentialIdOf(legacy),
|
||||
);
|
||||
expect(old?.supersededBy).toBe(credentialIdOf(target));
|
||||
expect(await getPending(account.token)).toBeNull();
|
||||
const ready = await createBuilder<RpcSessionResponse>(harness, '')
|
||||
.post('/test/rpc-session-init')
|
||||
.body({type: 'session', token: account.token, version: 1, ip: '127.0.0.1'})
|
||||
.execute();
|
||||
expect(ready.data.webauthn_credentials.map(({id, rp_id}) => ({id, rp_id}))).toEqual([
|
||||
{id: credentialIdOf(target), rp_id: TARGET_RP_ID},
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps the old passkey working off the new origin', async () => {
|
||||
const {account, legacy} = await createAssignedAccount();
|
||||
const target = await migrate(account, legacy);
|
||||
await discoverableLogin(legacy);
|
||||
await discoverableLogin(legacy, TARGET_ORIGIN, HTTP_STATUS.UNAUTHORIZED);
|
||||
await discoverableLogin(target, TARGET_ORIGIN);
|
||||
await createBuilder(harness, account.token)
|
||||
.patch(`/users/@me/mfa/webauthn/credentials/${credentialIdOf(legacy)}`)
|
||||
.body({name: 'Renamed', password: account.password})
|
||||
.expect(HTTP_STATUS.NOT_FOUND, APIErrorCodes.UNKNOWN_WEBAUTHN_CREDENTIAL)
|
||||
.execute();
|
||||
const sudoOptions = await createBuilder<WebAuthnAuthenticationOptions>(harness, account.token)
|
||||
.post('/users/@me/sudo/webauthn/authentication-options')
|
||||
.body(null)
|
||||
.execute();
|
||||
expect(sudoOptions.rpId).toBe(LEGACY_RP_ID);
|
||||
expect(sudoOptions.allowCredentials?.map((cred) => cred.id)).toEqual([credentialIdOf(legacy)]);
|
||||
});
|
||||
|
||||
it('removes the old passkey together with its replacement', async () => {
|
||||
const {account, legacy} = await createAssignedAccount();
|
||||
const target = await migrate(account, legacy);
|
||||
await createBuilder(harness, account.token)
|
||||
.delete(`/users/@me/mfa/webauthn/credentials/${credentialIdOf(target)}`)
|
||||
.body({password: account.password})
|
||||
.expect(HTTP_STATUS.NO_CONTENT)
|
||||
.execute();
|
||||
expect(await getUserRepository().listWebAuthnCredentials(createUserID(BigInt(account.userId)))).toEqual([]);
|
||||
await discoverableLogin(legacy, undefined, HTTP_STATUS.UNAUTHORIZED);
|
||||
});
|
||||
|
||||
it('removes every remaining superseded passkey with the last visible one', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const orphan = createWebAuthnDevice();
|
||||
const visible = createWebAuthnDevice();
|
||||
await registerPasskey(harness, account.token, orphan, {password: account.password}, 'Orphan');
|
||||
await registerPasskey(harness, account.token, visible, {password: account.password}, 'Visible');
|
||||
const userId = createUserID(BigInt(account.userId));
|
||||
await getUserRepository().setWebAuthnCredentialSupersededBy(userId, credentialIdOf(orphan), 'gone');
|
||||
await createBuilder(harness, account.token)
|
||||
.delete(`/users/@me/mfa/webauthn/credentials/${credentialIdOf(visible)}`)
|
||||
.body({password: account.password})
|
||||
.expect(HTTP_STATUS.NO_CONTENT)
|
||||
.execute();
|
||||
expect(await getUserRepository().listWebAuthnCredentials(userId)).toEqual([]);
|
||||
});
|
||||
|
||||
it('has no way to attach the old passkey to another one', async () => {
|
||||
const {account, legacy} = await createAssignedAccount();
|
||||
await runNativeSudoBridge(harness, account.token, legacy);
|
||||
const target = createWebAuthnDevice();
|
||||
await registerPasskey(harness, account.token, target, {password: account.password}, 'Phone', TARGET_ORIGIN);
|
||||
await createBuilder(harness, account.token)
|
||||
.delete(MIGRATION_PATH)
|
||||
.header('origin', TARGET_ORIGIN)
|
||||
.expect(HTTP_STATUS.NOT_FOUND)
|
||||
.execute();
|
||||
expect((await listPasskeys(harness, account.token)).map((cred) => cred.id).sort()).toEqual(
|
||||
[credentialIdOf(legacy), credentialIdOf(target)].sort(),
|
||||
);
|
||||
});
|
||||
|
||||
it('never lets a migration challenge through the normal registration route', async () => {
|
||||
const {account, legacy} = await createAssignedAccount();
|
||||
await runNativeSudoBridge(harness, account.token, legacy);
|
||||
const options = await migrationOptions(account.token);
|
||||
await createBuilder(harness, account.token)
|
||||
.post('/users/@me/mfa/webauthn/credentials')
|
||||
.header('origin', TARGET_ORIGIN)
|
||||
.body({
|
||||
response: createRegistrationResponse(createWebAuthnDevice(), options, 'Sneaky'),
|
||||
challenge: options.challenge,
|
||||
name: 'Sneaky',
|
||||
})
|
||||
.expect(HTTP_STATUS.BAD_REQUEST, APIErrorCodes.INVALID_WEBAUTHN_CREDENTIAL)
|
||||
.execute();
|
||||
});
|
||||
|
||||
it('creates one credential when two updates race', async () => {
|
||||
const {account, legacy} = await createAssignedAccount();
|
||||
await runNativeSudoBridge(harness, account.token, legacy);
|
||||
const first = await migrationOptions(account.token);
|
||||
const second = await migrationOptions(account.token);
|
||||
const results = await Promise.all(
|
||||
[first, second].map((options) =>
|
||||
completeMigration(account.token, createWebAuthnDevice(), options)
|
||||
.expect(HTTP_STATUS.NO_CONTENT)
|
||||
.executeWithResponse()
|
||||
.then(
|
||||
() => 'ok',
|
||||
() => 'failed',
|
||||
),
|
||||
),
|
||||
);
|
||||
expect(results.sort()).toEqual(['failed', 'ok']);
|
||||
const credentials = await listPasskeys(harness, account.token);
|
||||
expect(credentials).toHaveLength(1);
|
||||
expect(credentials[0].rp_id).toBe(TARGET_RP_ID);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,232 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {createAuthHarness, createTestAccount, type TestAccount} from '@app/api/auth/tests/AuthTestUtils';
|
||||
import {
|
||||
LEGACY_ORIGIN,
|
||||
LEGACY_RP_ID,
|
||||
listPasskeys,
|
||||
registerPasskey,
|
||||
TARGET_ORIGIN,
|
||||
TARGET_RP_ID,
|
||||
} from '@app/api/auth/tests/PasskeyTestUtils';
|
||||
import {
|
||||
createAuthenticationResponse,
|
||||
createWebAuthnDevice,
|
||||
type WebAuthnAuthenticationOptions,
|
||||
type WebAuthnDevice,
|
||||
type WebAuthnRegistrationOptions,
|
||||
} from '@app/api/auth/tests/WebAuthnTestUtils';
|
||||
import {createUserID} from '@app/api/BrandedTypes';
|
||||
import {getConfig} from '@app/api/Config';
|
||||
import {getUserRepository} from '@app/api/middleware/ServiceSingletons';
|
||||
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 {afterAll, afterEach, beforeAll, beforeEach, describe, expect, it} from 'vitest';
|
||||
|
||||
function credentialIdOf(device: WebAuthnDevice): string {
|
||||
return device.credentialId.toString('base64url');
|
||||
}
|
||||
|
||||
describe('Passkey relying party selection', () => {
|
||||
let harness: ApiTestHarness;
|
||||
|
||||
beforeAll(async () => {
|
||||
harness = await createAuthHarness();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await harness.reset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
getConfig().instance.selfHosted = false;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await harness?.shutdown();
|
||||
});
|
||||
|
||||
async function registrationRpId(account: TestAccount, origin?: string): Promise<string> {
|
||||
const builder = createBuilder<WebAuthnRegistrationOptions>(harness, account.token)
|
||||
.post('/users/@me/mfa/webauthn/credentials/registration-options')
|
||||
.body({password: account.password});
|
||||
if (origin) builder.header('origin', origin);
|
||||
return (await builder.execute()).rp.id;
|
||||
}
|
||||
|
||||
async function discoverableOptions(origin?: string): Promise<WebAuthnAuthenticationOptions> {
|
||||
const builder = createBuilderWithoutAuth<WebAuthnAuthenticationOptions>(harness)
|
||||
.post('/auth/webauthn/authentication-options')
|
||||
.body(null);
|
||||
if (origin) builder.header('origin', origin);
|
||||
return builder.execute();
|
||||
}
|
||||
|
||||
async function sudoOptions(token: string, origin?: string): Promise<WebAuthnAuthenticationOptions> {
|
||||
const builder = createBuilder<WebAuthnAuthenticationOptions>(harness, token)
|
||||
.post('/users/@me/sudo/webauthn/authentication-options')
|
||||
.body(null);
|
||||
if (origin) builder.header('origin', origin);
|
||||
return builder.execute();
|
||||
}
|
||||
|
||||
async function createMixedAccount(): Promise<{account: TestAccount; legacy: WebAuthnDevice; target: WebAuthnDevice}> {
|
||||
const account = await createTestAccount(harness);
|
||||
const legacy = createWebAuthnDevice();
|
||||
const target = createWebAuthnDevice();
|
||||
await registerPasskey(harness, account.token, legacy, {password: account.password}, 'Old');
|
||||
await registerPasskey(harness, account.token, target, {password: account.password}, 'New', TARGET_ORIGIN);
|
||||
return {account, legacy, target};
|
||||
}
|
||||
|
||||
it('uses the new relying party only for requests from the new origin', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
expect(await registrationRpId(account)).toBe(LEGACY_RP_ID);
|
||||
expect(await registrationRpId(account, LEGACY_ORIGIN)).toBe(LEGACY_RP_ID);
|
||||
expect(await registrationRpId(account, TARGET_ORIGIN)).toBe(TARGET_RP_ID);
|
||||
expect((await discoverableOptions()).rpId).toBe(LEGACY_RP_ID);
|
||||
expect((await discoverableOptions(LEGACY_ORIGIN)).rpId).toBe(LEGACY_RP_ID);
|
||||
expect((await discoverableOptions(TARGET_ORIGIN)).rpId).toBe(TARGET_RP_ID);
|
||||
});
|
||||
|
||||
it('keeps the legacy relying party everywhere on a self-hosted instance', async () => {
|
||||
getConfig().instance.selfHosted = true;
|
||||
const account = await createTestAccount(harness);
|
||||
expect(await registrationRpId(account, TARGET_ORIGIN)).toBe(LEGACY_RP_ID);
|
||||
expect((await discoverableOptions(TARGET_ORIGIN)).rpId).toBe(LEGACY_RP_ID);
|
||||
});
|
||||
|
||||
it('stores and exposes the relying party of each passkey', async () => {
|
||||
const {account, legacy, target} = await createMixedAccount();
|
||||
const credentials = await listPasskeys(harness, account.token);
|
||||
expect(credentials.map(({id, rp_id}) => ({id, rp_id}))).toEqual(
|
||||
expect.arrayContaining([
|
||||
{id: credentialIdOf(legacy), rp_id: LEGACY_RP_ID},
|
||||
{id: credentialIdOf(target), rp_id: TARGET_RP_ID},
|
||||
]),
|
||||
);
|
||||
const legacyRow = await getUserRepository().getWebAuthnCredential(
|
||||
createUserID(BigInt(account.userId)),
|
||||
credentialIdOf(legacy),
|
||||
);
|
||||
expect(legacyRow?.rpId).toBeNull();
|
||||
});
|
||||
|
||||
it('keeps the legacy options unchanged for a legacy-only account off the new origin', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const device = createWebAuthnDevice();
|
||||
await registerPasskey(harness, account.token, device, {password: account.password}, 'Old');
|
||||
for (const origin of [undefined, LEGACY_ORIGIN]) {
|
||||
const options = await sudoOptions(account.token, origin);
|
||||
expect(options.rpId).toBe(LEGACY_RP_ID);
|
||||
expect(options.allowCredentials?.map((cred) => cred.id)).toEqual([credentialIdOf(device)]);
|
||||
expect(options.userVerification).toBe('discouraged');
|
||||
}
|
||||
});
|
||||
|
||||
it('offers one relying party group per request', async () => {
|
||||
const {account, legacy, target} = await createMixedAccount();
|
||||
const onTarget = await sudoOptions(account.token, TARGET_ORIGIN);
|
||||
expect(onTarget.rpId).toBe(TARGET_RP_ID);
|
||||
expect(onTarget.allowCredentials?.map((cred) => cred.id)).toEqual([credentialIdOf(target)]);
|
||||
const offTarget = await sudoOptions(account.token);
|
||||
expect(offTarget.rpId).toBe(LEGACY_RP_ID);
|
||||
expect(offTarget.allowCredentials?.map((cred) => cred.id)).toEqual([credentialIdOf(legacy)]);
|
||||
});
|
||||
|
||||
it('falls back to the other group when the preferred one is empty', async () => {
|
||||
const legacyOnly = await createTestAccount(harness);
|
||||
const legacy = createWebAuthnDevice();
|
||||
await registerPasskey(harness, legacyOnly.token, legacy, {password: legacyOnly.password}, 'Old');
|
||||
expect((await sudoOptions(legacyOnly.token, TARGET_ORIGIN)).rpId).toBe(LEGACY_RP_ID);
|
||||
const targetOnly = await createTestAccount(harness);
|
||||
const target = createWebAuthnDevice();
|
||||
await registerPasskey(harness, targetOnly.token, target, {password: targetOnly.password}, 'New', TARGET_ORIGIN);
|
||||
expect((await sudoOptions(targetOnly.token)).rpId).toBe(TARGET_RP_ID);
|
||||
});
|
||||
|
||||
it('rejects a passkey from another relying party before the test mode shortcut', async () => {
|
||||
const {legacy} = await createMixedAccount();
|
||||
const options = await discoverableOptions(TARGET_ORIGIN);
|
||||
await createBuilderWithoutAuth(harness)
|
||||
.post('/auth/webauthn/authenticate')
|
||||
.header('origin', TARGET_ORIGIN)
|
||||
.body({response: createAuthenticationResponse(legacy, options), challenge: options.challenge})
|
||||
.expect(HTTP_STATUS.UNAUTHORIZED, APIErrorCodes.PASSKEY_AUTHENTICATION_FAILED)
|
||||
.execute();
|
||||
});
|
||||
|
||||
it('rejects a passkey outside the offered list before the test mode shortcut', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const visible = createWebAuthnDevice();
|
||||
const superseded = createWebAuthnDevice();
|
||||
await registerPasskey(harness, account.token, visible, {password: account.password}, 'Visible');
|
||||
await registerPasskey(harness, account.token, superseded, {password: account.password}, 'Superseded');
|
||||
await getUserRepository().setWebAuthnCredentialSupersededBy(
|
||||
createUserID(BigInt(account.userId)),
|
||||
credentialIdOf(superseded),
|
||||
credentialIdOf(visible),
|
||||
);
|
||||
const options = await sudoOptions(account.token, TARGET_ORIGIN);
|
||||
expect(options.rpId).toBe(LEGACY_RP_ID);
|
||||
expect(options.allowCredentials?.map((cred) => cred.id)).toEqual([credentialIdOf(visible)]);
|
||||
await createBuilder(harness, account.token)
|
||||
.patch(`/users/@me/mfa/webauthn/credentials/${credentialIdOf(visible)}`)
|
||||
.header('origin', TARGET_ORIGIN)
|
||||
.body({
|
||||
name: 'Renamed',
|
||||
mfa_method: 'webauthn',
|
||||
webauthn_response: createAuthenticationResponse(superseded, options),
|
||||
webauthn_challenge: options.challenge,
|
||||
})
|
||||
.expect(HTTP_STATUS.BAD_REQUEST)
|
||||
.execute();
|
||||
const retry = await sudoOptions(account.token, TARGET_ORIGIN);
|
||||
await createBuilder(harness, account.token)
|
||||
.patch(`/users/@me/mfa/webauthn/credentials/${credentialIdOf(visible)}`)
|
||||
.header('origin', TARGET_ORIGIN)
|
||||
.body({
|
||||
name: 'Renamed',
|
||||
mfa_method: 'webauthn',
|
||||
webauthn_response: createAuthenticationResponse(visible, retry),
|
||||
webauthn_challenge: retry.challenge,
|
||||
})
|
||||
.expect(HTTP_STATUS.NO_CONTENT)
|
||||
.execute();
|
||||
});
|
||||
|
||||
it('accepts a superseded passkey only off the new origin', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const legacy = createWebAuthnDevice();
|
||||
const target = createWebAuthnDevice();
|
||||
await registerPasskey(harness, account.token, legacy, {password: account.password}, 'Old');
|
||||
await registerPasskey(harness, account.token, target, {password: account.password}, 'New', TARGET_ORIGIN);
|
||||
await getUserRepository().setWebAuthnCredentialSupersededBy(
|
||||
createUserID(BigInt(account.userId)),
|
||||
credentialIdOf(legacy),
|
||||
credentialIdOf(target),
|
||||
);
|
||||
expect((await listPasskeys(harness, account.token)).map((cred) => cred.id)).toEqual([credentialIdOf(target)]);
|
||||
|
||||
const offTarget = await discoverableOptions();
|
||||
await createBuilderWithoutAuth(harness)
|
||||
.post('/auth/webauthn/authenticate')
|
||||
.body({response: createAuthenticationResponse(legacy, offTarget), challenge: offTarget.challenge})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
const sudoOffTarget = await sudoOptions(account.token);
|
||||
expect(sudoOffTarget.allowCredentials?.map((cred) => cred.id)).toEqual([credentialIdOf(legacy)]);
|
||||
|
||||
const onTarget = await discoverableOptions(TARGET_ORIGIN);
|
||||
await createBuilderWithoutAuth(harness)
|
||||
.post('/auth/webauthn/authenticate')
|
||||
.header('origin', TARGET_ORIGIN)
|
||||
.body({response: createAuthenticationResponse(legacy, onTarget), challenge: onTarget.challenge})
|
||||
.expect(HTTP_STATUS.UNAUTHORIZED, APIErrorCodes.PASSKEY_AUTHENTICATION_FAILED)
|
||||
.execute();
|
||||
const sudoOnTarget = await sudoOptions(account.token, TARGET_ORIGIN);
|
||||
expect(sudoOnTarget.allowCredentials?.map((cred) => cred.id)).toEqual([credentialIdOf(target)]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,102 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {createHash, randomBytes} from 'node:crypto';
|
||||
import {
|
||||
createAuthenticationResponse,
|
||||
createRegistrationResponse,
|
||||
type WebAuthnAuthenticationOptions,
|
||||
type WebAuthnDevice,
|
||||
type WebAuthnRegistrationOptions,
|
||||
} from '@app/api/auth/tests/WebAuthnTestUtils';
|
||||
import {getInstanceConfigRepository} from '@app/api/middleware/ServiceSingletons';
|
||||
import type {ApiTestHarness} from '@app/api/test/ApiTestHarness';
|
||||
import {createBuilder, createBuilderWithoutAuth} from '@app/api/test/TestRequestBuilder';
|
||||
import {DEFAULT_DOMAIN_MIGRATION_CONFIG} from '@fluxer/schema/src/domains/admin/DomainMigrationSchemas';
|
||||
import type {
|
||||
PasskeyBridgeFinishResponse,
|
||||
PasskeyBridgeStartResponse,
|
||||
PasskeyBridgeSudoRedeemResponse,
|
||||
} from '@fluxer/schema/src/domains/auth/PasskeyBridgeSchemas';
|
||||
|
||||
export const TARGET_ORIGIN = 'https://fluxer.com';
|
||||
export const LEGACY_ORIGIN = 'https://web.fluxer.app';
|
||||
export const LEGACY_RP_ID = 'localhost';
|
||||
export const TARGET_RP_ID = 'fluxer.com';
|
||||
|
||||
export interface PasskeyCredentialListItem {
|
||||
id: string;
|
||||
name: string;
|
||||
rp_id: string;
|
||||
}
|
||||
|
||||
export interface BridgeNonce {
|
||||
nonce: string;
|
||||
nonceHash: string;
|
||||
}
|
||||
|
||||
export function createBridgeNonce(): BridgeNonce {
|
||||
const nonce = randomBytes(32).toString('base64url');
|
||||
return {nonce, nonceHash: createHash('sha256').update(nonce).digest('hex')};
|
||||
}
|
||||
|
||||
export async function setDomainMigration(enabled: boolean, includedUserIds: Array<string> = []): Promise<void> {
|
||||
await getInstanceConfigRepository().setDomainMigrationConfig({
|
||||
...DEFAULT_DOMAIN_MIGRATION_CONFIG,
|
||||
enabled,
|
||||
included_user_ids: includedUserIds,
|
||||
});
|
||||
}
|
||||
|
||||
export async function registerPasskey(
|
||||
harness: ApiTestHarness,
|
||||
token: string,
|
||||
device: WebAuthnDevice,
|
||||
sudo: Record<string, unknown>,
|
||||
name: string,
|
||||
origin?: string,
|
||||
): Promise<void> {
|
||||
const optionsBuilder = createBuilder<WebAuthnRegistrationOptions>(harness, token)
|
||||
.post('/users/@me/mfa/webauthn/credentials/registration-options')
|
||||
.body(sudo);
|
||||
if (origin) optionsBuilder.header('origin', origin);
|
||||
const options = await optionsBuilder.execute();
|
||||
const registerBuilder = createBuilder(harness, token)
|
||||
.post('/users/@me/mfa/webauthn/credentials')
|
||||
.body({response: createRegistrationResponse(device, options, name), challenge: options.challenge, name})
|
||||
.expect(204);
|
||||
if (origin) registerBuilder.header('origin', origin);
|
||||
await registerBuilder.execute();
|
||||
}
|
||||
|
||||
export async function listPasskeys(harness: ApiTestHarness, token: string): Promise<Array<PasskeyCredentialListItem>> {
|
||||
return createBuilder<Array<PasskeyCredentialListItem>>(harness, token)
|
||||
.get('/users/@me/mfa/webauthn/credentials')
|
||||
.execute();
|
||||
}
|
||||
|
||||
export async function runNativeSudoBridge(
|
||||
harness: ApiTestHarness,
|
||||
token: string,
|
||||
device: WebAuthnDevice,
|
||||
nonce: BridgeNonce = createBridgeNonce(),
|
||||
): Promise<PasskeyBridgeSudoRedeemResponse> {
|
||||
const start = await createBuilder<PasskeyBridgeStartResponse>(harness, token)
|
||||
.post('/users/@me/passkey-bridge')
|
||||
.header('origin', TARGET_ORIGIN)
|
||||
.body({runner: 'native', nonce_hash: nonce.nonceHash})
|
||||
.execute();
|
||||
const {options} = await createBuilderWithoutAuth<{options: WebAuthnAuthenticationOptions}>(harness)
|
||||
.post(`/auth/passkey-bridge/${start.ceremony_id}/options`)
|
||||
.header('origin', TARGET_ORIGIN)
|
||||
.execute();
|
||||
const finish = await createBuilderWithoutAuth<PasskeyBridgeFinishResponse>(harness)
|
||||
.post(`/auth/passkey-bridge/${start.ceremony_id}/complete`)
|
||||
.header('origin', TARGET_ORIGIN)
|
||||
.body({response: createAuthenticationResponse(device, options)})
|
||||
.execute();
|
||||
return createBuilder<PasskeyBridgeSudoRedeemResponse>(harness, token)
|
||||
.post(`/users/@me/passkey-bridge/${start.ceremony_id}/redeem`)
|
||||
.header('origin', TARGET_ORIGIN)
|
||||
.body({nonce: nonce.nonce, completion_code: finish.completion_code})
|
||||
.execute();
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -100,6 +100,8 @@ export interface WebAuthnCredentialRow {
|
||||
created_at: Date;
|
||||
last_used_at: Nullish<Date>;
|
||||
version: number;
|
||||
rp_id: Nullish<string>;
|
||||
superseded_by: Nullish<string>;
|
||||
}
|
||||
|
||||
export interface EmailChangeTicketRow {
|
||||
@@ -193,6 +195,8 @@ export const WEBAUTHN_CREDENTIAL_COLUMNS = [
|
||||
'created_at',
|
||||
'last_used_at',
|
||||
'version',
|
||||
'rp_id',
|
||||
'superseded_by',
|
||||
] as const satisfies ReadonlyArray<keyof WebAuthnCredentialRow>;
|
||||
|
||||
export interface PhoneTokenRow {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -12,6 +12,8 @@ export class WebAuthnCredential {
|
||||
readonly createdAt: Date;
|
||||
readonly lastUsedAt: Date | null;
|
||||
readonly version: number;
|
||||
readonly rpId: string | null;
|
||||
readonly supersededBy: string | null;
|
||||
|
||||
constructor(row: WebAuthnCredentialRow) {
|
||||
this.credentialId = row.credential_id;
|
||||
@@ -22,6 +24,8 @@ export class WebAuthnCredential {
|
||||
this.createdAt = row.created_at;
|
||||
this.lastUsedAt = row.last_used_at ?? null;
|
||||
this.version = row.version;
|
||||
this.rpId = row.rp_id ?? null;
|
||||
this.supersededBy = row.superseded_by ?? null;
|
||||
}
|
||||
|
||||
toRow(userId: UserID): WebAuthnCredentialRow {
|
||||
@@ -35,6 +39,8 @@ export class WebAuthnCredential {
|
||||
created_at: this.createdAt,
|
||||
last_used_at: this.lastUsedAt,
|
||||
version: this.version,
|
||||
rp_id: this.rpId,
|
||||
superseded_by: this.supersededBy,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -104,6 +104,10 @@ export const AuthRateLimitConfigs = {
|
||||
bucket: 'mfa:webauthn:two_factor',
|
||||
config: {limit: 10, windowMs: ms('1 minute')},
|
||||
} as RouteRateLimitConfig,
|
||||
MFA_WEBAUTHN_MIGRATION: {
|
||||
bucket: 'mfa:webauthn:migration',
|
||||
config: {limit: 20, windowMs: ms('1 minute')},
|
||||
} as RouteRateLimitConfig,
|
||||
PHONE_SEND_VERIFICATION: {
|
||||
bucket: 'phone:send_verification',
|
||||
config: {limit: 5, windowMs: ms('1 minute')},
|
||||
@@ -132,6 +136,34 @@ 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,
|
||||
AUTH_PASSKEY_BRIDGE_START: {
|
||||
bucket: 'auth:passkey_bridge:start',
|
||||
config: {limit: 10, windowMs: ms('1 minute')},
|
||||
} as RouteRateLimitConfig,
|
||||
AUTH_PASSKEY_BRIDGE_CEREMONY: {
|
||||
bucket: 'auth:passkey_bridge:ceremony',
|
||||
config: {limit: 20, windowMs: ms('1 minute')},
|
||||
} as RouteRateLimitConfig,
|
||||
AUTH_PASSKEY_BRIDGE_REDEEM: {
|
||||
bucket: 'auth:passkey_bridge:redeem',
|
||||
config: {limit: 60, windowMs: ms('1 minute')},
|
||||
} as RouteRateLimitConfig,
|
||||
USER_PASSKEY_BRIDGE_START: {
|
||||
bucket: 'mfa:passkey_bridge:start',
|
||||
config: {limit: 10, windowMs: ms('1 minute')},
|
||||
} as RouteRateLimitConfig,
|
||||
USER_PASSKEY_BRIDGE_REDEEM: {
|
||||
bucket: 'mfa:passkey_bridge:redeem',
|
||||
config: {limit: 60, windowMs: ms('1 minute')},
|
||||
} as RouteRateLimitConfig,
|
||||
SUDO_WEBAUTHN_OPTIONS: {
|
||||
bucket: 'sudo:webauthn:options',
|
||||
config: {limit: 10, windowMs: ms('1 minute')},
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import {createHash} from 'node:crypto';
|
||||
import type {ApiContext} from '@app/api/ApiContext';
|
||||
import * as AuthSession from '@app/api/auth/AuthSession';
|
||||
import {visibleWebAuthnCredentials} from '@app/api/auth/services/PasskeyRelyingParty';
|
||||
import type {ChannelID, GuildID, UserID} from '@app/api/BrandedTypes';
|
||||
import {
|
||||
createChannelID,
|
||||
@@ -75,6 +76,7 @@ import {
|
||||
mapUserGuildSettingsToResponse,
|
||||
mapUserSettingsToResponse,
|
||||
mapUserToPrivateResponse,
|
||||
mapWebAuthnCredentialToResponse,
|
||||
} from '@app/api/user/UserMappers';
|
||||
import {isUserAdult} from '@app/api/utils/AgeUtils';
|
||||
import {deriveDominantAvatarColor} from '@app/api/utils/AvatarColorUtils';
|
||||
@@ -1199,12 +1201,9 @@ export class RpcService {
|
||||
longitude: geoipLongitude,
|
||||
rtc_regions: rtcRegions,
|
||||
webauthn_credentials: timeRpcStepSync(responseBuildSteps, 'map_webauthn_credentials', () =>
|
||||
userData.webAuthnCredentials.map((cred) => ({
|
||||
id: cred.credentialId,
|
||||
name: cred.name,
|
||||
created_at: cred.createdAt.toISOString(),
|
||||
last_used_at: cred.lastUsedAt?.toISOString() ?? null,
|
||||
})),
|
||||
visibleWebAuthnCredentials(userData.webAuthnCredentials).map((cred) =>
|
||||
mapWebAuthnCredentialToResponse(cred, Config.auth.passkeys.rpId),
|
||||
),
|
||||
),
|
||||
version,
|
||||
};
|
||||
|
||||
@@ -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`,
|
||||
}),
|
||||
},
|
||||
},
|
||||
|
||||
@@ -14,7 +14,7 @@ import {createRequestCache} from '@app/api/middleware/RequestCacheMiddleware';
|
||||
import {addGiftCodeDuration} from '@app/api/models/GiftCode';
|
||||
import type {User} from '@app/api/models/User';
|
||||
import type {IUserRepository} from '@app/api/user/IUserRepository';
|
||||
import {createPremiumClearPatch, getEffectivePremiumUntil} from '@app/api/user/UserHelpers';
|
||||
import {clearPerksSanitizedFlag, createPremiumClearPatch, getEffectivePremiumUntil} from '@app/api/user/UserHelpers';
|
||||
import {mapUserToPrivateResponse} from '@app/api/user/UserMappers';
|
||||
import {UserPremiumTypes} from '@fluxer/constants/src/UserConstants';
|
||||
import {MissingAccessError} from '@fluxer/errors/src/domains/core/MissingAccessError';
|
||||
@@ -62,6 +62,7 @@ export class StripePremiumService {
|
||||
premium_will_cancel: false,
|
||||
premium_billing_cycle: billingCycle,
|
||||
premium_grace_ends_at: null,
|
||||
premium_flags: clearPerksSanitizedFlag(user.premiumFlags),
|
||||
},
|
||||
user.toRow(),
|
||||
);
|
||||
@@ -90,6 +91,7 @@ export class StripePremiumService {
|
||||
premium_since: this.resolvePremiumSince(user.premiumSince, premiumSinceAnchor, now),
|
||||
premium_until: null,
|
||||
premium_lifetime_sequence: visionarySequence,
|
||||
premium_flags: clearPerksSanitizedFlag(user.premiumFlags),
|
||||
has_ever_purchased: hasEverPurchased,
|
||||
premium_will_cancel: false,
|
||||
premium_billing_cycle: null,
|
||||
@@ -127,6 +129,7 @@ export class StripePremiumService {
|
||||
};
|
||||
if ((user.premiumType ?? 0) <= 0) {
|
||||
patch.premium_type = premiumType;
|
||||
patch.premium_flags = clearPerksSanitizedFlag(user.premiumFlags);
|
||||
patch.premium_since = this.resolvePremiumSince(user.premiumSince, null, now);
|
||||
}
|
||||
if (hasEverPurchased && !user.hasEverPurchased) {
|
||||
|
||||
@@ -261,7 +261,7 @@ export class StripeRefundService {
|
||||
const subscriptionId = refund.metadata.subscription_id;
|
||||
if (subscriptionId) {
|
||||
try {
|
||||
await this.subscriptionService.cancelSubscriptionImmediately(user.id, 'self_serve_refund');
|
||||
await this.subscriptionService.cancelSubscriptionImmediately(user.id, 'self_serve_refund', subscriptionId);
|
||||
} catch (error) {
|
||||
Logger.error(
|
||||
{error, userId: user.id.toString(), subscriptionId},
|
||||
|
||||
@@ -174,7 +174,7 @@ export class StripeSubscriptionService {
|
||||
}
|
||||
}
|
||||
|
||||
async cancelSubscriptionImmediately(userId: UserID, reason?: string): Promise<void> {
|
||||
async cancelSubscriptionImmediately(userId: UserID, reason?: string, expectedSubscriptionId?: string): Promise<void> {
|
||||
if (!this.stripe) {
|
||||
throw new StripePaymentNotAvailableError();
|
||||
}
|
||||
@@ -185,6 +185,18 @@ export class StripeSubscriptionService {
|
||||
if (!user.stripeSubscriptionId) {
|
||||
throw new StripeNoActiveSubscriptionError();
|
||||
}
|
||||
if (expectedSubscriptionId && user.stripeSubscriptionId !== expectedSubscriptionId) {
|
||||
Logger.info(
|
||||
{
|
||||
userId: user.id.toString(),
|
||||
expectedSubscriptionId,
|
||||
currentSubscriptionId: user.stripeSubscriptionId,
|
||||
reason: reason ?? null,
|
||||
},
|
||||
'Skipping immediate cancellation because the target subscription is no longer the current one',
|
||||
);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const canceledSubscription = await this.stripe.subscriptions.cancel(
|
||||
user.stripeSubscriptionId,
|
||||
|
||||
@@ -487,4 +487,90 @@ describe('StripeRefundService self-serve refund', () => {
|
||||
expect(idempotencyKeys[1]).toContain('retry-1');
|
||||
});
|
||||
});
|
||||
describe('self-serve refund teardown targeting', () => {
|
||||
function trackingSubscriptionDeleteHandler(deleted: Array<string>) {
|
||||
return http.delete(`${STRIPE_API_BASE}/v1/subscriptions/:id`, ({params}) => {
|
||||
deleted.push(String(params.id));
|
||||
return HttpResponse.json({id: params.id, object: 'subscription', status: 'canceled'});
|
||||
});
|
||||
}
|
||||
|
||||
function buildRefundUpdatedEvent(opts: {
|
||||
eventId: string;
|
||||
refundId: string;
|
||||
userId: string;
|
||||
invoiceId: string;
|
||||
subscriptionId: string;
|
||||
}): StripeWebhookEventData {
|
||||
return {
|
||||
id: opts.eventId,
|
||||
type: 'refund.updated',
|
||||
data: {
|
||||
object: {
|
||||
id: opts.refundId,
|
||||
object: 'refund',
|
||||
status: 'succeeded',
|
||||
amount: 2500,
|
||||
currency: 'usd',
|
||||
metadata: {
|
||||
refund_kind: 'self_serve',
|
||||
user_id: opts.userId,
|
||||
invoice_id: opts.invoiceId,
|
||||
subscription_id: opts.subscriptionId,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test('leaves a newer subscription alone when the refunded one is no longer current', async () => {
|
||||
server.use(...createStripeApiHandlers().handlers);
|
||||
const deleted: Array<string> = [];
|
||||
server.use(trackingSubscriptionDeleteHandler(deleted));
|
||||
const account = await createTestAccount(harness);
|
||||
const userId = createUserID(BigInt(account.userId));
|
||||
await setStripeIds(harness, account, {
|
||||
stripe_customer_id: MOCK_CUSTOMER_ID,
|
||||
stripe_subscription_id: 'sub_bought_after_the_refund',
|
||||
});
|
||||
await sendWebhook(
|
||||
buildRefundUpdatedEvent({
|
||||
eventId: 'evt_stale_teardown',
|
||||
refundId: 're_stale_teardown',
|
||||
userId: account.userId,
|
||||
invoiceId: 'in_stale_teardown',
|
||||
subscriptionId: 'sub_refunded_and_already_gone',
|
||||
}),
|
||||
);
|
||||
expect(deleted).toEqual([]);
|
||||
const userRepository = new UserRepository();
|
||||
const user = await userRepository.findUnique(userId);
|
||||
expect(user!.stripeSubscriptionId).toBe('sub_bought_after_the_refund');
|
||||
});
|
||||
|
||||
test('cancels the subscription when the refunded one is still current', async () => {
|
||||
server.use(...createStripeApiHandlers().handlers);
|
||||
const deleted: Array<string> = [];
|
||||
server.use(trackingSubscriptionDeleteHandler(deleted));
|
||||
const account = await createTestAccount(harness);
|
||||
const userId = createUserID(BigInt(account.userId));
|
||||
await setStripeIds(harness, account, {
|
||||
stripe_customer_id: MOCK_CUSTOMER_ID,
|
||||
stripe_subscription_id: MOCK_SUBSCRIPTION_ID,
|
||||
});
|
||||
await sendWebhook(
|
||||
buildRefundUpdatedEvent({
|
||||
eventId: 'evt_current_teardown',
|
||||
refundId: 're_current_teardown',
|
||||
userId: account.userId,
|
||||
invoiceId: 'in_current_teardown',
|
||||
subscriptionId: MOCK_SUBSCRIPTION_ID,
|
||||
}),
|
||||
);
|
||||
expect(deleted).toEqual([MOCK_SUBSCRIPTION_ID]);
|
||||
const userRepository = new UserRepository();
|
||||
const user = await userRepository.findUnique(userId);
|
||||
expect(user!.stripeSubscriptionId).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -275,6 +275,10 @@ export function createPremiumClearPatch(): Partial<UserRow> {
|
||||
return mapExpiredPremiumFields(() => null) as Partial<UserRow>;
|
||||
}
|
||||
|
||||
export function clearPerksSanitizedFlag(premiumFlags: number): number {
|
||||
return premiumFlags & ~PremiumFlags.PERKS_SANITIZED;
|
||||
}
|
||||
|
||||
const PROFILE_SUBSTRING_EXEMPT_FLAGS = UserFlags.STAFF;
|
||||
|
||||
export function isProfileSubstringExempt(user: Pick<PremiumCheckable, 'flags'>): boolean {
|
||||
|
||||
@@ -9,6 +9,7 @@ import type {Relationship} from '@app/api/models/Relationship';
|
||||
import type {User} from '@app/api/models/User';
|
||||
import type {UserGuildSettings} from '@app/api/models/UserGuildSettings';
|
||||
import type {UserSettings} from '@app/api/models/UserSettings';
|
||||
import type {WebAuthnCredential} from '@app/api/models/WebAuthnCredential';
|
||||
import {canUseProfileTimezone, getRequiredActions} from '@app/api/user/UserHelpers';
|
||||
import {canUserAccessNsfwContent} from '@app/api/utils/AgeUtils';
|
||||
import type {ChannelMessageNotifications} from '@fluxer/constants/src/NotificationConstants';
|
||||
@@ -26,6 +27,7 @@ import {
|
||||
UserFlags,
|
||||
UserPremiumTypes,
|
||||
} from '@fluxer/constants/src/UserConstants';
|
||||
import type {WebAuthnCredentialResponse} from '@fluxer/schema/src/domains/auth/AuthSchemas';
|
||||
import type {
|
||||
RelationshipResponse,
|
||||
UserGuildSettingsResponse,
|
||||
@@ -420,3 +422,16 @@ export function mapUserGuildSettingsToResponse(settings: UserGuildSettings): Use
|
||||
version: settings.version,
|
||||
};
|
||||
}
|
||||
|
||||
export function mapWebAuthnCredentialToResponse(
|
||||
credential: WebAuthnCredential,
|
||||
legacyRpId: string,
|
||||
): WebAuthnCredentialResponse {
|
||||
return {
|
||||
id: credential.credentialId,
|
||||
name: credential.name,
|
||||
created_at: credential.createdAt.toISOString(),
|
||||
last_used_at: credential.lastUsedAt?.toISOString() ?? null,
|
||||
rp_id: credential.rpId ?? legacyRpId,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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});
|
||||
},
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {
|
||||
completePasskeyMigration,
|
||||
getPasskeyMigration,
|
||||
getPasskeyMigrationRegistrationOptions,
|
||||
} from '@app/api/auth/services/PasskeyMigrationService';
|
||||
import {requireSudoMode} from '@app/api/auth/services/SudoVerificationService';
|
||||
import {Config} from '@app/api/Config';
|
||||
import {DefaultUserOnly, LoginRequired, LoginRequiredAllowSuspicious} from '@app/api/middleware/AuthMiddleware';
|
||||
@@ -34,6 +39,10 @@ import {
|
||||
WebAuthnTwoFactorRequest,
|
||||
WebAuthnTwoFactorResponse,
|
||||
} from '@fluxer/schema/src/domains/auth/AuthSchemas';
|
||||
import {
|
||||
PasskeyMigrationCompleteRequest,
|
||||
PasskeyMigrationResponse,
|
||||
} from '@fluxer/schema/src/domains/auth/PasskeyMigrationSchemas';
|
||||
import {CredentialIdParam} from '@fluxer/schema/src/domains/common/CommonParamSchemas';
|
||||
import {EmptyBodyRequest} from '@fluxer/schema/src/domains/user/UserRequestSchemas';
|
||||
|
||||
@@ -347,7 +356,9 @@ export function UserAuthController(app: HonoApp) {
|
||||
await requireSudoMode(ctx, user, body, {
|
||||
issueSudoToken: false,
|
||||
});
|
||||
return ctx.json(await ctx.get('userAuthRequestService').generateWebAuthnRegistrationOptions(user));
|
||||
return ctx.json(
|
||||
await ctx.get('userAuthRequestService').generateWebAuthnRegistrationOptions(user, ctx.req.header('origin')),
|
||||
);
|
||||
},
|
||||
);
|
||||
app.post(
|
||||
@@ -433,6 +444,78 @@ export function UserAuthController(app: HonoApp) {
|
||||
return ctx.body(null, 204);
|
||||
},
|
||||
);
|
||||
app.get(
|
||||
'/users/@me/mfa/webauthn/migration',
|
||||
RateLimitMiddleware(RateLimitConfigs.MFA_WEBAUTHN_MIGRATION),
|
||||
LoginRequired,
|
||||
DefaultUserOnly,
|
||||
OpenAPI({
|
||||
operationId: 'get_webauthn_migration',
|
||||
summary: 'Get pending passkey update',
|
||||
responseSchema: PasskeyMigrationResponse,
|
||||
statusCode: 200,
|
||||
security: ['bearerToken', 'sessionToken'],
|
||||
tags: ['Users'],
|
||||
description:
|
||||
'Return the passkey this session can update to the new domain after using it within the last five minutes, or null.',
|
||||
}),
|
||||
async (ctx) => {
|
||||
return ctx.json(await getPasskeyMigration(ctx.get('apiContext'), ctx.get('user').id, ctx.get('authSession')));
|
||||
},
|
||||
);
|
||||
app.post(
|
||||
'/users/@me/mfa/webauthn/migration/registration-options',
|
||||
RateLimitMiddleware(RateLimitConfigs.MFA_WEBAUTHN_MIGRATION),
|
||||
LoginRequired,
|
||||
DefaultUserOnly,
|
||||
OpenAPI({
|
||||
operationId: 'get_webauthn_migration_registration_options',
|
||||
summary: 'Get passkey update registration options',
|
||||
responseSchema: WebAuthnChallengeResponse,
|
||||
statusCode: 200,
|
||||
security: ['bearerToken', 'sessionToken'],
|
||||
tags: ['Users'],
|
||||
description:
|
||||
'Generate registration options for the passkey that replaces the pending one. Requires a pending passkey update for this session.',
|
||||
}),
|
||||
async (ctx) => {
|
||||
return ctx.json(
|
||||
await getPasskeyMigrationRegistrationOptions(
|
||||
ctx.get('apiContext'),
|
||||
ctx.get('user').id,
|
||||
ctx.get('authSession'),
|
||||
ctx.req.header('origin'),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
app.post(
|
||||
'/users/@me/mfa/webauthn/migration',
|
||||
RateLimitMiddleware(RateLimitConfigs.MFA_WEBAUTHN_MIGRATION),
|
||||
LoginRequired,
|
||||
DefaultUserOnly,
|
||||
Validator('json', PasskeyMigrationCompleteRequest),
|
||||
OpenAPI({
|
||||
operationId: 'complete_webauthn_migration',
|
||||
summary: 'Complete passkey update',
|
||||
responseSchema: null,
|
||||
statusCode: 204,
|
||||
security: ['bearerToken', 'sessionToken'],
|
||||
tags: ['Users'],
|
||||
description:
|
||||
'Register the replacement passkey under the name of the pending one. The old passkey stops appearing in lists and is removed together with its replacement.',
|
||||
}),
|
||||
async (ctx) => {
|
||||
await completePasskeyMigration(
|
||||
ctx.get('apiContext'),
|
||||
ctx.get('user').id,
|
||||
ctx.get('authSession'),
|
||||
ctx.req.header('origin'),
|
||||
ctx.req.valid('json'),
|
||||
);
|
||||
return ctx.body(null, 204);
|
||||
},
|
||||
);
|
||||
app.put(
|
||||
'/users/@me/mfa/webauthn/two-factor',
|
||||
RateLimitMiddleware(RateLimitConfigs.MFA_WEBAUTHN_TWO_FACTOR),
|
||||
@@ -492,7 +575,9 @@ export function UserAuthController(app: HonoApp) {
|
||||
'Generate WebAuthn challenge for sudo mode verification using a registered security key or biometric device.',
|
||||
}),
|
||||
async (ctx) => {
|
||||
return ctx.json(await ctx.get('userAuthRequestService').getSudoWebAuthnOptions(ctx.get('user')));
|
||||
return ctx.json(
|
||||
await ctx.get('userAuthRequestService').getSudoWebAuthnOptions(ctx.get('user'), ctx.req.header('origin')),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -65,10 +65,12 @@ export interface IUserAuthRepository {
|
||||
counter: bigint,
|
||||
transports: Set<string> | null,
|
||||
name: string,
|
||||
rpId: string | null,
|
||||
): Promise<void>;
|
||||
updateWebAuthnCredentialCounter(userId: UserID, credentialId: string, counter: bigint): Promise<void>;
|
||||
updateWebAuthnCredentialLastUsed(userId: UserID, credentialId: string): Promise<void>;
|
||||
updateWebAuthnCredentialName(userId: UserID, credentialId: string, name: string): Promise<void>;
|
||||
setWebAuthnCredentialSupersededBy(userId: UserID, credentialId: string, supersededBy: string): Promise<void>;
|
||||
deleteWebAuthnCredential(userId: UserID, credentialId: string): Promise<void>;
|
||||
getUserIdByCredentialId(credentialId: string): Promise<UserID | null>;
|
||||
deleteAllWebAuthnCredentials(userId: UserID): Promise<void>;
|
||||
|
||||
@@ -193,8 +193,17 @@ export class UserAuthRepository implements IUserAuthRepository {
|
||||
counter: bigint,
|
||||
transports: Set<string> | null,
|
||||
name: string,
|
||||
rpId: string | null,
|
||||
): Promise<void> {
|
||||
return this.webAuthnRepository.createWebAuthnCredential(userId, credentialId, publicKey, counter, transports, name);
|
||||
return this.webAuthnRepository.createWebAuthnCredential(
|
||||
userId,
|
||||
credentialId,
|
||||
publicKey,
|
||||
counter,
|
||||
transports,
|
||||
name,
|
||||
rpId,
|
||||
);
|
||||
}
|
||||
|
||||
async updateWebAuthnCredentialCounter(userId: UserID, credentialId: string, counter: bigint): Promise<void> {
|
||||
@@ -209,6 +218,10 @@ export class UserAuthRepository implements IUserAuthRepository {
|
||||
return this.webAuthnRepository.updateWebAuthnCredentialName(userId, credentialId, name);
|
||||
}
|
||||
|
||||
async setWebAuthnCredentialSupersededBy(userId: UserID, credentialId: string, supersededBy: string): Promise<void> {
|
||||
return this.webAuthnRepository.setWebAuthnCredentialSupersededBy(userId, credentialId, supersededBy);
|
||||
}
|
||||
|
||||
async deleteWebAuthnCredential(userId: UserID, credentialId: string): Promise<void> {
|
||||
return this.webAuthnRepository.deleteWebAuthnCredential(userId, credentialId);
|
||||
}
|
||||
|
||||
@@ -407,8 +407,9 @@ export class UserRepository implements IUserRepositoryAggregate {
|
||||
counter: bigint,
|
||||
transports: Set<string> | null,
|
||||
name: string,
|
||||
rpId: string | null,
|
||||
): Promise<void> {
|
||||
return this.authRepo.createWebAuthnCredential(userId, credentialId, publicKey, counter, transports, name);
|
||||
return this.authRepo.createWebAuthnCredential(userId, credentialId, publicKey, counter, transports, name, rpId);
|
||||
}
|
||||
|
||||
async updateWebAuthnCredentialCounter(userId: UserID, credentialId: string, counter: bigint): Promise<void> {
|
||||
@@ -423,6 +424,10 @@ export class UserRepository implements IUserRepositoryAggregate {
|
||||
return this.authRepo.updateWebAuthnCredentialName(userId, credentialId, name);
|
||||
}
|
||||
|
||||
async setWebAuthnCredentialSupersededBy(userId: UserID, credentialId: string, supersededBy: string): Promise<void> {
|
||||
return this.authRepo.setWebAuthnCredentialSupersededBy(userId, credentialId, supersededBy);
|
||||
}
|
||||
|
||||
async deleteWebAuthnCredential(userId: UserID, credentialId: string): Promise<void> {
|
||||
return this.authRepo.deleteWebAuthnCredential(userId, credentialId);
|
||||
}
|
||||
|
||||
@@ -26,7 +26,7 @@ const FETCH_WEBAUTHN_CREDENTIALS_FOR_USER_CQL = WebAuthnCredentials.selectCql({
|
||||
export class WebAuthnRepository {
|
||||
async listWebAuthnCredentials(userId: UserID): Promise<Array<WebAuthnCredential>> {
|
||||
const credentials = await fetchMany<WebAuthnCredentialRow>(FETCH_WEBAUTHN_CREDENTIALS_CQL, {user_id: userId});
|
||||
return credentials.map((cred) => new WebAuthnCredential(cred));
|
||||
return credentials.filter((cred) => cred.public_key).map((cred) => new WebAuthnCredential(cred));
|
||||
}
|
||||
|
||||
async getWebAuthnCredential(userId: UserID, credentialId: string): Promise<WebAuthnCredential | null> {
|
||||
@@ -34,7 +34,7 @@ export class WebAuthnRepository {
|
||||
user_id: userId,
|
||||
credential_id: credentialId,
|
||||
});
|
||||
if (!cred) {
|
||||
if (!cred?.public_key) {
|
||||
return null;
|
||||
}
|
||||
return new WebAuthnCredential(cred);
|
||||
@@ -47,6 +47,7 @@ export class WebAuthnRepository {
|
||||
counter: bigint,
|
||||
transports: Set<string> | null,
|
||||
name: string,
|
||||
rpId: string | null,
|
||||
): Promise<void> {
|
||||
const credentialData = {
|
||||
user_id: userId,
|
||||
@@ -58,6 +59,8 @@ export class WebAuthnRepository {
|
||||
created_at: new Date(),
|
||||
last_used_at: null,
|
||||
version: 1 as const,
|
||||
rp_id: rpId,
|
||||
superseded_by: null,
|
||||
};
|
||||
await upsertOne(WebAuthnCredentials.insert(credentialData));
|
||||
await upsertOne(
|
||||
@@ -101,6 +104,17 @@ export class WebAuthnRepository {
|
||||
);
|
||||
}
|
||||
|
||||
async setWebAuthnCredentialSupersededBy(userId: UserID, credentialId: string, supersededBy: string): Promise<void> {
|
||||
await upsertOne(
|
||||
WebAuthnCredentials.patchByPk(
|
||||
{user_id: userId, credential_id: credentialId},
|
||||
{
|
||||
superseded_by: Db.set(supersededBy),
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
async deleteWebAuthnCredential(userId: UserID, credentialId: string): Promise<void> {
|
||||
await deleteOneOrMany(
|
||||
WebAuthnCredentials.deleteByPk({
|
||||
|
||||
@@ -4,12 +4,13 @@ import type {ApiContext} from '@app/api/ApiContext';
|
||||
import * as AuthMfa from '@app/api/auth/AuthMfa';
|
||||
import * as AuthPhone from '@app/api/auth/AuthPhone';
|
||||
import {requireEmailVerified} from '@app/api/auth/EmailVerificationUtils';
|
||||
import {visibleWebAuthnCredentials} from '@app/api/auth/services/PasskeyRelyingParty';
|
||||
import type {SudoVerificationResult} from '@app/api/auth/services/SudoVerificationService';
|
||||
import type {IGuildRepositoryAggregate} from '@app/api/guild/repositories/IGuildRepositoryAggregate';
|
||||
import type {User} from '@app/api/models/User';
|
||||
import type {IUserRepository} from '@app/api/user/IUserRepository';
|
||||
import * as UserAuth from '@app/api/user/services/UserAuth';
|
||||
import {mapUserToPrivateResponse} from '@app/api/user/UserMappers';
|
||||
import {mapUserToPrivateResponse, mapWebAuthnCredentialToResponse} from '@app/api/user/UserMappers';
|
||||
import {GuildVerificationLevel} from '@fluxer/constants/src/GuildConstants';
|
||||
import {UserAuthenticatorTypes} from '@fluxer/constants/src/UserConstants';
|
||||
import {PhoneAddNotEligibleError} from '@fluxer/errors/src/domains/auth/PhoneAddNotEligibleError';
|
||||
@@ -170,17 +171,16 @@ export class UserAuthRequestService {
|
||||
|
||||
async listWebAuthnCredentials(user: User): Promise<WebAuthnCredentialListResponse> {
|
||||
const credentials = await this.userRepository.listWebAuthnCredentials(user.id);
|
||||
return credentials.map((cred) => ({
|
||||
id: cred.credentialId,
|
||||
name: cred.name,
|
||||
created_at: cred.createdAt.toISOString(),
|
||||
last_used_at: cred.lastUsedAt?.toISOString() ?? null,
|
||||
}));
|
||||
const legacyRpId = this.apiContext.services.config.auth.passkeys.rpId;
|
||||
return visibleWebAuthnCredentials(credentials).map((cred) => mapWebAuthnCredentialToResponse(cred, legacyRpId));
|
||||
}
|
||||
|
||||
async generateWebAuthnRegistrationOptions(user: User): Promise<WebAuthnChallengeResponse> {
|
||||
async generateWebAuthnRegistrationOptions(
|
||||
user: User,
|
||||
origin: string | undefined,
|
||||
): Promise<WebAuthnChallengeResponse> {
|
||||
requireEmailVerified(user, 'mfa');
|
||||
const options = await AuthMfa.generateWebAuthnRegistrationOptions(this.apiContext, user.id);
|
||||
const options = await AuthMfa.generateWebAuthnRegistrationOptions(this.apiContext, user.id, origin);
|
||||
return this.toWebAuthnChallengeResponse(options);
|
||||
}
|
||||
|
||||
@@ -217,8 +217,8 @@ export class UserAuthRequestService {
|
||||
return AuthMfa.getAvailableMfaMethods(this.apiContext, user.id);
|
||||
}
|
||||
|
||||
async getSudoWebAuthnOptions(user: User): Promise<WebAuthnChallengeResponse> {
|
||||
const options = await AuthMfa.generateWebAuthnOptionsForSudo(this.apiContext, user.id);
|
||||
async getSudoWebAuthnOptions(user: User, origin: string | undefined): Promise<WebAuthnChallengeResponse> {
|
||||
const options = await AuthMfa.generateWebAuthnOptionsForSudo(this.apiContext, user.id, origin);
|
||||
return this.toWebAuthnChallengeResponse(options);
|
||||
}
|
||||
|
||||
|
||||
@@ -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});
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
getSubscriptionPremiumPeriodEnd,
|
||||
getSubscriptionStartDate,
|
||||
} from '@app/api/stripe/StripeSubscriptionPeriod';
|
||||
import {createPremiumClearPatch, getEffectivePremiumUntil} from '@app/api/user/UserHelpers';
|
||||
import {clearPerksSanitizedFlag, createPremiumClearPatch, getEffectivePremiumUntil} from '@app/api/user/UserHelpers';
|
||||
import {mapUserToPrivateResponse} from '@app/api/user/UserMappers';
|
||||
import {getWorkerDependencies} from '@app/api/worker/WorkerContext';
|
||||
import {PremiumFlags, UserPremiumTypes} from '@fluxer/constants/src/UserConstants';
|
||||
@@ -73,6 +73,10 @@ function buildStripePremiumRepairPatch(user: User, subscription: Stripe.Subscrip
|
||||
if (user.stripeSubscriptionId !== subscription.id) {
|
||||
patch.stripe_subscription_id = subscription.id;
|
||||
}
|
||||
const clearedPremiumFlags = clearPerksSanitizedFlag(user.premiumFlags);
|
||||
if (user.premiumFlags !== clearedPremiumFlags) {
|
||||
patch.premium_flags = clearedPremiumFlags;
|
||||
}
|
||||
if (subscriptionCustomerId && user.stripeCustomerId !== subscriptionCustomerId) {
|
||||
patch.stripe_customer_id = subscriptionCustomerId;
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import {NoopLogger} from '@app/api/test/mocks/NoopLogger';
|
||||
import type {UserRepository} from '@app/api/user/repositories/UserRepository';
|
||||
import processPremiumStateReconciliationQueue from '@app/api/worker/tasks/ProcessPremiumStateReconciliationQueue';
|
||||
import {clearWorkerDependencies, setWorkerDependenciesForTest} from '@app/api/worker/WorkerContext';
|
||||
import {PremiumFlags} from '@fluxer/constants/src/UserConstants';
|
||||
import type {WorkerTaskHelpers} from '@pkgs/worker/src/contracts/WorkerTask';
|
||||
import type Stripe from 'stripe';
|
||||
import {afterEach, describe, expect, test} from 'vitest';
|
||||
@@ -48,6 +49,28 @@ function createCancelledSubscription(endedAtMs: number): Stripe.Subscription {
|
||||
} as unknown as Stripe.Subscription;
|
||||
}
|
||||
|
||||
function createActiveSubscription(periodEndMs: number): Stripe.Subscription {
|
||||
return {
|
||||
id: 'sub_test',
|
||||
status: 'active',
|
||||
customer: 'cus_test',
|
||||
ended_at: null,
|
||||
canceled_at: null,
|
||||
cancel_at: null,
|
||||
cancel_at_period_end: false,
|
||||
trial_end: null,
|
||||
start_date: Math.floor((Date.now() - 200 * ONE_DAY_MS) / 1000),
|
||||
items: {
|
||||
data: [
|
||||
{
|
||||
current_period_end: Math.floor(periodEndMs / 1000),
|
||||
price: {recurring: {interval: 'month'}},
|
||||
},
|
||||
],
|
||||
},
|
||||
} as unknown as Stripe.Subscription;
|
||||
}
|
||||
|
||||
function createPaidInvoice(periodEndMs: number): Stripe.Invoice {
|
||||
return {
|
||||
id: 'in_test',
|
||||
@@ -299,4 +322,27 @@ describe('processPremiumStateReconciliationQueue', () => {
|
||||
expect(patches[0].premium_until).toBeNull();
|
||||
expect(patches[0].premium_since).toBeNull();
|
||||
});
|
||||
test('clears the perks-sanitized latch once the subscription is active again', async () => {
|
||||
const queueService = createQueueService();
|
||||
await queueService.enqueueUser(USER_ID, new Date(Date.now() - 1000));
|
||||
|
||||
const periodEndMs = Math.floor((Date.now() + 20 * ONE_DAY_MS) / 1000) * 1000;
|
||||
const user = createPremiumUser({
|
||||
premium_until: new Date(periodEndMs),
|
||||
premium_flags: PremiumFlags.PERKS_SANITIZED,
|
||||
});
|
||||
const {userRepository, patches, extras} = createCapturingDeps(user);
|
||||
|
||||
setWorkerDependenciesForTest({
|
||||
premiumStateReconciliationQueueService: queueService,
|
||||
stripe: createStripeStub(createActiveSubscription(periodEndMs), []),
|
||||
userRepository,
|
||||
...extras,
|
||||
});
|
||||
|
||||
await processPremiumStateReconciliationQueue({}, createHelpers());
|
||||
|
||||
expect(patches).toHaveLength(1);
|
||||
expect(patches[0].premium_flags).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -29,7 +29,7 @@ interface ForwardOriginChannel {
|
||||
interface BuildForwardDefaultDestinationsRequest {
|
||||
readonly frequentIds: ReadonlyArray<string>;
|
||||
readonly history: ReadonlyArray<string>;
|
||||
readonly isValid: (row: ForwardRowIdentity) => boolean;
|
||||
readonly accepts: (row: ForwardRowIdentity) => boolean;
|
||||
readonly mode: ForwardResultType | null;
|
||||
readonly origin: ForwardDestination | null;
|
||||
readonly pinned: ReadonlyArray<ForwardDestination>;
|
||||
@@ -51,7 +51,7 @@ export function resolveForwardOrigin(
|
||||
export function buildForwardDefaultDestinations({
|
||||
frequentIds,
|
||||
history,
|
||||
isValid,
|
||||
accepts,
|
||||
mode,
|
||||
origin,
|
||||
pinned,
|
||||
@@ -64,7 +64,7 @@ export function buildForwardDefaultDestinations({
|
||||
...history.slice(0, HISTORY_LIMIT).map((channelId) => resolveChannel(channelId)),
|
||||
...frequentIds.slice(0, FREQUENT_LIMIT).map((channelId) => resolveChannel(channelId)),
|
||||
];
|
||||
const rows = candidates.filter((row): row is ForwardRowIdentity => row != null && isValid(row));
|
||||
const rows = candidates.filter((row): row is ForwardRowIdentity => row != null && accepts(row));
|
||||
const originSelected =
|
||||
origin != null && selected.some((destination) => destination.type === origin.type && destination.id === origin.id);
|
||||
const hiddenIds = origin == null || originSelected ? [] : [origin.id];
|
||||
@@ -79,10 +79,10 @@ export function buildForwardDefaultDestinations({
|
||||
|
||||
export function filterForwardSearchRows(
|
||||
results: ReadonlyArray<ForwardRowIdentity>,
|
||||
isValid: (row: ForwardRowIdentity) => boolean,
|
||||
accepts: (row: ForwardRowIdentity) => boolean,
|
||||
): ReadonlyArray<ForwardDestinationRow> {
|
||||
return dedupeRows(
|
||||
results.filter((result) => isValid(result)),
|
||||
results.filter((result) => accepts(result)),
|
||||
[],
|
||||
);
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user