mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-10 12:42:27 +09:00
Compare commits
57
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
444dc2b7d4 | ||
|
|
e2d05a44a8 | ||
|
|
669bd3c581 | ||
|
|
30ba55bd4d | ||
|
|
d4e93d3e84 | ||
|
|
9def9fbef6 | ||
|
|
7c82ca1102 | ||
|
|
fa3fd0027c | ||
|
|
83c1710b47 | ||
|
|
7e1b934637 | ||
|
|
48cf56e732 | ||
|
|
33a118d12a | ||
|
|
6b52de6354 | ||
|
|
01f53a168d | ||
|
|
59840af1bf | ||
|
|
336b8b7dcd | ||
|
|
2df37450ae | ||
|
|
48d0034239 | ||
|
|
2fc97f4544 | ||
|
|
677ef8491e | ||
|
|
2bf3a610f4 | ||
|
|
6a6119ed1e | ||
|
|
86d92564c0 | ||
|
|
931327d1dc | ||
|
|
2edd0f188f | ||
|
|
858a2d9e2b | ||
|
|
cb55e62bd9 | ||
|
|
ed579aaeec | ||
|
|
0808bf680f | ||
|
|
e75ed31a4c | ||
|
|
b6e3fa47a8 | ||
|
|
690cca6edb | ||
|
|
f28937d86f | ||
|
|
6b04ad25b1 | ||
|
|
63980fca11 | ||
|
|
0d92584431 | ||
|
|
0e2b7a1a2b | ||
|
|
bbe55397c3 | ||
|
|
0b5514f663 | ||
|
|
380995cc19 | ||
|
|
e3522ec7be | ||
|
|
56bc0e5612 | ||
|
|
d501a1ea68 | ||
|
|
6e3739bb9b | ||
|
|
b4f789a5ba | ||
|
|
49761959b1 | ||
|
|
34e03c9732 | ||
|
|
58d6e2d4bf | ||
|
|
4766ce7974 | ||
|
|
9e6aa67834 | ||
|
|
57832208d5 | ||
|
|
b35c85fc54 | ||
|
|
7f58fba66d | ||
|
|
5b35d6da7b | ||
|
|
53b9f14f35 | ||
|
|
bb83a6c042 | ||
|
|
e7125e4e62 |
@@ -33,9 +33,9 @@ Fluxer is a free and open source instant messaging and VoIP chat app built for f
|
||||
|
||||
| Windows | macOS | Linux | Android | iOS |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| [Installer (x64)][win-setup-x64] | [Disk image][mac-dmg] | [Flathub][flathub] | [APK][android-apk] | [TestFlight][ios-testflight] |
|
||||
| [Installer (ARM64)][win-setup-arm64] | | [deb (x64)][linux-deb-x64] | [Obtainium][obtainium] | |
|
||||
| [Portable (x64)][win-portable-x64] | | [deb (ARM64)][linux-deb-arm64] | | |
|
||||
| [Installer (x64)][win-setup-x64] | [Disk image][mac-dmg] | [Flathub][flathub] | [Google Play (beta)][android-play] | [TestFlight][ios-testflight] |
|
||||
| [Installer (ARM64)][win-setup-arm64] | | [deb (x64)][linux-deb-x64] | [APK (beta)][android-apk] | |
|
||||
| [Portable (x64)][win-portable-x64] | | [deb (ARM64)][linux-deb-arm64] | [Obtainium (beta)][obtainium] | |
|
||||
| [Portable (ARM64)][win-portable-arm64] | | [rpm (x64)][linux-rpm-x64] | | |
|
||||
| | | [rpm (ARM64)][linux-rpm-arm64] | | |
|
||||
| | | [AppImage (x64)][linux-appimage-x64] | | |
|
||||
@@ -168,6 +168,7 @@ endorsement rights.
|
||||
[flatpak-ref]: https://pkgs.fluxer.com/flatpak/fluxer.flatpakref
|
||||
[flatpak-canary-ref]: https://pkgs.fluxer.com/flatpak/fluxer-canary.flatpakref
|
||||
[flathub]: https://flathub.org/apps/app.fluxer.Fluxer
|
||||
[android-play]: https://play.google.com/store/apps/details?id=com.fluxer
|
||||
[android-apk]: https://github.com/fluxerapp/flutter_client/releases
|
||||
[obtainium]: https://obtainium.imranr.dev/
|
||||
[ios-testflight]: https://testflight.apple.com/join/PKZR6pK9
|
||||
|
||||
@@ -10526,6 +10526,7 @@
|
||||
"voice_noise_suppression": {"$ref": "#/components/schemas/VoiceNoiseSuppressionConfigResponse"},
|
||||
"push_service_delivery": {"$ref": "#/components/schemas/PushServiceDeliveryConfigResponse"},
|
||||
"domain_migration": {"$ref": "#/components/schemas/DomainMigrationConfigResponse"},
|
||||
"altcha_captcha": {"$ref": "#/components/schemas/AltchaCaptchaConfigResponse"},
|
||||
"experiment_delivery": {"$ref": "#/components/schemas/ExperimentDeliveryConfigResponse"},
|
||||
"registration": {
|
||||
"type": "object",
|
||||
@@ -10955,6 +10956,7 @@
|
||||
"voice_noise_suppression",
|
||||
"push_service_delivery",
|
||||
"domain_migration",
|
||||
"altcha_captcha",
|
||||
"experiment_delivery",
|
||||
"registration",
|
||||
"self_hosted",
|
||||
@@ -11097,6 +11099,10 @@
|
||||
"nullable": true,
|
||||
"allOf": [{"$ref": "#/components/schemas/DomainMigrationConfigUpdateRequest"}]
|
||||
},
|
||||
"altcha_captcha": {
|
||||
"nullable": true,
|
||||
"allOf": [{"$ref": "#/components/schemas/AltchaCaptchaConfigUpdateRequest"}]
|
||||
},
|
||||
"experiment_delivery": {
|
||||
"nullable": true,
|
||||
"allOf": [{"$ref": "#/components/schemas/ExperimentDeliveryConfigUpdateRequest"}]
|
||||
@@ -15190,6 +15196,27 @@
|
||||
"poll_jitter_percent": {"type": "integer", "minimum": 0, "maximum": 50}
|
||||
}
|
||||
},
|
||||
"AltchaCaptchaConfigUpdateRequest": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"enabled": {"type": "boolean"},
|
||||
"rollout_basis_points": {"type": "integer", "minimum": 0, "maximum": 10000},
|
||||
"rollout_salt": {"type": "string", "minLength": 1, "maxLength": 64, "pattern": "^[\\x20-\\x7e]+$"},
|
||||
"included_user_ids": {
|
||||
"maxItems": 1000,
|
||||
"type": "array",
|
||||
"items": {"type": "string", "pattern": "^\\d{1,20}$"}
|
||||
},
|
||||
"excluded_user_ids": {
|
||||
"maxItems": 1000,
|
||||
"type": "array",
|
||||
"items": {"type": "string", "pattern": "^\\d{1,20}$"}
|
||||
},
|
||||
"anonymous_enabled": {"type": "boolean"},
|
||||
"cost": {"type": "integer", "minimum": 1000, "maximum": 100000},
|
||||
"max_counter": {"type": "integer", "minimum": 100, "maximum": 1000000}
|
||||
}
|
||||
},
|
||||
"DomainMigrationConfigUpdateRequest": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -15225,7 +15252,8 @@
|
||||
"maxItems": 1000,
|
||||
"type": "array",
|
||||
"items": {"type": "string", "pattern": "^\\d{1,20}$"}
|
||||
}
|
||||
},
|
||||
"relay_consent_accepted": {"type": "boolean"}
|
||||
}
|
||||
},
|
||||
"VoiceNoiseSuppressionConfigUpdateRequest": {
|
||||
@@ -15293,6 +15321,48 @@
|
||||
"required": ["poll_interval_seconds", "poll_jitter_percent"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"AltchaCaptchaConfigResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"enabled": {"default": false, "type": "boolean"},
|
||||
"config_version": {"default": 0, "type": "integer", "minimum": 0, "maximum": 9007199254740991},
|
||||
"rollout_basis_points": {"default": 0, "type": "integer", "minimum": 0, "maximum": 10000},
|
||||
"rollout_salt": {
|
||||
"default": "altcha-captcha-v1",
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 64,
|
||||
"pattern": "^[\\x20-\\x7e]+$"
|
||||
},
|
||||
"included_user_ids": {
|
||||
"default": [],
|
||||
"maxItems": 1000,
|
||||
"type": "array",
|
||||
"items": {"type": "string", "pattern": "^\\d{1,20}$"}
|
||||
},
|
||||
"excluded_user_ids": {
|
||||
"default": [],
|
||||
"maxItems": 1000,
|
||||
"type": "array",
|
||||
"items": {"type": "string", "pattern": "^\\d{1,20}$"}
|
||||
},
|
||||
"anonymous_enabled": {"default": false, "type": "boolean"},
|
||||
"cost": {"default": 5000, "type": "integer", "minimum": 1000, "maximum": 100000},
|
||||
"max_counter": {"default": 10000, "type": "integer", "minimum": 100, "maximum": 1000000}
|
||||
},
|
||||
"required": [
|
||||
"enabled",
|
||||
"config_version",
|
||||
"rollout_basis_points",
|
||||
"rollout_salt",
|
||||
"included_user_ids",
|
||||
"excluded_user_ids",
|
||||
"anonymous_enabled",
|
||||
"cost",
|
||||
"max_counter"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"DomainMigrationConfigResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -15357,7 +15427,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",
|
||||
@@ -15365,7 +15444,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
|
||||
},
|
||||
|
||||
@@ -27,6 +27,8 @@ pub struct InstanceConfigResponse {
|
||||
#[serde(default)]
|
||||
pub domain_migration: DomainMigrationConfigResponse,
|
||||
#[serde(default)]
|
||||
pub altcha_captcha: AltchaCaptchaConfigResponse,
|
||||
#[serde(default)]
|
||||
pub experiment_delivery: ExperimentDeliveryConfigResponse,
|
||||
}
|
||||
|
||||
@@ -453,6 +455,9 @@ impl VoiceE2eeScope {
|
||||
pub const EXPERIMENT_MAX_TARGETED_USERS: usize = 1_000;
|
||||
pub const PUSH_SERVICE_DELIVERY_DEFAULT_SALT: &str = "push-service-delivery-v1";
|
||||
pub const DOMAIN_MIGRATION_DEFAULT_SALT: &str = "domain-migration-v1";
|
||||
pub const ALTCHA_CAPTCHA_DEFAULT_SALT: &str = "altcha-captcha-v1";
|
||||
pub const ALTCHA_CAPTCHA_COST_RANGE: std::ops::RangeInclusive<u32> = 1_000..=100_000;
|
||||
pub const ALTCHA_CAPTCHA_MAX_COUNTER_RANGE: std::ops::RangeInclusive<u32> = 100..=1_000_000;
|
||||
pub const VOICE_NS_MAX_GUILD_OVERRIDES: usize = 200;
|
||||
|
||||
impl NoiseSuppressionBackend {
|
||||
@@ -552,6 +557,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 {
|
||||
@@ -563,6 +571,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,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -579,6 +590,8 @@ 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)]
|
||||
@@ -627,6 +640,56 @@ pub struct DomainMigrationConfigUpdateRequest {
|
||||
pub standalone_forwarding: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
#[serde(default)]
|
||||
pub struct AltchaCaptchaConfigResponse {
|
||||
pub enabled: bool,
|
||||
pub config_version: u64,
|
||||
pub rollout_basis_points: u32,
|
||||
pub rollout_salt: String,
|
||||
pub included_user_ids: Vec<String>,
|
||||
pub excluded_user_ids: Vec<String>,
|
||||
pub anonymous_enabled: bool,
|
||||
pub cost: u32,
|
||||
pub max_counter: u32,
|
||||
}
|
||||
|
||||
impl Default for AltchaCaptchaConfigResponse {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: false,
|
||||
config_version: 0,
|
||||
rollout_basis_points: 0,
|
||||
rollout_salt: ALTCHA_CAPTCHA_DEFAULT_SALT.to_owned(),
|
||||
included_user_ids: Vec::new(),
|
||||
excluded_user_ids: Vec::new(),
|
||||
anonymous_enabled: false,
|
||||
cost: 5_000,
|
||||
max_counter: 10_000,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Serialize)]
|
||||
pub struct AltchaCaptchaConfigUpdateRequest {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub enabled: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub rollout_basis_points: Option<u32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub rollout_salt: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub included_user_ids: Option<Vec<String>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub excluded_user_ids: Option<Vec<String>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub anonymous_enabled: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub cost: Option<u32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub max_counter: Option<u32>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
#[serde(default)]
|
||||
pub struct ExperimentDeliveryConfigResponse {
|
||||
@@ -747,6 +810,8 @@ pub struct InstanceConfigUpdateRequest {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub domain_migration: Option<DomainMigrationConfigUpdateRequest>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub altcha_captcha: Option<AltchaCaptchaConfigUpdateRequest>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub experiment_delivery: Option<ExperimentDeliveryConfigUpdateRequest>,
|
||||
}
|
||||
|
||||
@@ -1086,17 +1151,24 @@ mod tests {
|
||||
.expect("default noise config");
|
||||
let domain_migration = serde_json::from_value::<DomainMigrationConfigResponse>(json!({}))
|
||||
.expect("default domain migration config");
|
||||
let altcha_captcha = serde_json::from_value::<AltchaCaptchaConfigResponse>(json!({}))
|
||||
.expect("default altcha captcha config");
|
||||
let delivery = serde_json::from_value::<ExperimentDeliveryConfigResponse>(json!({}))
|
||||
.expect("default delivery config");
|
||||
let noise = serde_json::to_value(noise).expect("serializable noise config");
|
||||
let domain_migration =
|
||||
serde_json::to_value(domain_migration).expect("serializable domain migration config");
|
||||
let altcha_captcha =
|
||||
serde_json::to_value(altcha_captcha).expect("serializable altcha captcha config");
|
||||
let delivery = serde_json::to_value(delivery).expect("serializable delivery config");
|
||||
let generated_noise: generated_types::VoiceNoiseSuppressionConfigResponse =
|
||||
serde_json::from_value(noise.clone()).expect("generated noise config contract");
|
||||
let generated_domain_migration: generated_types::DomainMigrationConfigResponse =
|
||||
serde_json::from_value(domain_migration.clone())
|
||||
.expect("generated domain migration config contract");
|
||||
let generated_altcha_captcha: generated_types::AltchaCaptchaConfigResponse =
|
||||
serde_json::from_value(altcha_captcha.clone())
|
||||
.expect("generated altcha captcha config contract");
|
||||
let generated_delivery: generated_types::ExperimentDeliveryConfigResponse =
|
||||
serde_json::from_value(delivery.clone()).expect("generated delivery config contract");
|
||||
assert_eq!(
|
||||
@@ -1108,6 +1180,11 @@ mod tests {
|
||||
.expect("serializable generated domain migration config"),
|
||||
domain_migration
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::to_value(generated_altcha_captcha)
|
||||
.expect("serializable generated altcha captcha config"),
|
||||
altcha_captcha
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::to_value(generated_delivery)
|
||||
.expect("serializable generated delivery config"),
|
||||
@@ -1116,6 +1193,7 @@ mod tests {
|
||||
for (name, value) in [
|
||||
("VoiceNoiseSuppressionConfigResponse", noise),
|
||||
("DomainMigrationConfigResponse", domain_migration),
|
||||
("AltchaCaptchaConfigResponse", altcha_captcha),
|
||||
("ExperimentDeliveryConfigResponse", delivery),
|
||||
] {
|
||||
for (field, value) in value.as_object().expect("config object") {
|
||||
|
||||
@@ -4,24 +4,25 @@ use crate::{
|
||||
api::{
|
||||
client::AdminApiClient,
|
||||
types::{
|
||||
AppBrandingConfigUpdateRequest, AppLegalConfigUpdateRequest,
|
||||
AppPublicConfigUpdateRequest, AppRegistrationConfigUpdateRequest,
|
||||
AppSetupConfigUpdateRequest, CreateRegistrationUrlRequest,
|
||||
DeferredPhoneGateUpdateRequest, DomainMigrationConfigUpdateRequest,
|
||||
EXPERIMENT_MAX_TARGETED_USERS, ExperimentDeliveryConfigUpdateRequest,
|
||||
GatewayRolloutConfigUpdateRequest, GatewayRolloutMode,
|
||||
InstanceAttachmentDecayUpdateRequest, InstanceBlueskyIntegrationUpdateRequest,
|
||||
InstanceBlueskyKeyIntegrationUpdateRequest, InstanceCaptchaIntegrationUpdateRequest,
|
||||
InstanceConfigUpdateRequest, InstanceEmailIntegrationUpdateRequest,
|
||||
InstanceEmailSmtpIntegrationUpdateRequest, InstanceEmailSmtpTestRequest,
|
||||
InstanceGifIntegrationUpdateRequest, InstanceIntegrationsUpdateRequest,
|
||||
InstanceMediaUpdateRequest, InstancePolicyUpdateRequest,
|
||||
InstanceRegistrationConfigUpdateRequest, InstanceServicesUpdateRequest,
|
||||
InstanceYoutubeIntegrationUpdateRequest, LimitConfigUpdateRequest, LimitRule,
|
||||
LimitRuleFilters, NoiseSuppressionBackend, PremiumMode,
|
||||
PushServiceDeliveryConfigUpdateRequest, RegistrationMode, SsoConfigUpdateRequest,
|
||||
VOICE_NS_MAX_GUILD_OVERRIDES, VoiceE2eeScope, VoiceNoiseSuppressionConfigUpdateRequest,
|
||||
VoiceNoiseSuppressionGuildOverride,
|
||||
ALTCHA_CAPTCHA_COST_RANGE, ALTCHA_CAPTCHA_MAX_COUNTER_RANGE,
|
||||
AltchaCaptchaConfigUpdateRequest, AppBrandingConfigUpdateRequest,
|
||||
AppLegalConfigUpdateRequest, AppPublicConfigUpdateRequest,
|
||||
AppRegistrationConfigUpdateRequest, AppSetupConfigUpdateRequest,
|
||||
CreateRegistrationUrlRequest, DeferredPhoneGateUpdateRequest,
|
||||
DomainMigrationConfigUpdateRequest, EXPERIMENT_MAX_TARGETED_USERS,
|
||||
ExperimentDeliveryConfigUpdateRequest, GatewayRolloutConfigUpdateRequest,
|
||||
GatewayRolloutMode, InstanceAttachmentDecayUpdateRequest,
|
||||
InstanceBlueskyIntegrationUpdateRequest, InstanceBlueskyKeyIntegrationUpdateRequest,
|
||||
InstanceCaptchaIntegrationUpdateRequest, InstanceConfigUpdateRequest,
|
||||
InstanceEmailIntegrationUpdateRequest, InstanceEmailSmtpIntegrationUpdateRequest,
|
||||
InstanceEmailSmtpTestRequest, InstanceGifIntegrationUpdateRequest,
|
||||
InstanceIntegrationsUpdateRequest, InstanceMediaUpdateRequest,
|
||||
InstancePolicyUpdateRequest, InstanceRegistrationConfigUpdateRequest,
|
||||
InstanceServicesUpdateRequest, InstanceYoutubeIntegrationUpdateRequest,
|
||||
LimitConfigUpdateRequest, LimitRule, LimitRuleFilters, NoiseSuppressionBackend,
|
||||
PremiumMode, PushServiceDeliveryConfigUpdateRequest, RegistrationMode,
|
||||
SsoConfigUpdateRequest, VOICE_NS_MAX_GUILD_OVERRIDES, VoiceE2eeScope,
|
||||
VoiceNoiseSuppressionConfigUpdateRequest, VoiceNoiseSuppressionGuildOverride,
|
||||
},
|
||||
},
|
||||
config::AdminConfig,
|
||||
@@ -216,6 +217,10 @@ pub async fn instance_config_post(
|
||||
Ok(update) => instance_config_result(client.update_instance_config(&update).await),
|
||||
Err(message) => FlashData::error(message),
|
||||
},
|
||||
"update_altcha_captcha" => match build_altcha_captcha_update(&form) {
|
||||
Ok(update) => instance_config_result(client.update_instance_config(&update).await),
|
||||
Err(message) => FlashData::error(message),
|
||||
},
|
||||
"update_experiment_delivery" => match build_experiment_delivery_update(&form) {
|
||||
Ok(update) => instance_config_result(client.update_instance_config(&update).await),
|
||||
Err(message) => FlashData::error(message),
|
||||
@@ -680,6 +685,9 @@ 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()
|
||||
})
|
||||
@@ -725,6 +733,50 @@ fn build_domain_migration_update(
|
||||
})
|
||||
}
|
||||
|
||||
fn build_altcha_captcha_update(
|
||||
form: &MultiValueForm,
|
||||
) -> Result<InstanceConfigUpdateRequest, String> {
|
||||
Ok(InstanceConfigUpdateRequest {
|
||||
altcha_captcha: Some(AltchaCaptchaConfigUpdateRequest {
|
||||
enabled: Some(form.bool_value("altcha_captcha_enabled")),
|
||||
rollout_basis_points: parse_form_number(
|
||||
form,
|
||||
"altcha_captcha_rollout_basis_points",
|
||||
"Rollout basis points",
|
||||
0,
|
||||
EXPERIMENT_ROLLOUT_BASIS_POINTS_MAX,
|
||||
)?,
|
||||
rollout_salt: parse_ascii_experiment_rollout_salt(form, "altcha_captcha_rollout_salt")?,
|
||||
included_user_ids: Some(parse_experiment_user_ids(
|
||||
form.first("altcha_captcha_included_user_ids")
|
||||
.unwrap_or_default(),
|
||||
"Included user IDs",
|
||||
)?),
|
||||
excluded_user_ids: Some(parse_experiment_user_ids(
|
||||
form.first("altcha_captcha_excluded_user_ids")
|
||||
.unwrap_or_default(),
|
||||
"Excluded user IDs",
|
||||
)?),
|
||||
anonymous_enabled: Some(form.bool_value("altcha_captcha_anonymous_enabled")),
|
||||
cost: parse_form_number(
|
||||
form,
|
||||
"altcha_captcha_cost",
|
||||
"Cost",
|
||||
*ALTCHA_CAPTCHA_COST_RANGE.start(),
|
||||
*ALTCHA_CAPTCHA_COST_RANGE.end(),
|
||||
)?,
|
||||
max_counter: parse_form_number(
|
||||
form,
|
||||
"altcha_captcha_max_counter",
|
||||
"Maximum counter",
|
||||
*ALTCHA_CAPTCHA_MAX_COUNTER_RANGE.start(),
|
||||
*ALTCHA_CAPTCHA_MAX_COUNTER_RANGE.end(),
|
||||
)?,
|
||||
}),
|
||||
..Default::default()
|
||||
})
|
||||
}
|
||||
|
||||
fn build_experiment_delivery_update(
|
||||
form: &MultiValueForm,
|
||||
) -> Result<InstanceConfigUpdateRequest, String> {
|
||||
@@ -1731,6 +1783,94 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[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_altcha_captcha_update_reads_the_rollout_and_difficulty_fields() {
|
||||
let form = MultiValueForm::parse(
|
||||
b"altcha_captcha_enabled=true&altcha_captcha_rollout_basis_points=%20500%20&altcha_captcha_rollout_salt=%20altcha-captcha-v2%20&altcha_captcha_included_user_ids=1500000000000000001&altcha_captcha_excluded_user_ids=1500000000000000002&altcha_captcha_anonymous_enabled=true&altcha_captcha_cost=2000&altcha_captcha_max_counter=%20400%20",
|
||||
);
|
||||
let update = build_altcha_captcha_update(&form)
|
||||
.expect("valid form")
|
||||
.altcha_captcha
|
||||
.expect("altcha captcha update");
|
||||
assert_eq!(update.enabled, Some(true));
|
||||
assert_eq!(update.rollout_basis_points, Some(500));
|
||||
assert_eq!(update.rollout_salt, Some("altcha-captcha-v2".to_owned()));
|
||||
assert_eq!(
|
||||
update.included_user_ids,
|
||||
Some(vec!["1500000000000000001".to_owned()])
|
||||
);
|
||||
assert_eq!(
|
||||
update.excluded_user_ids,
|
||||
Some(vec!["1500000000000000002".to_owned()])
|
||||
);
|
||||
assert_eq!(update.anonymous_enabled, Some(true));
|
||||
assert_eq!(update.cost, Some(2000));
|
||||
assert_eq!(update.max_counter, Some(400));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_altcha_captcha_update_leaves_the_feature_inert_when_nothing_is_submitted() {
|
||||
let form = MultiValueForm::parse(b"_csrf=token");
|
||||
let request = build_altcha_captcha_update(&form).expect("valid form");
|
||||
assert_eq!(
|
||||
serde_json::to_value(request).expect("serializable update"),
|
||||
serde_json::json!({"altcha_captcha": {
|
||||
"enabled": false,
|
||||
"included_user_ids": [],
|
||||
"excluded_user_ids": [],
|
||||
"anonymous_enabled": false,
|
||||
}})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_altcha_captcha_update_rejects_difficulty_outside_the_supported_range() {
|
||||
for (form, message) in [
|
||||
(
|
||||
"altcha_captcha_cost=999",
|
||||
"Cost must be a whole number between 1000 and 100000",
|
||||
),
|
||||
(
|
||||
"altcha_captcha_max_counter=1000001",
|
||||
"Maximum counter must be a whole number between 100 and 1000000",
|
||||
),
|
||||
(
|
||||
"altcha_captcha_rollout_basis_points=10001",
|
||||
"Rollout basis points must be a whole number between 0 and 10000",
|
||||
),
|
||||
] {
|
||||
let form = MultiValueForm::parse(form.as_bytes());
|
||||
assert_eq!(
|
||||
build_altcha_captcha_update(&form).expect_err("invalid field"),
|
||||
message
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_experiment_delivery_update_leaves_both_fields_unchanged_when_absent() {
|
||||
let form = MultiValueForm::parse(b"_csrf=token");
|
||||
|
||||
@@ -2,13 +2,15 @@
|
||||
|
||||
use crate::{
|
||||
api::types::{
|
||||
AppPublicConfigResponse, DOMAIN_MIGRATION_DEFAULT_SALT, DomainMigrationConfigResponse,
|
||||
EXPERIMENT_MAX_TARGETED_USERS, ExperimentDeliveryConfigResponse,
|
||||
GatewayRolloutConfigResponse, InstanceConfigResponse, InstanceIntegrationsResponse,
|
||||
InstanceMediaResponse, InstancePolicyResponse, InstanceRegistrationResponse,
|
||||
LimitConfigResponse, NoiseSuppressionBackend, PUSH_SERVICE_DELIVERY_DEFAULT_SALT,
|
||||
PendingRegistrationResponse, PushServiceDeliveryConfigResponse, RegistrationUrlResponse,
|
||||
SsoConfigResponse, VOICE_NS_MAX_GUILD_OVERRIDES, VoiceNoiseSuppressionConfigResponse,
|
||||
ALTCHA_CAPTCHA_COST_RANGE, ALTCHA_CAPTCHA_DEFAULT_SALT, ALTCHA_CAPTCHA_MAX_COUNTER_RANGE,
|
||||
AltchaCaptchaConfigResponse, AppPublicConfigResponse, DOMAIN_MIGRATION_DEFAULT_SALT,
|
||||
DomainMigrationConfigResponse, EXPERIMENT_MAX_TARGETED_USERS,
|
||||
ExperimentDeliveryConfigResponse, GatewayRolloutConfigResponse, InstanceConfigResponse,
|
||||
InstanceIntegrationsResponse, InstanceMediaResponse, InstancePolicyResponse,
|
||||
InstanceRegistrationResponse, LimitConfigResponse, NoiseSuppressionBackend,
|
||||
PUSH_SERVICE_DELIVERY_DEFAULT_SALT, PendingRegistrationResponse,
|
||||
PushServiceDeliveryConfigResponse, RegistrationUrlResponse, SsoConfigResponse,
|
||||
VOICE_NS_MAX_GUILD_OVERRIDES, VoiceNoiseSuppressionConfigResponse,
|
||||
},
|
||||
config::AdminConfig,
|
||||
middleware::auth::AuthContext,
|
||||
@@ -151,6 +153,7 @@ pub fn instance_config_page(
|
||||
(voice_noise_suppression_section(base, csrf_token, &instance_config.voice_noise_suppression))
|
||||
(push_service_delivery_section(base, csrf_token, &instance_config.push_service_delivery))
|
||||
(domain_migration_section(base, csrf_token, &instance_config.domain_migration))
|
||||
(altcha_captcha_section(base, csrf_token, &instance_config.altcha_captcha))
|
||||
(experiment_delivery_section(base, csrf_token, &instance_config.experiment_delivery))
|
||||
@if let Some(limit_config) = limit_config {
|
||||
(limit_config_section(base, limit_config))
|
||||
@@ -1191,6 +1194,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. \
|
||||
@@ -1219,6 +1230,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",
|
||||
@@ -1421,6 +1454,145 @@ fn domain_migration_section(
|
||||
)
|
||||
}
|
||||
|
||||
fn altcha_captcha_section(
|
||||
base: &str,
|
||||
csrf_token: &str,
|
||||
altcha_captcha: &AltchaCaptchaConfigResponse,
|
||||
) -> Markup {
|
||||
let status = if altcha_captcha.enabled {
|
||||
("Live", BadgeVariant::Success)
|
||||
} else {
|
||||
("Inert", BadgeVariant::Default)
|
||||
};
|
||||
let included_user_ids = altcha_captcha.included_user_ids.join("\n");
|
||||
let excluded_user_ids = altcha_captcha.excluded_user_ids.join("\n");
|
||||
section_card_with_description(
|
||||
"ALTCHA Captcha",
|
||||
"Replaces the configured captcha provider with an ALTCHA proof-of-work check for the \
|
||||
selected requesters. The API issues and verifies every challenge itself, so no third \
|
||||
party is involved. Requests only need a captcha where one is already required, so this \
|
||||
does nothing while captcha is off for the instance.",
|
||||
html! {
|
||||
form method="post" action={(base) "/instance-config?action=update_altcha_captcha"} {
|
||||
(csrf_input(csrf_token))
|
||||
div class="space-y-6" {
|
||||
div class="flex flex-wrap items-center gap-2" {
|
||||
h3 class="text-sm font-semibold text-neutral-900" { "Master switch" }
|
||||
(badge(status.0, status.1))
|
||||
span class="text-xs text-neutral-500" {
|
||||
"Config version " (altcha_captcha.config_version)
|
||||
}
|
||||
}
|
||||
(checkbox(
|
||||
"altcha_captcha_enabled",
|
||||
"true",
|
||||
"Serve ALTCHA to the selected requesters",
|
||||
altcha_captcha.enabled,
|
||||
true,
|
||||
))
|
||||
p class="text-xs text-neutral-500" {
|
||||
"Off is the safe state and the kill switch. With this unchecked every \
|
||||
requester gets the configured provider and ALTCHA answers are rejected."
|
||||
}
|
||||
|
||||
h3 class="text-sm font-semibold text-neutral-900" { "Logged-out requests" }
|
||||
(checkbox(
|
||||
"altcha_captcha_anonymous_enabled",
|
||||
"true",
|
||||
"Serve ALTCHA to logged-out requests",
|
||||
altcha_captcha.anonymous_enabled,
|
||||
true,
|
||||
))
|
||||
p class="text-xs text-neutral-500" {
|
||||
"Covers registration, login and password reset. These requests have no \
|
||||
account to bucket, so this switch applies to all of them at once."
|
||||
}
|
||||
|
||||
h3 class="text-sm font-semibold text-neutral-900" { "Rollout" }
|
||||
(number_field(
|
||||
"altcha_captcha_rollout_basis_points",
|
||||
"Rollout (basis points)",
|
||||
&altcha_captcha.rollout_basis_points.to_string(),
|
||||
Some(0), Some(10000), "1",
|
||||
Some("Share of logged-in users bucketed into ALTCHA, in basis points: 0 is nobody, 100 is 1%, 10000 is everybody."),
|
||||
))
|
||||
div class="flex flex-col gap-2" {
|
||||
(text_input(
|
||||
"altcha_captcha_rollout_salt",
|
||||
"Rollout Salt",
|
||||
&altcha_captcha.rollout_salt,
|
||||
ALTCHA_CAPTCHA_DEFAULT_SALT,
|
||||
))
|
||||
p class="text-xs text-neutral-500" {
|
||||
"Seeds the bucketing hash. Changing it reshuffles which users fall \
|
||||
inside the percentage above."
|
||||
}
|
||||
}
|
||||
div class="flex flex-col gap-2" {
|
||||
(textarea_input(
|
||||
"altcha_captcha_included_user_ids",
|
||||
"Always-on User IDs",
|
||||
"1500000000000000001\n1500000000000000002",
|
||||
&included_user_ids,
|
||||
4,
|
||||
false,
|
||||
))
|
||||
(entry_count_hint(
|
||||
altcha_captcha.included_user_ids.len(),
|
||||
EXPERIMENT_MAX_TARGETED_USERS,
|
||||
))
|
||||
p class="text-xs text-neutral-500" {
|
||||
"One snowflake per line, or comma separated. These users get ALTCHA \
|
||||
regardless of the percentage above. Invalid entries prevent the save."
|
||||
}
|
||||
}
|
||||
div class="flex flex-col gap-2" {
|
||||
(textarea_input(
|
||||
"altcha_captcha_excluded_user_ids",
|
||||
"Never-on User IDs",
|
||||
"1500000000000000003\n1500000000000000004",
|
||||
&excluded_user_ids,
|
||||
4,
|
||||
false,
|
||||
))
|
||||
(entry_count_hint(
|
||||
altcha_captcha.excluded_user_ids.len(),
|
||||
EXPERIMENT_MAX_TARGETED_USERS,
|
||||
))
|
||||
p class="text-xs text-neutral-500" {
|
||||
"Same format. Exclusion wins over both the always-on list and the percentage."
|
||||
}
|
||||
}
|
||||
|
||||
h3 class="text-sm font-semibold text-neutral-900" { "Difficulty" }
|
||||
(number_field(
|
||||
"altcha_captcha_cost",
|
||||
"Cost (PBKDF2 iterations per attempt)",
|
||||
&altcha_captcha.cost.to_string(),
|
||||
Some(*ALTCHA_CAPTCHA_COST_RANGE.start()),
|
||||
Some(*ALTCHA_CAPTCHA_COST_RANGE.end()),
|
||||
"1",
|
||||
Some("The API spends one attempt at this cost to issue each challenge."),
|
||||
))
|
||||
(number_field(
|
||||
"altcha_captcha_max_counter",
|
||||
"Maximum counter",
|
||||
&altcha_captcha.max_counter.to_string(),
|
||||
Some(*ALTCHA_CAPTCHA_MAX_COUNTER_RANGE.start()),
|
||||
Some(*ALTCHA_CAPTCHA_MAX_COUNTER_RANGE.end()),
|
||||
"1",
|
||||
Some("Each challenge hides its answer between half this value and this value. The client tries counters from 0 until it finds it, so solve time grows with cost times this value. At the defaults a recent laptop takes about 3 seconds."),
|
||||
))
|
||||
|
||||
(form_actions(html! {
|
||||
(submit_button("Save ALTCHA Configuration"))
|
||||
}))
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
fn experiment_delivery_section(
|
||||
base: &str,
|
||||
csrf_token: &str,
|
||||
@@ -2086,6 +2258,29 @@ mod tests {
|
||||
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,10 @@ 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,
|
||||
@@ -428,6 +431,18 @@ fn deserialize_instance_config_response_with_unknown_keys() {
|
||||
"anonymous_rollout_basis_points": 100,
|
||||
"standalone_forwarding": true
|
||||
},
|
||||
"altcha_captcha": {
|
||||
"enabled": true,
|
||||
"config_version": 3,
|
||||
"rollout_basis_points": 500,
|
||||
"rollout_salt": "altcha-captcha-v1",
|
||||
"included_user_ids": [],
|
||||
"excluded_user_ids": ["1500000000000000003"],
|
||||
"anonymous_enabled": true,
|
||||
"cost": 5000,
|
||||
"max_counter": 10000,
|
||||
"future_altcha_knob": "argon2id"
|
||||
},
|
||||
"experiment_delivery": {"poll_interval_seconds": 300, "poll_jitter_percent": 15},
|
||||
"registration": {
|
||||
"mode": "open",
|
||||
@@ -564,6 +579,12 @@ fn deserialize_instance_config_response_with_unknown_keys() {
|
||||
assert_eq!(resp.domain_migration.included_user_ids.len(), 1);
|
||||
assert_eq!(resp.domain_migration.anonymous_rollout_basis_points, 100);
|
||||
assert!(resp.domain_migration.standalone_forwarding);
|
||||
assert!(resp.push_service_delivery.relay_consent_accepted);
|
||||
assert!(resp.altcha_captcha.enabled);
|
||||
assert_eq!(resp.altcha_captcha.config_version, 3);
|
||||
assert!(resp.altcha_captcha.anonymous_enabled);
|
||||
assert_eq!(resp.altcha_captcha.excluded_user_ids.len(), 1);
|
||||
assert_eq!(resp.altcha_captcha.max_counter, 10000);
|
||||
assert_eq!(resp.experiment_delivery.poll_interval_seconds, 300);
|
||||
assert!(resp.policy.single_community_guild_id.is_none());
|
||||
assert_eq!(resp.policy.services.gif_enabled, Some(true));
|
||||
@@ -596,6 +617,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#"{
|
||||
|
||||
@@ -56,6 +56,7 @@
|
||||
"@simplewebauthn/server": "catalog:",
|
||||
"@types/node": "catalog:",
|
||||
"@vvo/tzdb": "catalog:",
|
||||
"altcha-lib": "catalog:",
|
||||
"archiver": "catalog:",
|
||||
"argon2": "catalog:",
|
||||
"bowser": "catalog:",
|
||||
|
||||
@@ -11,7 +11,9 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@fluxer/logger": "workspace:*",
|
||||
"itty-time": "catalog:"
|
||||
"altcha-lib": "catalog:",
|
||||
"itty-time": "catalog:",
|
||||
"zod": "catalog:"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "catalog:",
|
||||
|
||||
@@ -5,7 +5,7 @@ export interface VerifyCaptchaParams {
|
||||
remoteIp?: string;
|
||||
}
|
||||
|
||||
export type CaptchaProviderType = 'hcaptcha' | 'recaptcha' | 'turnstile' | 'test' | 'unavailable';
|
||||
export type CaptchaProviderType = 'hcaptcha' | 'recaptcha' | 'turnstile' | 'altcha' | 'test' | 'unavailable';
|
||||
|
||||
export interface ICaptchaProvider {
|
||||
readonly type: CaptchaProviderType;
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import type {LoggerInterface} from '@fluxer/logger/src/LoggerInterface';
|
||||
import type {CaptchaProviderType, ICaptchaProvider, VerifyCaptchaParams} from '@pkgs/captcha/src/ICaptchaProvider';
|
||||
import {createChallenge, randomInt, verifySolution} from 'altcha-lib';
|
||||
import {deriveKey} from 'altcha-lib/algorithms/pbkdf2';
|
||||
import type {Challenge} from 'altcha-lib/types';
|
||||
import {ms} from 'itty-time';
|
||||
import {z} from 'zod';
|
||||
|
||||
export const ALTCHA_ALGORITHM = 'PBKDF2/SHA-256';
|
||||
const ALTCHA_CHALLENGE_TTL_MS = ms('10 minutes');
|
||||
const ALTCHA_MAX_TOKEN_LENGTH = 4096;
|
||||
const HEX_PATTERN = /^[0-9a-f]+$/u;
|
||||
|
||||
const AltchaPayloadSchema = z.object({
|
||||
challenge: z.object({
|
||||
parameters: z.looseObject({
|
||||
algorithm: z.literal(ALTCHA_ALGORITHM),
|
||||
nonce: z.string().regex(HEX_PATTERN),
|
||||
salt: z.string().regex(HEX_PATTERN),
|
||||
cost: z.number().int().positive(),
|
||||
keyLength: z.number().int().positive(),
|
||||
keyPrefix: z.string().regex(HEX_PATTERN),
|
||||
keySignature: z.string().regex(HEX_PATTERN),
|
||||
expiresAt: z.number().int().positive(),
|
||||
}),
|
||||
signature: z.string().regex(HEX_PATTERN),
|
||||
}),
|
||||
solution: z.object({
|
||||
counter: z.number().int().min(0),
|
||||
derivedKey: z.string().regex(HEX_PATTERN),
|
||||
time: z.number().optional(),
|
||||
}),
|
||||
});
|
||||
|
||||
type AltchaPayload = z.infer<typeof AltchaPayloadSchema>;
|
||||
|
||||
export interface AltchaProviderOptions {
|
||||
hmacSignatureSecret: string;
|
||||
hmacKeySignatureSecret: string;
|
||||
cost: number;
|
||||
maxCounter: number;
|
||||
claimChallenge: (signature: string, ttlSeconds: number) => Promise<boolean>;
|
||||
logger?: LoggerInterface;
|
||||
now?: () => number;
|
||||
}
|
||||
|
||||
function decodePayload(token: string): AltchaPayload | null {
|
||||
if (token.length > ALTCHA_MAX_TOKEN_LENGTH) return null;
|
||||
try {
|
||||
const parsed = AltchaPayloadSchema.safeParse(JSON.parse(Buffer.from(token, 'base64').toString('utf8')));
|
||||
return parsed.success ? parsed.data : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export class AltchaProvider implements ICaptchaProvider {
|
||||
readonly type: CaptchaProviderType = 'altcha';
|
||||
private readonly options: AltchaProviderOptions;
|
||||
private readonly now: () => number;
|
||||
|
||||
constructor(options: AltchaProviderOptions) {
|
||||
this.options = options;
|
||||
this.now = options.now ?? Date.now;
|
||||
}
|
||||
|
||||
async createChallenge(): Promise<Challenge> {
|
||||
const {cost, maxCounter, hmacSignatureSecret, hmacKeySignatureSecret} = this.options;
|
||||
return await createChallenge({
|
||||
algorithm: ALTCHA_ALGORITHM,
|
||||
cost,
|
||||
counter: randomInt(maxCounter, Math.ceil(maxCounter / 2)),
|
||||
deriveKey,
|
||||
expiresAt: new Date(this.now() + ALTCHA_CHALLENGE_TTL_MS),
|
||||
hmacSignatureSecret,
|
||||
hmacKeySignatureSecret,
|
||||
});
|
||||
}
|
||||
|
||||
async verify({token}: VerifyCaptchaParams): Promise<boolean> {
|
||||
const payload = decodePayload(token);
|
||||
if (!payload) return false;
|
||||
try {
|
||||
const result = await verifySolution({
|
||||
challenge: payload.challenge,
|
||||
solution: payload.solution,
|
||||
deriveKey,
|
||||
hmacSignatureSecret: this.options.hmacSignatureSecret,
|
||||
hmacKeySignatureSecret: this.options.hmacKeySignatureSecret,
|
||||
});
|
||||
if (!result.verified) {
|
||||
this.options.logger?.warn(
|
||||
{expired: result.expired, invalidSignature: result.invalidSignature, invalidSolution: result.invalidSolution},
|
||||
'ALTCHA verification failed',
|
||||
);
|
||||
return false;
|
||||
}
|
||||
} catch (error) {
|
||||
this.options.logger?.error({error}, 'Error verifying ALTCHA payload');
|
||||
return false;
|
||||
}
|
||||
const ttlSeconds = Math.max(1, payload.challenge.parameters.expiresAt - Math.floor(this.now() / 1000));
|
||||
return await this.options.claimChallenge(payload.challenge.signature, ttlSeconds);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import type {APIConfig, BlueskyOAuthConfig} from '@app/api/config/APIConfig';
|
||||
import {parseIpBanEntry} from '@app/api/utils/IpRangeUtils';
|
||||
import type {WorkerTaskName} from '@app/api/worker/WorkerLaneConfig';
|
||||
import type {MasterConfig} from '@fluxer/config/src/MasterConfig';
|
||||
import {parseIpAddress} from '@fluxer/ip_utils/src/IpAddress';
|
||||
@@ -82,6 +83,14 @@ function resolveTrustClientIpHeader(proxyConfig: object): boolean {
|
||||
function normalizeIpBanExemptIps(values: Array<string>): Array<string> {
|
||||
const normalized = new Set<string>();
|
||||
for (const value of values) {
|
||||
if (value.includes('/')) {
|
||||
const range = parseIpBanEntry(value);
|
||||
if (range?.type !== 'range') {
|
||||
throw new Error(`FLUXER_API_IP_BAN_EXEMPT_IPS contains an invalid CIDR range: ${value}`);
|
||||
}
|
||||
normalized.add(range.canonical);
|
||||
continue;
|
||||
}
|
||||
const parsed = parseIpAddress(value);
|
||||
if (!parsed) {
|
||||
throw new Error(`FLUXER_API_IP_BAN_EXEMPT_IPS contains an invalid IP address: ${value}`);
|
||||
|
||||
@@ -34,9 +34,14 @@ import {
|
||||
PendingRegistrationActionRequest,
|
||||
RegistrationUrlIdParam,
|
||||
} from '@fluxer/schema/src/domains/admin/AdminSchemas';
|
||||
import {AltchaCaptchaConfigSchema} from '@fluxer/schema/src/domains/admin/AltchaCaptchaSchemas';
|
||||
import {DomainMigrationConfigSchema} from '@fluxer/schema/src/domains/admin/DomainMigrationSchemas';
|
||||
import {GatewayRolloutConfigSchema} from '@fluxer/schema/src/domains/admin/GatewayRolloutSchemas';
|
||||
import {PushServiceDeliveryConfigSchema} from '@fluxer/schema/src/domains/admin/PushServiceDeliverySchemas';
|
||||
import {
|
||||
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';
|
||||
@@ -67,6 +72,7 @@ async function buildInstanceConfigResponse(): Promise<InstanceConfigResponse> {
|
||||
voiceNoiseSuppression,
|
||||
pushServiceDelivery,
|
||||
domainMigration,
|
||||
altchaCaptcha,
|
||||
experimentDelivery,
|
||||
registrationConfig,
|
||||
registrationUrls,
|
||||
@@ -77,6 +83,7 @@ async function buildInstanceConfigResponse(): Promise<InstanceConfigResponse> {
|
||||
instanceConfigRepository.getVoiceNoiseSuppressionConfig(),
|
||||
instanceConfigRepository.getPushServiceDeliveryConfig(),
|
||||
instanceConfigRepository.getDomainMigrationConfig(),
|
||||
instanceConfigRepository.getAltchaCaptchaConfig(),
|
||||
instanceConfigRepository.getExperimentDeliveryConfig(),
|
||||
instanceConfigRepository.getRegistrationConfig(),
|
||||
instanceConfigRepository.getRegistrationUrlsForAdmin(),
|
||||
@@ -110,6 +117,7 @@ async function buildInstanceConfigResponse(): Promise<InstanceConfigResponse> {
|
||||
voice_noise_suppression: voiceNoiseSuppression,
|
||||
push_service_delivery: pushServiceDelivery,
|
||||
domain_migration: domainMigration,
|
||||
altcha_captcha: altchaCaptcha,
|
||||
experiment_delivery: experimentDelivery,
|
||||
registration: {
|
||||
...registrationConfig,
|
||||
@@ -194,6 +202,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)
|
||||
@@ -276,10 +298,12 @@ 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,
|
||||
}),
|
||||
);
|
||||
@@ -298,6 +322,18 @@ export function InstanceConfigAdminController(app: HonoApp) {
|
||||
);
|
||||
}
|
||||
}
|
||||
if (data.altcha_captcha) {
|
||||
const patch = omitUndefinedFields(data.altcha_captcha);
|
||||
if (Object.keys(patch).length > 0) {
|
||||
await instanceConfigRepository.updateAltchaCaptchaConfig((current) =>
|
||||
AltchaCaptchaConfigSchema.parse({
|
||||
...current,
|
||||
...patch,
|
||||
config_version: current.config_version + 1,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
if (data.experiment_delivery) {
|
||||
const patch = data.experiment_delivery;
|
||||
await instanceConfigRepository.updateExperimentDeliveryConfig((current) =>
|
||||
|
||||
@@ -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}));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,178 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {createTestAccount} from '@app/api/auth/tests/AuthTestUtils';
|
||||
import {Config} from '@app/api/Config';
|
||||
import {getInstanceConfigRepository} from '@app/api/middleware/ServiceSingletons';
|
||||
import {type ApiTestHarness, createApiTestHarness} from '@app/api/test/ApiTestHarness';
|
||||
import {HTTP_STATUS} from '@app/api/test/TestConstants';
|
||||
import {createBuilder, createBuilderWithoutAuth, type TestRequestBuilder} from '@app/api/test/TestRequestBuilder';
|
||||
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
|
||||
import {
|
||||
type AltchaCaptchaConfig,
|
||||
DEFAULT_ALTCHA_CAPTCHA_CONFIG,
|
||||
} from '@fluxer/schema/src/domains/admin/AltchaCaptchaSchemas';
|
||||
import {solveChallenge} from 'altcha-lib';
|
||||
import {deriveKey} from 'altcha-lib/algorithms/pbkdf2';
|
||||
import type {Challenge} from 'altcha-lib/types';
|
||||
import {afterAll, afterEach, beforeAll, beforeEach, describe, expect, it} from 'vitest';
|
||||
|
||||
interface CaptchaErrorBody {
|
||||
code: string;
|
||||
captcha_provider?: string;
|
||||
altcha_challenge?: Challenge;
|
||||
}
|
||||
|
||||
const FORGOT_PATH = '/auth/forgot';
|
||||
const FORGOT_BODY = {email: '[email protected]'};
|
||||
|
||||
async function setAltchaConfig(overrides: Partial<AltchaCaptchaConfig>): Promise<void> {
|
||||
await getInstanceConfigRepository().setAltchaCaptchaConfig({
|
||||
...DEFAULT_ALTCHA_CAPTCHA_CONFIG,
|
||||
enabled: true,
|
||||
cost: 1000,
|
||||
max_counter: 100,
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
async function solve(challenge: Challenge): Promise<string> {
|
||||
const solution = await solveChallenge({challenge, deriveKey, timeout: 0});
|
||||
if (!solution) throw new Error('ALTCHA challenge was not solved');
|
||||
return Buffer.from(JSON.stringify({challenge, solution}), 'utf8').toString('base64');
|
||||
}
|
||||
|
||||
async function rejectWith(builder: TestRequestBuilder<CaptchaErrorBody>, code: string): Promise<CaptchaErrorBody> {
|
||||
const {json} = await builder.expect(HTTP_STATUS.BAD_REQUEST, code).executeWithResponse();
|
||||
expect(json.code).toBe(code);
|
||||
return json;
|
||||
}
|
||||
|
||||
function forgot(harness: ApiTestHarness): TestRequestBuilder<CaptchaErrorBody> {
|
||||
return createBuilderWithoutAuth<CaptchaErrorBody>(harness).post(FORGOT_PATH).body(FORGOT_BODY);
|
||||
}
|
||||
|
||||
describe('ALTCHA captcha experiment', () => {
|
||||
let harness: ApiTestHarness;
|
||||
let previousCaptchaEnabled: boolean;
|
||||
let previousTestModeEnabled: boolean;
|
||||
|
||||
beforeAll(async () => {
|
||||
harness = await createApiTestHarness();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await harness.reset();
|
||||
previousCaptchaEnabled = Config.captcha.enabled;
|
||||
previousTestModeEnabled = Config.dev.testModeEnabled;
|
||||
Config.captcha.enabled = true;
|
||||
Config.dev.testModeEnabled = true;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
Config.captcha.enabled = previousCaptchaEnabled;
|
||||
Config.dev.testModeEnabled = previousTestModeEnabled;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await harness.shutdown();
|
||||
});
|
||||
|
||||
it('keeps the configured provider while the experiment is off', async () => {
|
||||
const body = await rejectWith(forgot(harness), APIErrorCodes.CAPTCHA_REQUIRED);
|
||||
expect(body).not.toHaveProperty('captcha_provider');
|
||||
expect(body).not.toHaveProperty('altcha_challenge');
|
||||
});
|
||||
|
||||
it('leaves anonymous requests on the configured provider unless anonymous_enabled is set', async () => {
|
||||
await setAltchaConfig({rollout_basis_points: 10000});
|
||||
const body = await rejectWith(forgot(harness), APIErrorCodes.CAPTCHA_REQUIRED);
|
||||
expect(body).not.toHaveProperty('altcha_challenge');
|
||||
});
|
||||
|
||||
it('serves anonymous requests a challenge and accepts the solved payload once', async () => {
|
||||
await setAltchaConfig({anonymous_enabled: true});
|
||||
const required = await rejectWith(forgot(harness), APIErrorCodes.CAPTCHA_REQUIRED);
|
||||
expect(required.captcha_provider).toBe('altcha');
|
||||
expect(required.altcha_challenge?.parameters).toMatchObject({algorithm: 'PBKDF2/SHA-256', cost: 1000});
|
||||
const token = await solve(required.altcha_challenge as Challenge);
|
||||
|
||||
await forgot(harness)
|
||||
.header('X-Captcha-Token', token)
|
||||
.header('X-Captcha-Type', 'altcha')
|
||||
.expect(HTTP_STATUS.NO_CONTENT)
|
||||
.execute();
|
||||
|
||||
const replayed = await rejectWith(
|
||||
forgot(harness).header('X-Captcha-Token', token).header('X-Captcha-Type', 'altcha'),
|
||||
APIErrorCodes.INVALID_CAPTCHA,
|
||||
);
|
||||
expect(replayed.captcha_provider).toBe('altcha');
|
||||
expect(replayed.altcha_challenge?.signature).not.toBe(required.altcha_challenge?.signature);
|
||||
});
|
||||
|
||||
it('rejects a payload whose derived key does not match the challenge', async () => {
|
||||
await setAltchaConfig({anonymous_enabled: true});
|
||||
const required = await rejectWith(forgot(harness), APIErrorCodes.CAPTCHA_REQUIRED);
|
||||
const challenge = required.altcha_challenge as Challenge;
|
||||
const forged = Buffer.from(
|
||||
JSON.stringify({challenge, solution: {counter: 1, derivedKey: '00'.repeat(32)}}),
|
||||
'utf8',
|
||||
).toString('base64');
|
||||
|
||||
await rejectWith(
|
||||
forgot(harness).header('X-Captcha-Token', forged).header('X-Captcha-Type', 'altcha'),
|
||||
APIErrorCodes.INVALID_CAPTCHA,
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects an ALTCHA payload from a requester outside the experiment', async () => {
|
||||
await setAltchaConfig({anonymous_enabled: true});
|
||||
const required = await rejectWith(forgot(harness), APIErrorCodes.CAPTCHA_REQUIRED);
|
||||
const token = await solve(required.altcha_challenge as Challenge);
|
||||
await setAltchaConfig({anonymous_enabled: false});
|
||||
|
||||
const rejected = await rejectWith(
|
||||
forgot(harness).header('X-Captcha-Token', token).header('X-Captcha-Type', 'altcha'),
|
||||
APIErrorCodes.INVALID_CAPTCHA,
|
||||
);
|
||||
expect(rejected).not.toHaveProperty('altcha_challenge');
|
||||
});
|
||||
|
||||
it('buckets signed-in users by their own rollout and still accepts the configured provider', async () => {
|
||||
Config.captcha.enabled = false;
|
||||
const included = await createTestAccount(harness);
|
||||
const excluded = await createTestAccount(harness);
|
||||
Config.captcha.enabled = true;
|
||||
await setAltchaConfig({
|
||||
anonymous_enabled: true,
|
||||
included_user_ids: [included.userId],
|
||||
excluded_user_ids: [excluded.userId],
|
||||
});
|
||||
const redeemPath = '/gifts/altcha-gift-code/redeem';
|
||||
|
||||
const excludedBody = await rejectWith(
|
||||
createBuilder<CaptchaErrorBody>(harness, excluded.token).post(redeemPath),
|
||||
APIErrorCodes.CAPTCHA_REQUIRED,
|
||||
);
|
||||
expect(excludedBody).not.toHaveProperty('altcha_challenge');
|
||||
|
||||
const includedBody = await rejectWith(
|
||||
createBuilder<CaptchaErrorBody>(harness, included.token).post(redeemPath),
|
||||
APIErrorCodes.CAPTCHA_REQUIRED,
|
||||
);
|
||||
const token = await solve(includedBody.altcha_challenge as Challenge);
|
||||
const solved = await createBuilder<CaptchaErrorBody>(harness, included.token)
|
||||
.post(redeemPath)
|
||||
.header('X-Captcha-Token', token)
|
||||
.header('X-Captcha-Type', 'altcha')
|
||||
.executeRaw();
|
||||
expect([APIErrorCodes.CAPTCHA_REQUIRED, APIErrorCodes.INVALID_CAPTCHA]).not.toContain(solved.json?.code);
|
||||
|
||||
const classic = await createBuilder<CaptchaErrorBody>(harness, included.token)
|
||||
.post(redeemPath)
|
||||
.header('X-Captcha-Token', 'hcaptcha-token')
|
||||
.header('X-Captcha-Type', 'hcaptcha')
|
||||
.executeRaw();
|
||||
expect([APIErrorCodes.CAPTCHA_REQUIRED, APIErrorCodes.INVALID_CAPTCHA]).not.toContain(classic.json?.code);
|
||||
});
|
||||
});
|
||||
@@ -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 {resolveAltchaCaptchaAssignment} from '@fluxer/schema/src/domains/admin/AltchaCaptchaSchemas';
|
||||
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';
|
||||
@@ -29,10 +30,11 @@ export function ExperimentController(app: HonoApp) {
|
||||
}),
|
||||
async (ctx) => {
|
||||
const instanceConfigRepository = ctx.get('instanceConfigRepository');
|
||||
const [delivery, voiceConfig, domainMigrationConfig] = await Promise.all([
|
||||
const [delivery, voiceConfig, domainMigrationConfig, altchaCaptchaConfig] = await Promise.all([
|
||||
instanceConfigRepository.getExperimentDeliveryConfig(),
|
||||
instanceConfigRepository.getVoiceNoiseSuppressionConfig(),
|
||||
instanceConfigRepository.getDomainMigrationConfig(),
|
||||
instanceConfigRepository.getAltchaCaptchaConfig(),
|
||||
]);
|
||||
const userId = ctx.get('user').id.toString();
|
||||
const body: ExperimentAssignmentsResponse = {
|
||||
@@ -41,6 +43,7 @@ export function ExperimentController(app: HonoApp) {
|
||||
assignments: {
|
||||
voice_noise_suppression: resolveVoiceNoiseSuppressionAssignment(voiceConfig, userId),
|
||||
domain_migration: resolveDomainMigrationAssignment(domainMigrationConfig, userId),
|
||||
altcha_captcha: resolveAltchaCaptchaAssignment(altchaCaptchaConfig, 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_ALTCHA_CAPTCHA_CONFIG,
|
||||
INERT_ALTCHA_CAPTCHA_ASSIGNMENT,
|
||||
} from '@fluxer/schema/src/domains/admin/AltchaCaptchaSchemas';
|
||||
import {
|
||||
DEFAULT_DOMAIN_MIGRATION_CONFIG,
|
||||
INERT_DOMAIN_MIGRATION_ASSIGNMENT,
|
||||
@@ -57,6 +61,7 @@ describe('GET /experiments', () => {
|
||||
assignments: {
|
||||
voice_noise_suppression: INERT_VOICE_NOISE_SUPPRESSION_ASSIGNMENT,
|
||||
domain_migration: INERT_DOMAIN_MIGRATION_ASSIGNMENT,
|
||||
altcha_captcha: INERT_ALTCHA_CAPTCHA_ASSIGNMENT,
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -134,6 +139,62 @@ describe('GET /experiments', () => {
|
||||
expect(body.assignments.domain_migration).toEqual({enabled: false});
|
||||
});
|
||||
|
||||
it('resolves the altcha captcha caller through the allowlist and the exclusion list', async () => {
|
||||
const targeted = await createTestAccount(harness);
|
||||
const excluded = await createTestAccount(harness);
|
||||
await getInstanceConfigRepository().setAltchaCaptchaConfig({
|
||||
...DEFAULT_ALTCHA_CAPTCHA_CONFIG,
|
||||
enabled: true,
|
||||
rollout_basis_points: 10000,
|
||||
anonymous_enabled: true,
|
||||
included_user_ids: [targeted.userId],
|
||||
excluded_user_ids: [excluded.userId],
|
||||
});
|
||||
|
||||
const targetedBody = await createBuilder<ExperimentAssignmentsResponse>(harness, targeted.token)
|
||||
.get(ENDPOINT)
|
||||
.execute();
|
||||
expect(targetedBody.assignments.altcha_captcha).toEqual({enabled: true});
|
||||
|
||||
const excludedBody = await createBuilder<ExperimentAssignmentsResponse>(harness, excluded.token)
|
||||
.get(ENDPOINT)
|
||||
.execute();
|
||||
expect(excludedBody.assignments.altcha_captcha).toEqual({enabled: false});
|
||||
});
|
||||
|
||||
it('bumps the altcha captcha config version on every admin update without the client sending one', async () => {
|
||||
const admin = await setUserACLs(harness, await createTestAccount(harness), [
|
||||
AdminACLs.AUTHENTICATE,
|
||||
AdminACLs.INSTANCE_CONFIG_VIEW,
|
||||
AdminACLs.INSTANCE_CONFIG_UPDATE,
|
||||
]);
|
||||
|
||||
const afterFirst = await createBuilder<{altcha_captcha: {config_version: number; enabled: boolean}}>(
|
||||
harness,
|
||||
admin.token,
|
||||
)
|
||||
.patch('/admin/instance/config')
|
||||
.body({altcha_captcha: {enabled: true, included_user_ids: [admin.userId]}})
|
||||
.execute();
|
||||
expect(afterFirst.altcha_captcha).toMatchObject({config_version: 1, enabled: true});
|
||||
|
||||
const afterSecond = await createBuilder<{
|
||||
altcha_captcha: {config_version: number; anonymous_enabled: boolean; cost: number; max_counter: number};
|
||||
}>(harness, admin.token)
|
||||
.patch('/admin/instance/config')
|
||||
.body({altcha_captcha: {anonymous_enabled: true, cost: 2000, max_counter: 400}})
|
||||
.execute();
|
||||
expect(afterSecond.altcha_captcha).toMatchObject({
|
||||
config_version: 2,
|
||||
anonymous_enabled: true,
|
||||
cost: 2000,
|
||||
max_counter: 400,
|
||||
});
|
||||
|
||||
const body = await createBuilder<ExperimentAssignmentsResponse>(harness, admin.token).get(ENDPOINT).execute();
|
||||
expect(body.assignments.altcha_captcha).toEqual({enabled: true});
|
||||
});
|
||||
|
||||
it('serves the delivery cadence from the delivery config and not from the voice config', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
await getInstanceConfigRepository().setExperimentDeliveryConfig({
|
||||
|
||||
@@ -28,6 +28,10 @@ import {
|
||||
type PendingRegistrationResponse,
|
||||
type RegistrationUrlResponse,
|
||||
} from '@fluxer/schema/src/domains/admin/AdminSchemas';
|
||||
import {
|
||||
type AltchaCaptchaConfig,
|
||||
AltchaCaptchaConfigSchema,
|
||||
} from '@fluxer/schema/src/domains/admin/AltchaCaptchaSchemas';
|
||||
import {
|
||||
type DomainMigrationConfig,
|
||||
DomainMigrationConfigSchema,
|
||||
@@ -68,6 +72,7 @@ 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 ALTCHA_CAPTCHA_CONFIG_KEY = 'altcha_captcha_config';
|
||||
const EXPERIMENT_DELIVERY_CONFIG_KEY = 'experiment_delivery_config';
|
||||
const REGISTRATION_CONFIG_KEY = 'registration_config';
|
||||
const REGISTRATION_URLS_KEY = 'registration_urls';
|
||||
@@ -376,6 +381,7 @@ type StoredConfigSection =
|
||||
| 'voice noise suppression'
|
||||
| 'push service delivery'
|
||||
| 'domain migration'
|
||||
| 'altcha captcha'
|
||||
| 'experiment delivery'
|
||||
| 'instance policy'
|
||||
| 'integrations'
|
||||
@@ -522,6 +528,10 @@ function parseStoredDomainMigrationConfig(raw: string | null): DomainMigrationCo
|
||||
return parseStoredConfigOrDefault(DomainMigrationConfigSchema, raw, 'domain migration');
|
||||
}
|
||||
|
||||
function parseStoredAltchaCaptchaConfig(raw: string | null): AltchaCaptchaConfig {
|
||||
return parseStoredConfigOrDefault(AltchaCaptchaConfigSchema, raw, 'altcha captcha');
|
||||
}
|
||||
|
||||
function parseStoredExperimentDeliveryConfig(raw: string | null): ExperimentDeliveryConfig {
|
||||
return parseStoredConfigOrDefault(ExperimentDeliveryConfigSchema, raw, 'experiment delivery');
|
||||
}
|
||||
@@ -1171,6 +1181,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);
|
||||
parseStoredAltchaCaptchaConfig(snapshot.get(ALTCHA_CAPTCHA_CONFIG_KEY) ?? null);
|
||||
parseStoredExperimentDeliveryConfig(snapshot.get(EXPERIMENT_DELIVERY_CONFIG_KEY) ?? null);
|
||||
const policy = parseStoredInstancePolicyConfig(snapshot.get(INSTANCE_POLICY_CONFIG_KEY) ?? null);
|
||||
checkStoredConfig('registration', () =>
|
||||
@@ -1305,6 +1316,23 @@ export class InstanceConfigRepository {
|
||||
);
|
||||
}
|
||||
|
||||
async getAltchaCaptchaConfig(): Promise<AltchaCaptchaConfig> {
|
||||
const raw = await this.getConfig(ALTCHA_CAPTCHA_CONFIG_KEY);
|
||||
return parseStoredAltchaCaptchaConfig(raw);
|
||||
}
|
||||
|
||||
async setAltchaCaptchaConfig(config: AltchaCaptchaConfig): Promise<void> {
|
||||
await this.updateAltchaCaptchaConfig(() => config);
|
||||
}
|
||||
|
||||
updateAltchaCaptchaConfig(
|
||||
update: (current: AltchaCaptchaConfig) => AltchaCaptchaConfig,
|
||||
): Promise<AltchaCaptchaConfig> {
|
||||
return this.updateStoredConfig(ALTCHA_CAPTCHA_CONFIG_KEY, (raw) =>
|
||||
validateStoredConfig(AltchaCaptchaConfigSchema, update(parseStoredAltchaCaptchaConfig(raw)), 'altcha captcha'),
|
||||
);
|
||||
}
|
||||
|
||||
async getExperimentDeliveryConfig(): Promise<ExperimentDeliveryConfig> {
|
||||
const raw = await this.getConfig(EXPERIMENT_DELIVERY_CONFIG_KEY);
|
||||
return parseStoredExperimentDeliveryConfig(raw);
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {createHmac} from 'node:crypto';
|
||||
import {Config} from '@app/api/Config';
|
||||
import type {InstanceCaptchaEffectiveConfig} from '@app/api/instance/InstanceConfigRepository';
|
||||
import {Logger} from '@app/api/Logger';
|
||||
import {getKVClient} from '@app/api/middleware/ServiceRegistry';
|
||||
import type {User} from '@app/api/models/User';
|
||||
import {accountPolicyContactHasCapability} from '@app/api/risk/AccountPolicyService';
|
||||
import type {HonoEnv} from '@app/api/types/HonoEnv';
|
||||
@@ -9,12 +12,43 @@ import {Headers} from '@fluxer/constants/src/Headers';
|
||||
import {UserFlags} from '@fluxer/constants/src/UserConstants';
|
||||
import {CaptchaRequiredError, InvalidCaptchaError} from '@fluxer/errors/src/CaptchaErrors';
|
||||
import {extractClientIp} from '@fluxer/ip_utils/src/ClientIp';
|
||||
import {type AltchaCaptchaConfig, altchaCaptchaAppliesTo} from '@fluxer/schema/src/domains/admin/AltchaCaptchaSchemas';
|
||||
import type {InstanceCaptchaProvider} from '@fluxer/schema/src/domains/instance/InstanceSchemas';
|
||||
import {createCaptchaProvider} from '@pkgs/captcha/src/CaptchaProviderFactory';
|
||||
import type {ICaptchaProvider} from '@pkgs/captcha/src/ICaptchaProvider';
|
||||
import {AltchaProvider} from '@pkgs/captcha/src/providers/AltchaProvider';
|
||||
import type {Context} from 'hono';
|
||||
import {createMiddleware} from 'hono/factory';
|
||||
|
||||
const ALTCHA_SPENT_CHALLENGE_KEY_PREFIX = 'captcha:altcha:spent:';
|
||||
|
||||
function deriveAltchaSecret(label: string): string {
|
||||
return createHmac('sha256', Config.auth.sudoModeSecret).update(label).digest('hex');
|
||||
}
|
||||
|
||||
function createAltchaProvider(config: AltchaCaptchaConfig): AltchaProvider {
|
||||
return new AltchaProvider({
|
||||
hmacSignatureSecret: deriveAltchaSecret('fluxer-altcha-challenge-signature-v1'),
|
||||
hmacKeySignatureSecret: deriveAltchaSecret('fluxer-altcha-key-signature-v1'),
|
||||
cost: config.cost,
|
||||
maxCounter: config.max_counter,
|
||||
claimChallenge: (signature, ttlSeconds) =>
|
||||
getKVClient().setnx(`${ALTCHA_SPENT_CHALLENGE_KEY_PREFIX}${signature}`, '1', ttlSeconds),
|
||||
logger: Logger,
|
||||
});
|
||||
}
|
||||
|
||||
async function altchaChallengeData(altcha: AltchaProvider | null): Promise<Record<string, unknown> | undefined> {
|
||||
if (!altcha) return undefined;
|
||||
return {captcha_provider: 'altcha', altcha_challenge: await altcha.createChallenge()};
|
||||
}
|
||||
|
||||
async function resolveAltchaProvider(ctx: Context<HonoEnv>, user: User | undefined): Promise<AltchaProvider | null> {
|
||||
const config = await ctx.get('instanceConfigRepository').getAltchaCaptchaConfig();
|
||||
if (!altchaCaptchaAppliesTo(config, user ? user.id.toString() : null)) return null;
|
||||
return createAltchaProvider(config);
|
||||
}
|
||||
|
||||
function resolveProviderSecret(
|
||||
config: InstanceCaptchaEffectiveConfig,
|
||||
provider: InstanceCaptchaProvider,
|
||||
@@ -58,11 +92,19 @@ export async function verifyCaptchaToken(ctx: Context<HonoEnv>): Promise<void> {
|
||||
if (accountPolicyContactHasCapability(user?.email, 'captcha_exempt')) return;
|
||||
if (userHasCaptchaExemptFlag(user)) return;
|
||||
if (await requestUserHasCaptchaExemptFlag(ctx)) return;
|
||||
const altcha = await resolveAltchaProvider(ctx, user);
|
||||
const token = ctx.req.header(Headers.X_CAPTCHA_TOKEN);
|
||||
if (!token) {
|
||||
throw new CaptchaRequiredError();
|
||||
throw new CaptchaRequiredError(await altchaChallengeData(altcha));
|
||||
}
|
||||
const provider = resolveCaptchaProvider(captchaConfig, ctx.req.header(Headers.X_CAPTCHA_TYPE));
|
||||
const requestedType = ctx.req.header(Headers.X_CAPTCHA_TYPE);
|
||||
if (requestedType === 'altcha') {
|
||||
if (!altcha || !(await altcha.verify({token}))) {
|
||||
throw new InvalidCaptchaError(await altchaChallengeData(altcha));
|
||||
}
|
||||
return;
|
||||
}
|
||||
const provider = resolveCaptchaProvider(captchaConfig, requestedType);
|
||||
const isValid = await provider.verify({
|
||||
token,
|
||||
remoteIp:
|
||||
@@ -72,7 +114,7 @@ export async function verifyCaptchaToken(ctx: Context<HonoEnv>): Promise<void> {
|
||||
}) ?? undefined,
|
||||
});
|
||||
if (!isValid) {
|
||||
throw new InvalidCaptchaError();
|
||||
throw new InvalidCaptchaError(await altchaChallengeData(altcha));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import type {
|
||||
import {CaptchaMiddleware} from '@app/api/middleware/CaptchaMiddleware';
|
||||
import type {HonoEnv} from '@app/api/types/HonoEnv';
|
||||
import {AppErrorHandler} from '@fluxer/errors/src/domains/core/ErrorHandlers';
|
||||
import {DEFAULT_ALTCHA_CAPTCHA_CONFIG} from '@fluxer/schema/src/domains/admin/AltchaCaptchaSchemas';
|
||||
import {Hono} from 'hono';
|
||||
import {afterEach, beforeEach, describe, expect, it} from 'vitest';
|
||||
|
||||
@@ -25,6 +26,7 @@ function createHarness(
|
||||
): (headers: Record<string, string>) => Promise<Response> {
|
||||
const repository = {
|
||||
getEffectiveCaptchaConfig: async () => captcha,
|
||||
getAltchaCaptchaConfig: async () => DEFAULT_ALTCHA_CAPTCHA_CONFIG,
|
||||
} as unknown as InstanceConfigRepository;
|
||||
const app = new Hono<HonoEnv>();
|
||||
app.use(async (ctx, next) => {
|
||||
|
||||
@@ -27953,7 +27953,8 @@
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"voice_noise_suppression": {"$ref": "#/components/schemas/VoiceNoiseSuppressionAssignmentResponse"},
|
||||
"domain_migration": {"$ref": "#/components/schemas/DomainMigrationAssignmentResponse"}
|
||||
"domain_migration": {"$ref": "#/components/schemas/DomainMigrationAssignmentResponse"},
|
||||
"altcha_captcha": {"$ref": "#/components/schemas/AltchaCaptchaAssignmentResponse"}
|
||||
},
|
||||
"additionalProperties": false
|
||||
}
|
||||
@@ -31629,6 +31630,12 @@
|
||||
"additionalProperties": false
|
||||
},
|
||||
"DonationCurrency": {"type": "string", "enum": ["usd", "eur", "brl", "inr", "pln", "try", "sek", "dkk", "nok"]},
|
||||
"AltchaCaptchaAssignmentResponse": {
|
||||
"type": "object",
|
||||
"properties": {"enabled": {"type": "boolean"}},
|
||||
"required": ["enabled"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"DomainMigrationAssignmentResponse": {
|
||||
"type": "object",
|
||||
"properties": {"enabled": {"type": "boolean"}},
|
||||
|
||||
@@ -1,34 +1,69 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {Config} from '@app/api/Config';
|
||||
import {parseIpBanEntry, tryParseSingleIp} from '@app/api/utils/IpRangeUtils';
|
||||
import type {IpAddressFamily} from '@fluxer/ip_utils/src/IpAddress';
|
||||
import {getSameIpDecisionKey} from '@fluxer/ip_utils/src/IpAddress';
|
||||
|
||||
let exemptDecisionKeys: ReadonlySet<string> | null = null;
|
||||
interface ExemptRange {
|
||||
family: IpAddressFamily;
|
||||
start: bigint;
|
||||
end: bigint;
|
||||
}
|
||||
|
||||
function getExemptDecisionKeys(): ReadonlySet<string> {
|
||||
if (exemptDecisionKeys) {
|
||||
return exemptDecisionKeys;
|
||||
interface IpBanExemptions {
|
||||
decisionKeys: ReadonlySet<string>;
|
||||
ranges: ReadonlyArray<ExemptRange>;
|
||||
}
|
||||
|
||||
let exemptions: IpBanExemptions | null = null;
|
||||
|
||||
function getExemptions(): IpBanExemptions {
|
||||
if (exemptions) {
|
||||
return exemptions;
|
||||
}
|
||||
const keys = new Set<string>();
|
||||
for (const ip of Config.ipBanExemptIps) {
|
||||
const key = getSameIpDecisionKey(ip);
|
||||
if (!key) {
|
||||
throw new Error(`Invalid IP ban exemption in API config: ${ip}`);
|
||||
const decisionKeys = new Set<string>();
|
||||
const ranges: Array<ExemptRange> = [];
|
||||
for (const entry of Config.ipBanExemptIps) {
|
||||
if (entry.includes('/')) {
|
||||
const range = parseIpBanEntry(entry);
|
||||
if (range?.type !== 'range') {
|
||||
throw new Error(`Invalid IP ban exemption in API config: ${entry}`);
|
||||
}
|
||||
ranges.push({family: range.family, start: range.start, end: range.end});
|
||||
continue;
|
||||
}
|
||||
keys.add(key);
|
||||
const key = getSameIpDecisionKey(entry);
|
||||
if (!key) {
|
||||
throw new Error(`Invalid IP ban exemption in API config: ${entry}`);
|
||||
}
|
||||
decisionKeys.add(key);
|
||||
}
|
||||
exemptDecisionKeys = keys;
|
||||
return keys;
|
||||
exemptions = {decisionKeys, ranges};
|
||||
return exemptions;
|
||||
}
|
||||
|
||||
export function isIpBanExempt(ip: string | null | undefined): boolean {
|
||||
if (!ip) {
|
||||
return false;
|
||||
}
|
||||
const {decisionKeys, ranges} = getExemptions();
|
||||
const key = getSameIpDecisionKey(ip);
|
||||
return key !== null && getExemptDecisionKeys().has(key);
|
||||
if (key !== null && decisionKeys.has(key)) {
|
||||
return true;
|
||||
}
|
||||
if (ranges.length === 0) {
|
||||
return false;
|
||||
}
|
||||
const parsed = tryParseSingleIp(ip);
|
||||
if (!parsed) {
|
||||
return false;
|
||||
}
|
||||
return ranges.some(
|
||||
(range) => range.family === parsed.family && parsed.value >= range.start && parsed.value <= range.end,
|
||||
);
|
||||
}
|
||||
|
||||
export function resetIpBanExemptionsForTesting(): void {
|
||||
exemptDecisionKeys = null;
|
||||
exemptions = null;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {getConfig} from '@app/api/Config';
|
||||
import {isIpBanExempt, resetIpBanExemptionsForTesting} from '@app/api/risk/IpBanExemptions';
|
||||
import {afterEach, beforeEach, describe, expect, it} from 'vitest';
|
||||
|
||||
describe('isIpBanExempt', () => {
|
||||
let originalExemptIps: Array<string>;
|
||||
|
||||
beforeEach(() => {
|
||||
const config = getConfig();
|
||||
originalExemptIps = config.ipBanExemptIps;
|
||||
config.ipBanExemptIps = ['198.51.100.7', '2001:db8:6::', '2001:db8:1200:1000::/56', '203.0.113.0/24'];
|
||||
resetIpBanExemptionsForTesting();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
getConfig().ipBanExemptIps = originalExemptIps;
|
||||
resetIpBanExemptionsForTesting();
|
||||
});
|
||||
|
||||
it('matches a bare IPv4 address exactly', () => {
|
||||
expect(isIpBanExempt('198.51.100.7')).toBe(true);
|
||||
expect(isIpBanExempt('198.51.100.8')).toBe(false);
|
||||
});
|
||||
|
||||
it('matches a bare IPv6 address on its /64', () => {
|
||||
expect(isIpBanExempt('2001:db8:6::abcd')).toBe(true);
|
||||
expect(isIpBanExempt('2001:db8:7::1')).toBe(false);
|
||||
});
|
||||
|
||||
it('matches every address inside a CIDR range', () => {
|
||||
expect(isIpBanExempt('2001:db8:1200:1000::1')).toBe(true);
|
||||
expect(isIpBanExempt('2001:db8:1200:10ff:ffff:ffff:ffff:ffff')).toBe(true);
|
||||
expect(isIpBanExempt('2001:db8:1200:1100::1')).toBe(false);
|
||||
expect(isIpBanExempt('2001:db8:1200:fff::1')).toBe(false);
|
||||
expect(isIpBanExempt('203.0.113.200')).toBe(true);
|
||||
expect(isIpBanExempt('::ffff:203.0.113.200')).toBe(true);
|
||||
expect(isIpBanExempt('203.0.114.1')).toBe(false);
|
||||
});
|
||||
|
||||
it('does not match empty or unparsable input', () => {
|
||||
expect(isIpBanExempt(null)).toBe(false);
|
||||
expect(isIpBanExempt('')).toBe(false);
|
||||
expect(isIpBanExempt('not-an-ip')).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -201,6 +201,7 @@
|
||||
"@sapphi-red/web-noise-suppressor": "catalog:",
|
||||
"@simplewebauthn/browser": "catalog:",
|
||||
"@tanstack/react-virtual": "^3.14.13",
|
||||
"altcha-lib": "catalog:",
|
||||
"animejs": "4.5.0",
|
||||
"bowser": "catalog:",
|
||||
"clsx": "catalog:",
|
||||
|
||||
@@ -233,6 +233,15 @@
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.noticeLink {
|
||||
align-self: flex-start;
|
||||
border-radius: 0.25rem;
|
||||
color: var(--text-link);
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.45;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.integrationFields {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
MediaExpiryStep,
|
||||
type PremiumMode,
|
||||
PremiumStep,
|
||||
PushRelayConsentStep,
|
||||
type RegistrationMode,
|
||||
RegistrationStep,
|
||||
type ServiceAvailability,
|
||||
@@ -459,6 +460,7 @@ export const SelfHostedSetupWizardGate = observer(() => {
|
||||
youtube: false,
|
||||
bluesky: false,
|
||||
});
|
||||
const [pushRelayConsentAccepted, setPushRelayConsentAccepted] = useState(false);
|
||||
const [premiumMode, setPremiumMode] = useState<PremiumMode>('mirror');
|
||||
const [assets, setAssets] = useState<ReadonlyArray<BrandingAssetState>>(() =>
|
||||
BRANDING_ASSET_KINDS.map((kind) => ({kind, url: null, preview: null})),
|
||||
@@ -549,6 +551,7 @@ export const SelfHostedSetupWizardGate = observer(() => {
|
||||
clearStepNavigationLock();
|
||||
setIntegrationDraft({...DEFAULT_INTEGRATION_DRAFT});
|
||||
setMediaExpiryDraft({...DEFAULT_MEDIA_EXPIRY_DRAFT});
|
||||
setPushRelayConsentAccepted(false);
|
||||
setSmtpTesting(false);
|
||||
setSmtpTestResult(null);
|
||||
try {
|
||||
@@ -569,6 +572,7 @@ export const SelfHostedSetupWizardGate = observer(() => {
|
||||
setSingleCommunityEnabled(next.policy.single_community_enabled);
|
||||
setDirectMessagesDisabled(next.policy.direct_messages_disabled);
|
||||
setPremiumMode(next.policy.premium_mode);
|
||||
setPushRelayConsentAccepted(next.push_service_delivery.relay_consent_accepted);
|
||||
setServiceSelection({
|
||||
gif: next.policy.services_resolved.gif_enabled,
|
||||
youtube: next.policy.services_resolved.youtube_enabled,
|
||||
@@ -677,6 +681,7 @@ export const SelfHostedSetupWizardGate = observer(() => {
|
||||
if (step === 'branding') return !productNameError;
|
||||
if (step === 'community') return !singleCommunityNameError;
|
||||
if (step === 'media_expiry') return isMediaExpiryStepValid(mediaExpiryDraft);
|
||||
if (step === 'push_relay_consent') return true;
|
||||
const integrationKind = wizardStepToIntegrationKind(step);
|
||||
if (integrationKind) return isIntegrationStepValid(integrationKind, integrationDraft);
|
||||
return true;
|
||||
@@ -766,6 +771,10 @@ export const SelfHostedSetupWizardGate = observer(() => {
|
||||
const nextConfig = await updateInstanceConfig({
|
||||
integrations: buildIntegrationsPatch(integrationDraft),
|
||||
media: buildMediaPatch(mediaExpiryDraft),
|
||||
push_service_delivery:
|
||||
config.push_service_delivery.relay_consent_accepted === pushRelayConsentAccepted
|
||||
? undefined
|
||||
: {relay_consent_accepted: pushRelayConsentAccepted},
|
||||
registration: {mode: registrationMode},
|
||||
app_public: {
|
||||
branding: {
|
||||
@@ -804,6 +813,7 @@ export const SelfHostedSetupWizardGate = observer(() => {
|
||||
singleCommunityEnabled,
|
||||
singleCommunityNameTrimmed,
|
||||
directMessagesDisabled,
|
||||
pushRelayConsentAccepted,
|
||||
premiumMode,
|
||||
serviceAvailability,
|
||||
serviceSelection,
|
||||
@@ -972,6 +982,14 @@ export const SelfHostedSetupWizardGate = observer(() => {
|
||||
data-flx="app.setup.self-hosted-setup-wizard-gate.integration-step"
|
||||
/>
|
||||
)}
|
||||
{step === 'push_relay_consent' && (
|
||||
<PushRelayConsentStep
|
||||
accepted={pushRelayConsentAccepted}
|
||||
disabled={submitting}
|
||||
onChange={setPushRelayConsentAccepted}
|
||||
data-flx="app.setup.self-hosted-setup-wizard-gate.push-relay-consent-step"
|
||||
/>
|
||||
)}
|
||||
{step === 'services' && (
|
||||
<ServicesStep
|
||||
available={serviceAvailability}
|
||||
@@ -996,6 +1014,7 @@ export const SelfHostedSetupWizardGate = observer(() => {
|
||||
singleCommunityEnabled={singleCommunityEnabled}
|
||||
directMessagesDisabled={directMessagesDisabled}
|
||||
attachmentExpiryEnabled={mediaExpiryDraft.enabled}
|
||||
pushRelayConsentAccepted={pushRelayConsentAccepted}
|
||||
premiumMode={premiumMode}
|
||||
submitError={submitError}
|
||||
data-flx="app.setup.self-hosted-setup-wizard-gate.finish-step"
|
||||
|
||||
@@ -17,6 +17,7 @@ export type WizardStep =
|
||||
| 'integration_captcha'
|
||||
| 'integration_email'
|
||||
| 'integration_bluesky'
|
||||
| 'push_relay_consent'
|
||||
| 'services'
|
||||
| 'premium'
|
||||
| 'finish';
|
||||
@@ -36,6 +37,7 @@ export const CONFIGURE_STEPS: ReadonlyArray<WizardStep> = [
|
||||
'integration_captcha',
|
||||
'integration_email',
|
||||
'integration_bluesky',
|
||||
'push_relay_consent',
|
||||
'services',
|
||||
'premium',
|
||||
'finish',
|
||||
|
||||
@@ -13,6 +13,7 @@ import {ColorPickerField} from '@app/features/ui/components/form/ColorPickerFiel
|
||||
import {Input} from '@app/features/ui/components/form/FormInput';
|
||||
import {Switch} from '@app/features/ui/components/form/FormSwitch';
|
||||
import {Spinner} from '@app/features/ui/components/Spinner';
|
||||
import FocusRing from '@app/features/ui/focus_ring/FocusRing';
|
||||
import {RadioGroup, type RadioOption} from '@app/features/ui/radio_group/RadioGroup';
|
||||
import {ThemeSelector} from '@app/features/user/components/modals/tabs/appearance_tab/theme/ThemeTabContent';
|
||||
import {LanguageSelector} from '@app/features/user/components/modals/tabs/LanguageTab';
|
||||
@@ -27,6 +28,8 @@ import {observer} from 'mobx-react-lite';
|
||||
import type React from 'react';
|
||||
import {useCallback, useEffect, useLayoutEffect, useRef, useState} from 'react';
|
||||
|
||||
const PUSH_RELAY_NOTICE_URL = 'https://fluxer.com/push-relay';
|
||||
|
||||
export type RegistrationMode = 'open' | 'approval' | 'closed';
|
||||
export type PremiumMode = 'mirror' | 'everyone';
|
||||
|
||||
@@ -263,6 +266,29 @@ const MEDIA_RENEW_WINDOW_LABEL_DESCRIPTOR = msg({
|
||||
comment: 'Label for attachment decay renewal window.',
|
||||
});
|
||||
|
||||
const PUSH_RELAY_TITLE_DESCRIPTOR = msg({
|
||||
message: 'Mobile push notifications',
|
||||
comment: 'Setup wizard push relay consent step title.',
|
||||
});
|
||||
const PUSH_RELAY_BODY_DESCRIPTOR = msg({
|
||||
message:
|
||||
"The official Fluxer mobile apps receive notifications through Fluxer's push relay, which hands them to Apple and Google. Self-hosted UnifiedPush and ntfy endpoints never reach the relay and need no agreement.",
|
||||
comment: 'Setup wizard push relay consent step body.',
|
||||
});
|
||||
const PUSH_RELAY_ACCEPT_LABEL_DESCRIPTOR = msg({
|
||||
message: 'Accept the push relay supplemental privacy notice',
|
||||
comment: 'Label for the push relay consent switch during setup.',
|
||||
});
|
||||
const PUSH_RELAY_ACCEPT_DESC_DESCRIPTOR = msg({
|
||||
message:
|
||||
'Leaving this off keeps the relay unused and drops notifications to the official mobile apps. You can accept it later in the admin panel.',
|
||||
comment: 'Description for the push relay consent switch during setup.',
|
||||
});
|
||||
const PUSH_RELAY_NOTICE_LINK_DESCRIPTOR = msg({
|
||||
message: 'Read the supplemental privacy notice',
|
||||
comment: 'Link to the push relay supplemental privacy notice shown during setup.',
|
||||
});
|
||||
|
||||
const SERVICES_TITLE_DESCRIPTOR = msg({
|
||||
message: 'Optional services',
|
||||
comment: 'Setup wizard optional services step title.',
|
||||
@@ -353,10 +379,22 @@ const SUMMARY_ATTACHMENT_EXPIRY_DESCRIPTOR = msg({
|
||||
message: 'Attachment expiration',
|
||||
comment: 'Summary row label for the attachment expiry choice in the setup wizard.',
|
||||
});
|
||||
const SUMMARY_PUSH_RELAY_DESCRIPTOR = msg({
|
||||
message: 'Push relay notice',
|
||||
comment: 'Summary row label for the push relay consent on the setup wizard finish step.',
|
||||
});
|
||||
const SUMMARY_PREMIUM_DESCRIPTOR = msg({
|
||||
message: 'Premium model',
|
||||
comment: 'Summary row label for the premium model in the setup wizard.',
|
||||
});
|
||||
const SUMMARY_ACCEPTED_DESCRIPTOR = msg({
|
||||
message: 'Accepted',
|
||||
comment: 'Summary value when the operator accepted the push relay notice.',
|
||||
});
|
||||
const SUMMARY_NOT_ACCEPTED_DESCRIPTOR = msg({
|
||||
message: 'Not accepted',
|
||||
comment: 'Summary value when the operator left the push relay notice unaccepted.',
|
||||
});
|
||||
const SUMMARY_ON_DESCRIPTOR = msg({
|
||||
message: 'Enabled',
|
||||
comment: 'Summary value when a setup option is enabled.',
|
||||
@@ -1531,6 +1569,40 @@ export const IntegrationStep = observer(
|
||||
},
|
||||
);
|
||||
|
||||
export const PushRelayConsentStep = observer(
|
||||
({accepted, disabled, onChange}: {accepted: boolean; disabled: boolean; onChange: (value: boolean) => void}) => {
|
||||
const {i18n} = useLingui();
|
||||
return (
|
||||
<section className={styles.step} data-flx="app.self-hosted-setup-wizard-gate.push-relay-consent-step">
|
||||
<StepHeader
|
||||
title={i18n._(PUSH_RELAY_TITLE_DESCRIPTOR)}
|
||||
body={i18n._(PUSH_RELAY_BODY_DESCRIPTOR)}
|
||||
data-flx="app.setup.setup-wizard-steps.push-relay-consent-step.step-header"
|
||||
/>
|
||||
<Switch
|
||||
label={i18n._(PUSH_RELAY_ACCEPT_LABEL_DESCRIPTOR)}
|
||||
description={i18n._(PUSH_RELAY_ACCEPT_DESC_DESCRIPTOR)}
|
||||
value={accepted}
|
||||
onChange={onChange}
|
||||
disabled={disabled}
|
||||
data-flx="app.self-hosted-setup-wizard-gate.push-relay-consent-switch"
|
||||
/>
|
||||
<FocusRing data-flx="app.setup.setup-wizard-steps.push-relay-consent-step.focus-ring">
|
||||
<a
|
||||
className={styles.noticeLink}
|
||||
href={PUSH_RELAY_NOTICE_URL}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
data-flx="app.self-hosted-setup-wizard-gate.push-relay-notice-link"
|
||||
>
|
||||
{i18n._(PUSH_RELAY_NOTICE_LINK_DESCRIPTOR)}
|
||||
</a>
|
||||
</FocusRing>
|
||||
</section>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
export const ServicesStep = observer(
|
||||
({
|
||||
available,
|
||||
@@ -1650,6 +1722,7 @@ export const FinishStep = observer(
|
||||
singleCommunityEnabled,
|
||||
directMessagesDisabled,
|
||||
attachmentExpiryEnabled,
|
||||
pushRelayConsentAccepted,
|
||||
premiumMode,
|
||||
submitError,
|
||||
}: {
|
||||
@@ -1658,6 +1731,7 @@ export const FinishStep = observer(
|
||||
singleCommunityEnabled: boolean;
|
||||
directMessagesDisabled: boolean;
|
||||
attachmentExpiryEnabled: boolean;
|
||||
pushRelayConsentAccepted: boolean;
|
||||
premiumMode: PremiumMode;
|
||||
submitError: string | null;
|
||||
}) => {
|
||||
@@ -1705,10 +1779,17 @@ export const FinishStep = observer(
|
||||
value={attachmentExpiryEnabled ? onLabel : offLabel}
|
||||
data-flx="app.setup.setup-wizard-steps.finish-step.summary-row--5"
|
||||
/>
|
||||
<SummaryRow
|
||||
label={i18n._(SUMMARY_PUSH_RELAY_DESCRIPTOR)}
|
||||
value={
|
||||
pushRelayConsentAccepted ? i18n._(SUMMARY_ACCEPTED_DESCRIPTOR) : i18n._(SUMMARY_NOT_ACCEPTED_DESCRIPTOR)
|
||||
}
|
||||
data-flx="app.setup.setup-wizard-steps.finish-step.summary-row--6"
|
||||
/>
|
||||
<SummaryRow
|
||||
label={i18n._(SUMMARY_PREMIUM_DESCRIPTOR)}
|
||||
value={premiumLabel}
|
||||
data-flx="app.setup.setup-wizard-steps.finish-step.summary-row--6"
|
||||
data-flx="app.setup.setup-wizard-steps.finish-step.summary-row--7"
|
||||
/>
|
||||
</div>
|
||||
{submitError && (
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {solveChallengeWorkers} from 'altcha-lib';
|
||||
import type {Challenge} from 'altcha-lib/types';
|
||||
|
||||
export type AltchaChallenge = Challenge;
|
||||
|
||||
const MAX_SOLVER_WORKERS = 8;
|
||||
const SOLVE_TIMEOUT_MS = 120_000;
|
||||
|
||||
function createSolverWorker(): Worker {
|
||||
return new Worker(
|
||||
new URL(/* webpackChunkName: "altcha-solver.worker" */ './AltchaSolverWorker.ts', import.meta.url),
|
||||
{
|
||||
type: 'module',
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export function readAltchaChallenge(body: unknown): AltchaChallenge | null {
|
||||
if (typeof body !== 'object' || body === null) return null;
|
||||
const {captcha_provider: provider, altcha_challenge: challenge} = body as Record<string, unknown>;
|
||||
if (provider !== 'altcha' || typeof challenge !== 'object' || challenge === null) return null;
|
||||
const {parameters, signature} = challenge as Record<string, unknown>;
|
||||
if (typeof parameters !== 'object' || parameters === null || typeof signature !== 'string') return null;
|
||||
return challenge as AltchaChallenge;
|
||||
}
|
||||
|
||||
export async function solveAltchaChallenge(
|
||||
challenge: AltchaChallenge,
|
||||
controller: AbortController,
|
||||
): Promise<string | null> {
|
||||
const solution = await solveChallengeWorkers({
|
||||
challenge,
|
||||
concurrency: Math.min(MAX_SOLVER_WORKERS, navigator.hardwareConcurrency || 2),
|
||||
controller,
|
||||
createWorker: createSolverWorker,
|
||||
timeout: SOLVE_TIMEOUT_MS,
|
||||
});
|
||||
if (!solution) return null;
|
||||
return btoa(
|
||||
JSON.stringify({challenge: {parameters: challenge.parameters, signature: challenge.signature}, solution}),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {deriveKey} from 'altcha-lib/algorithms/web/pbkdf2';
|
||||
import {handler} from 'altcha-lib/workers/shared';
|
||||
|
||||
handler({deriveKey});
|
||||
@@ -0,0 +1,16 @@
|
||||
/* SPDX-License-Identifier: AGPL-3.0-or-later */
|
||||
|
||||
.container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 1rem 0;
|
||||
}
|
||||
|
||||
.text {
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.25rem;
|
||||
text-align: center;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {type AltchaChallenge, solveAltchaChallenge} from '@app/features/auth/altcha/AltchaSolver';
|
||||
import styles from '@app/features/auth/components/AltchaVerification.module.css';
|
||||
import {Logger} from '@app/features/platform/utils/AppLogger';
|
||||
import {Button} from '@app/features/ui/button/Button';
|
||||
import {Spinner} from '@app/features/ui/components/Spinner';
|
||||
import {Trans} from '@lingui/react/macro';
|
||||
import {useCallback, useEffect, useRef, useState} from 'react';
|
||||
|
||||
const logger = new Logger('AltchaVerification');
|
||||
|
||||
interface AltchaVerificationProps {
|
||||
challenge: AltchaChallenge;
|
||||
onVerify: (token: string) => void;
|
||||
}
|
||||
|
||||
export function AltchaVerification({challenge, onVerify}: AltchaVerificationProps) {
|
||||
const onVerifyRef = useRef(onVerify);
|
||||
const [attempt, setAttempt] = useState(0);
|
||||
const [failed, setFailed] = useState(false);
|
||||
useEffect(() => {
|
||||
onVerifyRef.current = onVerify;
|
||||
}, [onVerify]);
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
setFailed(false);
|
||||
solveAltchaChallenge(challenge, controller).then(
|
||||
(token) => {
|
||||
if (controller.signal.aborted) return;
|
||||
if (token) {
|
||||
onVerifyRef.current(token);
|
||||
} else {
|
||||
setFailed(true);
|
||||
}
|
||||
},
|
||||
(error: unknown) => {
|
||||
if (controller.signal.aborted) return;
|
||||
logger.error('ALTCHA solve failed:', error);
|
||||
setFailed(true);
|
||||
},
|
||||
);
|
||||
return () => controller.abort();
|
||||
}, [challenge, attempt]);
|
||||
const handleRetry = useCallback(() => setAttempt((value) => value + 1), []);
|
||||
if (failed) {
|
||||
return (
|
||||
<div className={styles.container} data-flx="auth.altcha-verification.failed">
|
||||
<p className={styles.text} data-flx="auth.altcha-verification.failed-text">
|
||||
<Trans>Your browser couldn't finish the check.</Trans>
|
||||
</p>
|
||||
<Button small variant="secondary" onClick={handleRetry} data-flx="auth.altcha-verification.retry-button">
|
||||
<Trans>Try again</Trans>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className={styles.container} role="status" aria-live="polite" data-flx="auth.altcha-verification.solving">
|
||||
<Spinner data-flx="auth.altcha-verification.spinner" />
|
||||
<p className={styles.text} data-flx="auth.altcha-verification.solving-text">
|
||||
<Trans>Checking your browser. This takes a few seconds.</Trans>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {type AltchaChallenge, readAltchaChallenge} from '@app/features/auth/altcha/AltchaSolver';
|
||||
import {CaptchaModal, type CaptchaType} from '@app/features/auth/components/modals/CaptchaModal';
|
||||
import {http} from '@app/features/platform/transport/RestTransport';
|
||||
import type {RestResponse} from '@app/features/platform/types/TransportTypes';
|
||||
@@ -69,7 +70,7 @@ class CaptchaInterceptorState {
|
||||
return code === 'CAPTCHA_REQUIRED' || code === 'INVALID_CAPTCHA';
|
||||
}
|
||||
|
||||
private showCaptchaModal(): Promise<CaptchaResult> {
|
||||
private showCaptchaModal(altchaChallenge: AltchaChallenge | null): Promise<CaptchaResult> {
|
||||
if (this.pendingPromise) {
|
||||
this.pendingPromise.reject(new Error('Captcha cancelled'));
|
||||
this.pendingPromise = null;
|
||||
@@ -95,6 +96,7 @@ class CaptchaInterceptorState {
|
||||
};
|
||||
const CaptchaModalWrapper = observer(() => (
|
||||
<CaptchaModal
|
||||
altchaChallenge={altchaChallenge}
|
||||
onVerify={handleVerify}
|
||||
onCancel={handleCancel}
|
||||
error={this.state.error}
|
||||
@@ -119,7 +121,7 @@ class CaptchaInterceptorState {
|
||||
const errorMessage = replyMessage(reply.body) || i18n._(CAPTCHA_VERIFICATION_FAILED_PLEASE_TRY_AGAIN_DESCRIPTOR);
|
||||
this.state.setError(errorMessage);
|
||||
this.state.setIsVerifying(false);
|
||||
const promise = this.showCaptchaModal()
|
||||
const promise = this.showCaptchaModal(readAltchaChallenge(reply.body))
|
||||
.then((captchaResult) => {
|
||||
this.state.setError(null);
|
||||
this.state.setIsVerifying(false);
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
import * as Modal from '@app/features/app/components/dialogs/Modal';
|
||||
import RuntimeConfig from '@app/features/app/state/RuntimeConfig';
|
||||
import type {AltchaChallenge} from '@app/features/auth/altcha/AltchaSolver';
|
||||
import {AltchaVerification} from '@app/features/auth/components/AltchaVerification';
|
||||
import styles from '@app/features/auth/components/modals/CaptchaModal.module.css';
|
||||
import {TurnstileWidget} from '@app/features/auth/components/TurnstileWidget';
|
||||
import {Logger} from '@app/features/platform/utils/AppLogger';
|
||||
@@ -18,7 +20,7 @@ const VERIFY_YOU_RE_HUMAN_DESCRIPTOR = msg({
|
||||
});
|
||||
const logger = new Logger('CaptchaModal');
|
||||
|
||||
export type CaptchaType = 'turnstile' | 'hcaptcha';
|
||||
export type CaptchaType = 'turnstile' | 'hcaptcha' | 'altcha';
|
||||
|
||||
interface HCaptchaComponentProps {
|
||||
sitekey: string;
|
||||
@@ -35,16 +37,26 @@ interface CaptchaModalProps {
|
||||
onVerify: (token: string, captchaType: CaptchaType) => void;
|
||||
onCancel?: () => void;
|
||||
preferredType?: CaptchaType;
|
||||
altchaChallenge?: AltchaChallenge | null;
|
||||
error?: string | null;
|
||||
isVerifying?: boolean;
|
||||
closeOnVerify?: boolean;
|
||||
}
|
||||
|
||||
export const CaptchaModal = observer(
|
||||
({onVerify, onCancel, preferredType, error, isVerifying, closeOnVerify = true}: CaptchaModalProps) => {
|
||||
({
|
||||
onVerify,
|
||||
onCancel,
|
||||
preferredType,
|
||||
altchaChallenge,
|
||||
error,
|
||||
isVerifying,
|
||||
closeOnVerify = true,
|
||||
}: CaptchaModalProps) => {
|
||||
const {i18n} = useLingui();
|
||||
const hcaptchaRef = useRef<HCaptcha>(null);
|
||||
const [captchaType, setCaptchaType] = useState<CaptchaType>(() => {
|
||||
if (altchaChallenge) return 'altcha';
|
||||
if (preferredType) return preferredType;
|
||||
if (RuntimeConfig.captchaProvider === 'turnstile' && RuntimeConfig.turnstileSiteKey) {
|
||||
return 'turnstile';
|
||||
@@ -123,7 +135,13 @@ export const CaptchaModal = observer(
|
||||
</div>
|
||||
)}
|
||||
<div className={styles.captchaContainer} data-flx="auth.captcha-modal.captcha-container">
|
||||
{captchaType === 'turnstile' ? (
|
||||
{captchaType === 'altcha' && altchaChallenge ? (
|
||||
<AltchaVerification
|
||||
challenge={altchaChallenge}
|
||||
onVerify={handleVerify}
|
||||
data-flx="auth.captcha-modal.altcha-verification"
|
||||
/>
|
||||
) : captchaType === 'turnstile' ? (
|
||||
<TurnstileWidget
|
||||
sitekey={RuntimeConfig.turnstileSiteKey ?? ''}
|
||||
onVerify={handleVerify}
|
||||
|
||||
+1
@@ -141,6 +141,7 @@ export const OAuthAuthorizeFlowPanel: React.FC<OAuthAuthorizeFlowPanelProps> = o
|
||||
<OAuthScopesStep
|
||||
authParams={authParams}
|
||||
botInviteWithoutRedirect={flow.botInviteWithoutRedirect}
|
||||
cannotSubmit={flow.cannotSubmit}
|
||||
clientLabel={flow.clientLabel}
|
||||
hasNextStep={flow.hasNextStep}
|
||||
hasPreviousStep={flow.hasPreviousStep}
|
||||
|
||||
+12
-3
@@ -29,6 +29,7 @@ const REQUIRED_DESCRIPTOR = msg({
|
||||
interface OAuthScopesStepProps {
|
||||
authParams: AuthorizeParams;
|
||||
botInviteWithoutRedirect: boolean;
|
||||
cannotSubmit: boolean;
|
||||
clientLabel: string;
|
||||
hasNextStep: boolean;
|
||||
hasPreviousStep: boolean;
|
||||
@@ -51,6 +52,7 @@ interface OAuthScopesStepProps {
|
||||
export const OAuthScopesStep: React.FC<OAuthScopesStepProps> = ({
|
||||
authParams,
|
||||
botInviteWithoutRedirect,
|
||||
cannotSubmit,
|
||||
clientLabel,
|
||||
hasNextStep,
|
||||
hasPreviousStep,
|
||||
@@ -151,10 +153,16 @@ export const OAuthScopesStep: React.FC<OAuthScopesStepProps> = ({
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
{scopesAdjusted && (
|
||||
<div className={styles.caution} data-flx="auth.o-auth-authorize-page.caution--2">
|
||||
<Trans>Turning off scopes may prevent the app from working correctly.</Trans>
|
||||
{scopes.length > 0 && selectedScopes.size === 0 ? (
|
||||
<div className={styles.caution} data-flx="auth.o-auth-authorize-page.caution--no-scopes">
|
||||
<Trans>Turn on at least one scope to authorize this app.</Trans>
|
||||
</div>
|
||||
) : (
|
||||
scopesAdjusted && (
|
||||
<div className={styles.caution} data-flx="auth.o-auth-authorize-page.caution--2">
|
||||
<Trans>Turning off scopes may prevent the app from working correctly.</Trans>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -165,6 +173,7 @@ export const OAuthScopesStep: React.FC<OAuthScopesStepProps> = ({
|
||||
showRedirectNotice={showRedirectNotice}
|
||||
hasPreviousStep={hasPreviousStep}
|
||||
hasNextStep={hasNextStep}
|
||||
authorizeDisabled={cannotSubmit}
|
||||
onAuthorize={onAuthorize}
|
||||
onBack={onBack}
|
||||
onCancel={onCancel}
|
||||
|
||||
+3
-3
@@ -306,7 +306,7 @@ export function useAuthorizeFlow(options: UseAuthorizeFlowOptions = {}): Authori
|
||||
() => destinations.options.find((option) => option.value === selectedDestinationKey) ?? null,
|
||||
[destinations.options, selectedDestinationKey],
|
||||
);
|
||||
const cannotSubmit = hasBotScope && !selectedDestination;
|
||||
const cannotSubmit = scopeSelection.selected.size === 0 || (hasBotScope && !selectedDestination);
|
||||
const needsPermissionsStep =
|
||||
hasBotScope && selectedDestination?.kind !== 'group_dm' && permissionSelection.requestedKeys.length > 0;
|
||||
const hasRequestedBotPermissions =
|
||||
@@ -350,9 +350,9 @@ export function useAuthorizeFlow(options: UseAuthorizeFlowOptions = {}): Authori
|
||||
setSubmitError(null);
|
||||
setSubmitting('approve');
|
||||
try {
|
||||
const scopeToSend = scopeSelection.toScopeString() || params.scope;
|
||||
const scopeToSend = scopeSelection.toScopeString();
|
||||
const sendsBotScope = scopeToSend.split(/[\s+]+/).includes('bot');
|
||||
if (sendsBotScope && !selectedDestination) {
|
||||
if (!scopeToSend || (sendsBotScope && !selectedDestination)) {
|
||||
setSubmitting(null);
|
||||
return;
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1419,6 +1419,12 @@
|
||||
{
|
||||
"msgid": "About me is too long"
|
||||
},
|
||||
{
|
||||
"msgid": "Accept the push relay supplemental privacy notice"
|
||||
},
|
||||
{
|
||||
"msgid": "Accepted"
|
||||
},
|
||||
{
|
||||
"msgid": "Add a Klipy API key to enable GIF search at runtime."
|
||||
},
|
||||
@@ -1719,6 +1725,9 @@
|
||||
{
|
||||
"msgid": "Changed the voice region from {oldRegion} to {newRegion}."
|
||||
},
|
||||
{
|
||||
"msgid": "Checking your browser. This takes a few seconds."
|
||||
},
|
||||
{
|
||||
"msgid": "Choices"
|
||||
},
|
||||
@@ -2235,6 +2244,9 @@
|
||||
{
|
||||
"msgid": "No part of {query} could be applied. Fix the underlined value or add something to search for."
|
||||
},
|
||||
{
|
||||
"msgid": "Not accepted"
|
||||
},
|
||||
{
|
||||
"msgid": "Nothing to search for"
|
||||
},
|
||||
@@ -2379,12 +2391,18 @@
|
||||
{
|
||||
"msgid": "Public registration is closed."
|
||||
},
|
||||
{
|
||||
"msgid": "Push relay notice"
|
||||
},
|
||||
{
|
||||
"msgid": "Read messages sent before they opened a channel. Without it, they only see messages that arrive while it is open."
|
||||
},
|
||||
{
|
||||
"msgid": "Read messages sent in this channel before they opened it. Without it, they only see messages that arrive while it is open."
|
||||
},
|
||||
{
|
||||
"msgid": "Read the supplemental privacy notice"
|
||||
},
|
||||
{
|
||||
"msgid": "Reason (optional)."
|
||||
},
|
||||
@@ -3057,6 +3075,9 @@
|
||||
{
|
||||
"msgid": "The new price is already scheduled for {effectiveDate}."
|
||||
},
|
||||
{
|
||||
"msgid": "The official Fluxer mobile apps receive notifications through Fluxer's push relay, which hands them to Apple and Google. Self-hosted UnifiedPush and ntfy endpoints never reach the relay and need no agreement."
|
||||
},
|
||||
{
|
||||
"msgid": "The override allowed {permissions}."
|
||||
},
|
||||
@@ -3327,6 +3348,9 @@
|
||||
{
|
||||
"msgid": "Your account works normally again straight away and nothing is removed. We can ask for this check again later."
|
||||
},
|
||||
{
|
||||
"msgid": "Your browser couldn't finish the check."
|
||||
},
|
||||
{
|
||||
"msgid": "Your capture device is sending {deliveredResolution} instead of {resolution}."
|
||||
},
|
||||
@@ -3870,6 +3894,9 @@
|
||||
{
|
||||
"msgid": "{actor} updated {target}."
|
||||
},
|
||||
{
|
||||
"msgid": "{appName} can't reach its servers. It will keep trying in the background."
|
||||
},
|
||||
{
|
||||
"msgid": "{channelHeading}, {mentionCount, plural, one {# mention} other {# mentions}}"
|
||||
},
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -878,7 +878,7 @@ export async function forward(
|
||||
embed_indices: messageReference.embed_indices,
|
||||
type: 1,
|
||||
},
|
||||
flags: 1,
|
||||
flags: normalizedComment?.flags ?? 0,
|
||||
});
|
||||
if (!forwardedMessage) {
|
||||
logger.warn(`Forward send failed in channel ${channelId}`);
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
IDLE_DESCRIPTOR,
|
||||
ONLINE_DESCRIPTOR,
|
||||
OPEN_SETTINGS_DESCRIPTOR,
|
||||
TRY_AGAIN_DESCRIPTOR,
|
||||
} from '@app/features/i18n/utils/CommonMessageDescriptors';
|
||||
import {onLocaleChange} from '@app/features/i18n/utils/LocaleChangeListener';
|
||||
import {Logger} from '@app/features/platform/utils/AppLogger';
|
||||
@@ -286,6 +287,16 @@ const FAILED_TO_START_DESCRIPTOR = msg({
|
||||
comment:
|
||||
'Title of the native error dialog shown when the desktop app cannot boot. {appName} is the desktop app name (typically Fluxer).',
|
||||
});
|
||||
const APP_LOAD_FAILED_TITLE_DESCRIPTOR = msg({
|
||||
message: "Can't connect",
|
||||
comment:
|
||||
'Title of the native dialog shown when the desktop app has repeatedly failed to load because it cannot reach the servers.',
|
||||
});
|
||||
const APP_LOAD_FAILED_MESSAGE_DESCRIPTOR = msg({
|
||||
message: "{appName} can't reach its servers. It will keep trying in the background.",
|
||||
comment:
|
||||
'Body of the native dialog shown when the desktop app has repeatedly failed to load. {appName} is the desktop app name (typically Fluxer). The app keeps retrying on its own while the dialog is open.',
|
||||
});
|
||||
const LINUX_ENTRY_GENERIC_NAME_DESCRIPTOR = msg({
|
||||
message: 'Instant Messenger',
|
||||
comment:
|
||||
@@ -376,6 +387,9 @@ const NATIVE_MESSAGES: Record<string, NativeMessage> = {
|
||||
'desktop.badge.unreadMessages': UNREAD_MESSAGES_DESCRIPTOR,
|
||||
'desktop.badge.unreadMessagesCount': UNREAD_MESSAGES_COUNT_DESCRIPTOR,
|
||||
'desktop.startup.failedTitle': FAILED_TO_START_DESCRIPTOR,
|
||||
'desktop.appLoad.failedTitle': APP_LOAD_FAILED_TITLE_DESCRIPTOR,
|
||||
'desktop.appLoad.failedMessage': APP_LOAD_FAILED_MESSAGE_DESCRIPTOR,
|
||||
'desktop.appLoad.retry': TRY_AGAIN_DESCRIPTOR,
|
||||
'desktop.linuxEntry.genericName': LINUX_ENTRY_GENERIC_NAME_DESCRIPTOR,
|
||||
'desktop.linuxEntry.comment': LINUX_ENTRY_COMMENT_DESCRIPTOR,
|
||||
};
|
||||
|
||||
@@ -18,7 +18,8 @@ pub async fn assetlinks() -> Response {
|
||||
"namespace": "android_app",
|
||||
"package_name": package_name,
|
||||
"sha256_cert_fingerprints": [
|
||||
"91:E4:98:E1:B8:A6:C8:BA:99:41:5E:DB:29:78:29:6B:6C:58:BA:A5:E2:D2:A6:49:CE:C6:2D:A7:A8:29:C7:BC"
|
||||
"91:E4:98:E1:B8:A6:C8:BA:99:41:5E:DB:29:78:29:6B:6C:58:BA:A5:E2:D2:A6:49:CE:C6:2D:A7:A8:29:C7:BC",
|
||||
"2D:DE:2A:9D:3D:13:7C:17:87:31:3D:D8:BB:61:F4:59:3C:2A:97:18:05:B3:ED:F6:B8:39:B0:AA:D2:E7:76:C9"
|
||||
]
|
||||
}
|
||||
})
|
||||
@@ -60,7 +61,8 @@ mod tests {
|
||||
"namespace": "android_app",
|
||||
"package_name": "com.fluxer",
|
||||
"sha256_cert_fingerprints": [
|
||||
"91:E4:98:E1:B8:A6:C8:BA:99:41:5E:DB:29:78:29:6B:6C:58:BA:A5:E2:D2:A6:49:CE:C6:2D:A7:A8:29:C7:BC"
|
||||
"91:E4:98:E1:B8:A6:C8:BA:99:41:5E:DB:29:78:29:6B:6C:58:BA:A5:E2:D2:A6:49:CE:C6:2D:A7:A8:29:C7:BC",
|
||||
"2D:DE:2A:9D:3D:13:7C:17:87:31:3D:D8:BB:61:F4:59:3C:2A:97:18:05:B3:ED:F6:B8:39:B0:AA:D2:E7:76:C9"
|
||||
]
|
||||
}
|
||||
},
|
||||
@@ -73,7 +75,8 @@ mod tests {
|
||||
"namespace": "android_app",
|
||||
"package_name": "com.fluxer.canary",
|
||||
"sha256_cert_fingerprints": [
|
||||
"91:E4:98:E1:B8:A6:C8:BA:99:41:5E:DB:29:78:29:6B:6C:58:BA:A5:E2:D2:A6:49:CE:C6:2D:A7:A8:29:C7:BC"
|
||||
"91:E4:98:E1:B8:A6:C8:BA:99:41:5E:DB:29:78:29:6B:6C:58:BA:A5:E2:D2:A6:49:CE:C6:2D:A7:A8:29:C7:BC",
|
||||
"2D:DE:2A:9D:3D:13:7C:17:87:31:3D:D8:BB:61:F4:59:3C:2A:97:18:05:B3:ED:F6:B8:39:B0:AA:D2:E7:76:C9"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import assert from 'node:assert/strict';
|
||||
import {readFileSync} from 'node:fs';
|
||||
import {createRequire} from 'node:module';
|
||||
import {describe, test} from 'node:test';
|
||||
import {fileURLToPath} from 'node:url';
|
||||
import vm from 'node:vm';
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const esbuild = require('esbuild');
|
||||
|
||||
const sourcePath = fileURLToPath(new URL('./AppLoadRetry.ts', import.meta.url));
|
||||
const source = readFileSync(sourcePath, 'utf8');
|
||||
const transformedSource = esbuild.transformSync(source, {
|
||||
loader: 'ts',
|
||||
format: 'cjs',
|
||||
platform: 'node',
|
||||
target: 'node20',
|
||||
}).code;
|
||||
|
||||
function loadAppLoadRetry() {
|
||||
const module = {exports: {}};
|
||||
const context = vm.createContext({
|
||||
module,
|
||||
exports: module.exports,
|
||||
});
|
||||
vm.runInContext(transformedSource, context, {filename: sourcePath});
|
||||
return module.exports;
|
||||
}
|
||||
|
||||
const APP_URL = 'https://web.canary.fluxer.app/';
|
||||
const MIGRATED_URL = 'https://canary.fluxer.com/';
|
||||
const silentLogger = {info() {}, warn() {}, error() {}};
|
||||
|
||||
function createHarness({appUrl = APP_URL, fallbackUrl = null} = {}) {
|
||||
const listeners = new Map();
|
||||
const timers = [];
|
||||
const loads = [];
|
||||
const prompts = [];
|
||||
let commits = 0;
|
||||
let outcome = 'dns-failure';
|
||||
const webContents = {
|
||||
on(event, listener) {
|
||||
listeners.set(event, [...(listeners.get(event) ?? []), listener]);
|
||||
},
|
||||
emit(event, ...args) {
|
||||
for (const listener of listeners.get(event) ?? []) listener({}, ...args);
|
||||
},
|
||||
isDestroyed: () => false,
|
||||
isLoadingMainFrame: () => false,
|
||||
loadURL(url) {
|
||||
loads.push(url);
|
||||
if (outcome === 'ok') {
|
||||
webContents.emit('did-navigate', url, 200, 'OK');
|
||||
webContents.emit('did-finish-load');
|
||||
return Promise.resolve();
|
||||
}
|
||||
if (outcome === 'aborted') {
|
||||
return Promise.reject(new Error(`ERR_ABORTED (-3) loading '${url}'`));
|
||||
}
|
||||
webContents.emit('did-fail-load', -105, 'ERR_NAME_NOT_RESOLVED', url, true);
|
||||
webContents.emit('did-finish-load');
|
||||
return Promise.reject(new Error(`ERR_NAME_NOT_RESOLVED (-105) loading '${url}'`));
|
||||
},
|
||||
};
|
||||
const {createAppLoadRetry} = loadAppLoadRetry();
|
||||
const retry = createAppLoadRetry({
|
||||
webContents,
|
||||
appUrl,
|
||||
logger: silentLogger,
|
||||
isTrustedUrl: () => true,
|
||||
getFallbackUrl: (url) => (url === MIGRATED_URL ? fallbackUrl : null),
|
||||
onRepeatedFailure: (failure) => prompts.push(failure),
|
||||
onCommitted: () => {
|
||||
commits += 1;
|
||||
},
|
||||
setTimer: (callback, delay) => {
|
||||
const timer = {callback, delay, cleared: false};
|
||||
timers.push(timer);
|
||||
return timer;
|
||||
},
|
||||
clearTimer: (timer) => {
|
||||
timer.cleared = true;
|
||||
},
|
||||
});
|
||||
const settle = () => new Promise((resolve) => setImmediate(resolve));
|
||||
return {
|
||||
retry,
|
||||
loads,
|
||||
prompts,
|
||||
get commits() {
|
||||
return commits;
|
||||
},
|
||||
setOutcome(next) {
|
||||
outcome = next;
|
||||
},
|
||||
pendingDelays: () => timers.filter((timer) => !timer.cleared && !timer.fired).map((timer) => timer.delay),
|
||||
async fireNextTimer() {
|
||||
const timer = timers.find((candidate) => !candidate.cleared && !candidate.fired);
|
||||
assert.ok(timer, 'expected a pending retry timer');
|
||||
timer.fired = true;
|
||||
timer.callback();
|
||||
await settle();
|
||||
return timer.delay;
|
||||
},
|
||||
settle,
|
||||
};
|
||||
}
|
||||
|
||||
describe('AppLoadRetry', () => {
|
||||
test('backs off on a DNS failure even though the error page fires did-finish-load', async () => {
|
||||
const harness = createHarness();
|
||||
harness.retry.start();
|
||||
await harness.settle();
|
||||
|
||||
const delays = [];
|
||||
for (let i = 0; i < 8; i += 1) {
|
||||
assert.equal(harness.pendingDelays().length, 1);
|
||||
delays.push(await harness.fireNextTimer());
|
||||
}
|
||||
|
||||
assert.deepEqual(delays, [1000, 2000, 4000, 8000, 16000, 30000, 30000, 30000]);
|
||||
assert.equal(harness.loads.length, 9);
|
||||
});
|
||||
|
||||
test('resets the backoff and dismisses the prompt once the app commits', async () => {
|
||||
const harness = createHarness();
|
||||
harness.retry.start();
|
||||
await harness.settle();
|
||||
await harness.fireNextTimer();
|
||||
await harness.fireNextTimer();
|
||||
assert.equal(harness.prompts.length, 1);
|
||||
|
||||
harness.setOutcome('ok');
|
||||
await harness.fireNextTimer();
|
||||
assert.equal(harness.commits, 1);
|
||||
assert.deepEqual(harness.pendingDelays(), []);
|
||||
|
||||
harness.setOutcome('dns-failure');
|
||||
harness.retry.retryNow();
|
||||
await harness.settle();
|
||||
assert.deepEqual(harness.pendingDelays(), [1000]);
|
||||
});
|
||||
|
||||
test('asks the user only after three consecutive failures', async () => {
|
||||
const harness = createHarness();
|
||||
harness.retry.start();
|
||||
await harness.settle();
|
||||
await harness.fireNextTimer();
|
||||
assert.equal(harness.prompts.length, 0);
|
||||
|
||||
await harness.fireNextTimer();
|
||||
assert.equal(harness.prompts.length, 1);
|
||||
assert.equal(harness.prompts[0].errorCode, -105);
|
||||
assert.equal(harness.prompts[0].url, APP_URL);
|
||||
});
|
||||
|
||||
test('does not schedule a retry for an aborted load', async () => {
|
||||
const harness = createHarness();
|
||||
harness.setOutcome('aborted');
|
||||
harness.retry.start();
|
||||
await harness.settle();
|
||||
|
||||
assert.deepEqual(harness.pendingDelays(), []);
|
||||
});
|
||||
|
||||
test('falls back from the migrated origin without backing off', async () => {
|
||||
const harness = createHarness({appUrl: MIGRATED_URL, fallbackUrl: APP_URL});
|
||||
harness.retry.start();
|
||||
await harness.settle();
|
||||
|
||||
assert.deepEqual(harness.loads.slice(0, 2), [MIGRATED_URL, APP_URL]);
|
||||
assert.equal(harness.retry.getAppUrl(), APP_URL);
|
||||
assert.deepEqual(harness.pendingDelays(), [1000]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,137 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
const INITIAL_RETRY_DELAY_MS = 1000;
|
||||
const MAX_RETRY_DELAY_MS = 30000;
|
||||
const FAILURES_BEFORE_PROMPT = 3;
|
||||
const ERR_ABORTED = -3;
|
||||
|
||||
export interface AppLoadFailure {
|
||||
errorCode: number;
|
||||
errorDescription: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
interface AppLoadRetryLogger {
|
||||
info(message: string, detail?: Record<string, unknown>): void;
|
||||
warn(message: string, detail?: Record<string, unknown>): void;
|
||||
error(message: string, detail?: Record<string, unknown>): void;
|
||||
}
|
||||
|
||||
interface AppLoadRetryOptions {
|
||||
webContents: Pick<Electron.WebContents, 'on' | 'isDestroyed' | 'isLoadingMainFrame' | 'loadURL'>;
|
||||
appUrl: string;
|
||||
logger: AppLoadRetryLogger;
|
||||
isTrustedUrl: (url: string) => boolean;
|
||||
getFallbackUrl: (failedUrl: string) => string | null;
|
||||
onRepeatedFailure: (failure: AppLoadFailure) => void;
|
||||
onCommitted: () => void;
|
||||
setTimer?: (callback: () => void, delayMs: number) => ReturnType<typeof setTimeout>;
|
||||
clearTimer?: (timer: ReturnType<typeof setTimeout>) => void;
|
||||
}
|
||||
|
||||
export interface AppLoadRetry {
|
||||
start(): void;
|
||||
retryNow(): void;
|
||||
getAppUrl(): string;
|
||||
}
|
||||
|
||||
function isRetryableLoadError(errorCode: number): boolean {
|
||||
return errorCode < 0 && errorCode !== ERR_ABORTED;
|
||||
}
|
||||
|
||||
function getLoadErrorCode(error: unknown): number | null {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
const match = /\(([-\d]+)\)/.exec(message);
|
||||
if (!match) return null;
|
||||
const value = Number.parseInt(match[1], 10);
|
||||
return Number.isFinite(value) ? value : null;
|
||||
}
|
||||
|
||||
export function createAppLoadRetry(options: AppLoadRetryOptions): AppLoadRetry {
|
||||
const {webContents, logger} = options;
|
||||
const setTimer = options.setTimer ?? setTimeout;
|
||||
const clearTimer = options.clearTimer ?? clearTimeout;
|
||||
let appUrl = options.appUrl;
|
||||
let attempt = 0;
|
||||
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||
const cancelTimer = () => {
|
||||
if (timer) {
|
||||
clearTimer(timer);
|
||||
timer = null;
|
||||
}
|
||||
};
|
||||
const load = (reason: string) => {
|
||||
if (webContents.isDestroyed()) return;
|
||||
webContents.loadURL(appUrl).catch((error: unknown) => {
|
||||
const errorCode = getLoadErrorCode(error);
|
||||
if (errorCode !== null && !isRetryableLoadError(errorCode)) {
|
||||
logger.info('Ignoring non-retryable app load rejection', {reason, errorCode});
|
||||
return;
|
||||
}
|
||||
schedule(`${reason}-rejected`, {error});
|
||||
});
|
||||
};
|
||||
const schedule = (reason: string, detail?: Record<string, unknown>) => {
|
||||
if (timer) return;
|
||||
const delay = Math.min(INITIAL_RETRY_DELAY_MS * 2 ** attempt, MAX_RETRY_DELAY_MS);
|
||||
attempt += 1;
|
||||
logger.warn('Scheduling app load retry', {reason, delay, attempt, ...detail});
|
||||
timer = setTimer(() => {
|
||||
timer = null;
|
||||
if (webContents.isDestroyed()) return;
|
||||
if (webContents.isLoadingMainFrame()) {
|
||||
schedule('main-frame-still-loading');
|
||||
return;
|
||||
}
|
||||
load('retry');
|
||||
}, delay);
|
||||
};
|
||||
const reset = () => {
|
||||
cancelTimer();
|
||||
attempt = 0;
|
||||
};
|
||||
const fallBackFromMigratedOrigin = (failedUrl: string, detail: Record<string, unknown>): boolean => {
|
||||
const fallbackUrl = options.getFallbackUrl(failedUrl);
|
||||
if (fallbackUrl === null || fallbackUrl === appUrl) return false;
|
||||
logger.warn('Migrated app origin failed to load, falling back to the legacy app URL', {failedUrl, ...detail});
|
||||
appUrl = fallbackUrl;
|
||||
reset();
|
||||
load('legacy-fallback');
|
||||
return true;
|
||||
};
|
||||
return {
|
||||
start() {
|
||||
webContents.on('did-navigate', (_event, url, httpResponseCode) => {
|
||||
reset();
|
||||
options.onCommitted();
|
||||
if (httpResponseCode >= 400) {
|
||||
fallBackFromMigratedOrigin(url, {httpResponseCode});
|
||||
}
|
||||
});
|
||||
webContents.on('did-fail-load', (_event, errorCode, errorDescription, validatedURL, isMainFrame) => {
|
||||
if (isMainFrame) {
|
||||
logger.error('App main-frame load failed', {errorCode, errorDescription, validatedURL});
|
||||
}
|
||||
if (!isMainFrame || !options.isTrustedUrl(validatedURL) || !isRetryableLoadError(errorCode)) {
|
||||
return;
|
||||
}
|
||||
if (fallBackFromMigratedOrigin(validatedURL, {errorCode, errorDescription})) {
|
||||
return;
|
||||
}
|
||||
schedule('did-fail-load', {errorCode, errorDescription, validatedURL});
|
||||
if (attempt >= FAILURES_BEFORE_PROMPT) {
|
||||
options.onRepeatedFailure({errorCode, errorDescription, url: validatedURL});
|
||||
}
|
||||
});
|
||||
logger.info('Loading app URL', {appUrl});
|
||||
load('initial-load');
|
||||
},
|
||||
retryNow() {
|
||||
reset();
|
||||
load('manual-retry');
|
||||
},
|
||||
getAppUrl() {
|
||||
return appUrl;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -85,6 +85,9 @@ const FALLBACK_STRINGS: Readonly<Record<string, string>> = Object.freeze({
|
||||
'desktop.badge.unreadMessages': 'Unread messages',
|
||||
'desktop.badge.unreadMessagesCount': 'Unread messages: {count}',
|
||||
'desktop.startup.failedTitle': '{appName} failed to start',
|
||||
'desktop.appLoad.failedTitle': "Can't connect",
|
||||
'desktop.appLoad.failedMessage': "{appName} can't reach its servers. It will keep trying in the background.",
|
||||
'desktop.appLoad.retry': 'Try again',
|
||||
'desktop.linuxEntry.genericName': 'Instant Messenger',
|
||||
'desktop.linuxEntry.comment': 'Instant messaging and VoIP',
|
||||
});
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
} from '@electron/common/DesktopConfig';
|
||||
import {createChildLogger} from '@electron/common/Logger';
|
||||
import type {DesktopWindowBehaviorSettings} from '@electron/common/Types';
|
||||
import {createAppLoadRetry} from '@electron/main/AppLoadRetry';
|
||||
import {
|
||||
shouldForwardRendererConsoleToMainLog,
|
||||
shouldIgnoreWindowStateForLaunch,
|
||||
@@ -29,19 +30,18 @@ import {
|
||||
import {hasActiveDesktopTray, refreshDesktopTrayMenu} from '@electron/main/DesktopTray';
|
||||
import {drainPendingDisplayMediaRequests, registerDisplayMediaRequestHandler} from '@electron/main/DisplayMedia';
|
||||
import {shouldDisableV8CodeCache} from '@electron/main/LaunchOptions';
|
||||
import {t} from '@electron/main/MainI18n';
|
||||
import {openExternalDeduped} from '@electron/main/OpenExternal';
|
||||
import {registerSpellcheck} from '@electron/main/Spellcheck';
|
||||
import {resetStreamingPriority} from '@electron/main/StreamingPriority';
|
||||
import {getMainWindowRendererGoneAction} from '@electron/main/WindowRendererLifecycle';
|
||||
import {refreshWindowsBadgeOverlay} from '@electron/main/WindowsBadge';
|
||||
import {app, BrowserWindow, screen} from 'electron';
|
||||
import {app, BrowserWindow, dialog, screen} from 'electron';
|
||||
import log from 'electron-log';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const logger = createChildLogger('Window');
|
||||
const VISIBILITY_MARGIN = 32;
|
||||
const INITIAL_APP_LOAD_RETRY_DELAY_MS = 1000;
|
||||
const MAX_APP_LOAD_RETRY_DELAY_MS = 30000;
|
||||
const RENDERER_GONE_REPEAT_WINDOW_MS = 30000;
|
||||
const OPAQUE_WINDOW_BACKGROUND_COLOR = '#1a1a1a';
|
||||
const TRANSPARENT_WINDOW_BACKGROUND_COLOR = '#00000000';
|
||||
@@ -91,18 +91,6 @@ const trustedRendererPermissionTypes = new Set([
|
||||
]);
|
||||
const POPOUT_NAMESPACE = 'fluxer_';
|
||||
|
||||
function shouldRetryAppLoadFailure(errorCode: number): boolean {
|
||||
return errorCode < 0 && errorCode !== -3;
|
||||
}
|
||||
|
||||
function getElectronLoadErrorCode(error: unknown): number | null {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
const match = /\(([-\d]+)\)/.exec(message);
|
||||
if (!match) return null;
|
||||
const value = Number.parseInt(match[1], 10);
|
||||
return Number.isFinite(value) ? value : null;
|
||||
}
|
||||
|
||||
function getOrigin(url?: string): string | null {
|
||||
if (!url) return null;
|
||||
try {
|
||||
@@ -733,7 +721,6 @@ export function createWindow(options: CreateWindowOptions = {}): BrowserWindow {
|
||||
const acceptFirstMouseOnFocus = isMac;
|
||||
initialUseNativeTitleBar = useNativeTitleBar;
|
||||
initialAllowTransparency = allowTransparency;
|
||||
let appUrl = getAppUrl();
|
||||
const windowOptions: Electron.BrowserWindowConstructorOptions = {
|
||||
width: windowWidth,
|
||||
height: windowHeight,
|
||||
@@ -746,7 +733,7 @@ export function createWindow(options: CreateWindowOptions = {}): BrowserWindow {
|
||||
...getTitleBarWindowOptions(useNativeTitleBar),
|
||||
trafficLightPosition: isMac ? CUSTOM_TITLEBAR_TRAFFIC_LIGHT_POSITION : undefined,
|
||||
acceptFirstMouse: acceptFirstMouseOnFocus,
|
||||
webPreferences: getSharedWebPreferences(allowTransparency, useNativeTitleBar, appUrl),
|
||||
webPreferences: getSharedWebPreferences(allowTransparency, useNativeTitleBar, getAppUrl()),
|
||||
};
|
||||
if (isLinux) {
|
||||
const iconPath = getLinuxWindowIconPath();
|
||||
@@ -976,81 +963,55 @@ export function createWindow(options: CreateWindowOptions = {}): BrowserWindow {
|
||||
});
|
||||
registerDisplayMediaRequestHandler(session, webContents);
|
||||
logPhase('handlers');
|
||||
let appLoadRetryAttempt = 0;
|
||||
let appLoadRetryTimer: NodeJS.Timeout | null = null;
|
||||
const clearAppLoadRetry = () => {
|
||||
let appLoadFailurePrompt: AbortController | null = null;
|
||||
const dismissAppLoadFailurePrompt = () => {
|
||||
appLoadFailurePrompt?.abort();
|
||||
appLoadFailurePrompt = null;
|
||||
};
|
||||
const appLoadRetry = createAppLoadRetry({
|
||||
webContents,
|
||||
appUrl: getAppUrl(),
|
||||
logger,
|
||||
isTrustedUrl: isTrustedOrigin,
|
||||
getFallbackUrl: getAppUrlFallback,
|
||||
onCommitted: dismissAppLoadFailurePrompt,
|
||||
onRepeatedFailure: (failure) => {
|
||||
const window = mainWindow;
|
||||
if (appLoadFailurePrompt || !isAliveWindow(window) || !window.isVisible()) return;
|
||||
const prompt = new AbortController();
|
||||
appLoadFailurePrompt = prompt;
|
||||
void dialog
|
||||
.showMessageBox(window, {
|
||||
type: 'warning',
|
||||
buttons: [t('desktop.appLoad.retry'), t('desktop.tray.quit')],
|
||||
defaultId: 0,
|
||||
cancelId: 0,
|
||||
title: t('desktop.appLoad.failedTitle'),
|
||||
message: t('desktop.appLoad.failedMessage'),
|
||||
detail: `${failure.errorDescription} (${failure.errorCode})\n${failure.url}`,
|
||||
noLink: true,
|
||||
signal: prompt.signal,
|
||||
})
|
||||
.then(({response}) => {
|
||||
if (appLoadFailurePrompt === prompt) appLoadFailurePrompt = null;
|
||||
if (prompt.signal.aborted || isQuitting) return;
|
||||
if (response === 1) {
|
||||
app.quit();
|
||||
return;
|
||||
}
|
||||
appLoadRetry.retryNow();
|
||||
});
|
||||
},
|
||||
});
|
||||
webContents.on('did-finish-load', () => {
|
||||
rendererGoneReloaded = false;
|
||||
mainWindowRendererGone = false;
|
||||
if (appLoadRetryTimer) {
|
||||
clearTimeout(appLoadRetryTimer);
|
||||
appLoadRetryTimer = null;
|
||||
}
|
||||
appLoadRetryAttempt = 0;
|
||||
};
|
||||
const scheduleAppLoadRetry = (reason: string, detail?: Record<string, unknown>) => {
|
||||
if (!mainWindow || mainWindow.isDestroyed()) return;
|
||||
if (appLoadRetryTimer) return;
|
||||
const delay = Math.min(INITIAL_APP_LOAD_RETRY_DELAY_MS * 2 ** appLoadRetryAttempt, MAX_APP_LOAD_RETRY_DELAY_MS);
|
||||
appLoadRetryAttempt += 1;
|
||||
logger.warn('Scheduling app load retry', {reason, delay, attempt: appLoadRetryAttempt, ...detail});
|
||||
appLoadRetryTimer = setTimeout(() => {
|
||||
appLoadRetryTimer = null;
|
||||
if (!mainWindow || mainWindow.isDestroyed()) return;
|
||||
if (mainWindow.webContents.isLoadingMainFrame()) {
|
||||
scheduleAppLoadRetry('main-frame-still-loading');
|
||||
return;
|
||||
}
|
||||
mainWindow.loadURL(appUrl).catch((error) => {
|
||||
scheduleAppLoadRetry('load-url-rejected', {error});
|
||||
});
|
||||
}, delay);
|
||||
};
|
||||
const fallBackFromMigratedAppOrigin = (failedUrl: string, detail: Record<string, unknown>): boolean => {
|
||||
const fallbackUrl = getAppUrlFallback(failedUrl);
|
||||
if (!mainWindow || mainWindow.isDestroyed() || fallbackUrl === null || appUrl === fallbackUrl) {
|
||||
return false;
|
||||
}
|
||||
logger.warn('Migrated app origin failed to load, falling back to the legacy app URL', {failedUrl, ...detail});
|
||||
appUrl = fallbackUrl;
|
||||
clearAppLoadRetry();
|
||||
mainWindow.loadURL(appUrl).catch((error) => {
|
||||
scheduleAppLoadRetry('legacy-fallback-load-url-rejected', {error});
|
||||
});
|
||||
return true;
|
||||
};
|
||||
webContents.on('did-finish-load', clearAppLoadRetry);
|
||||
webContents.on('did-navigate', (_event, url, httpResponseCode) => {
|
||||
if (httpResponseCode >= 400) {
|
||||
fallBackFromMigratedAppOrigin(url, {httpResponseCode});
|
||||
}
|
||||
});
|
||||
webContents.on('did-fail-load', (_event, errorCode, errorDescription, validatedURL, isMainFrame) => {
|
||||
if (isMainFrame) {
|
||||
logger.error('App main-frame load failed', {errorCode, errorDescription, validatedURL});
|
||||
}
|
||||
if (!isMainFrame || !isTrustedOrigin(validatedURL) || !shouldRetryAppLoadFailure(errorCode)) {
|
||||
return;
|
||||
}
|
||||
if (fallBackFromMigratedAppOrigin(validatedURL, {errorCode, errorDescription})) {
|
||||
return;
|
||||
}
|
||||
scheduleAppLoadRetry('did-fail-load', {errorCode, errorDescription, validatedURL});
|
||||
});
|
||||
const loadAppUrl = (): void => {
|
||||
if (!mainWindow || mainWindow.isDestroyed()) return;
|
||||
logger.info('Loading app URL', {appUrl});
|
||||
mainWindow.loadURL(appUrl).catch((error) => {
|
||||
const errorCode = getElectronLoadErrorCode(error);
|
||||
if (errorCode !== null && !shouldRetryAppLoadFailure(errorCode)) {
|
||||
logger.info('Ignoring non-retryable initial app load rejection', {errorCode});
|
||||
return;
|
||||
}
|
||||
logger.error('Failed to load app URL:', error);
|
||||
scheduleAppLoadRetry('initial-load-url-rejected', {error});
|
||||
});
|
||||
void clearStartupRenderingCaches(session).then(() => {
|
||||
if (!isAliveWindow(mainWindow)) return;
|
||||
appLoadRetry.start();
|
||||
logPhase('load-url-dispatched');
|
||||
};
|
||||
void clearStartupRenderingCaches(session).then(loadAppUrl);
|
||||
});
|
||||
webContents.on('will-navigate', (event, url) => {
|
||||
if (!isTrustedOrigin(url)) {
|
||||
event.preventDefault();
|
||||
@@ -1095,7 +1056,11 @@ export function createWindow(options: CreateWindowOptions = {}): BrowserWindow {
|
||||
transparent: allowPopoutTransparency,
|
||||
hasShadow: getWindowHasShadow(allowPopoutTransparency),
|
||||
show: true,
|
||||
webPreferences: getSharedWebPreferences(allowPopoutTransparency, getActiveUseNativeTitleBar(), appUrl),
|
||||
webPreferences: getSharedWebPreferences(
|
||||
allowPopoutTransparency,
|
||||
getActiveUseNativeTitleBar(),
|
||||
appLoadRetry.getAppUrl(),
|
||||
),
|
||||
};
|
||||
return {action: 'allow', overrideBrowserWindowOptions};
|
||||
}
|
||||
|
||||
@@ -302,6 +302,11 @@ if (launchConfigurationError) {
|
||||
.then(() => app.whenReady())
|
||||
.then(async () => {
|
||||
log.info('App ready, initializing...');
|
||||
try {
|
||||
runStartupPhase('host-resolver', () => app.configureHostResolver({enableAdditionalDnsQueryTypes: false}));
|
||||
} catch (error) {
|
||||
log.error('[Init] Failed to configure the host resolver:', error);
|
||||
}
|
||||
await runStartupPhaseAsync('launch-net-log', startLaunchNetLog);
|
||||
try {
|
||||
await runStartupPhaseAsync('desktop-debug-info', async () => {
|
||||
|
||||
@@ -271,7 +271,7 @@ const ACCEPTED_TABLE_FINDINGS = new Map<string, Readonly<Partial<Record<TableRul
|
||||
['admin-api/discovery.mdx', {'table-identifier': 1}],
|
||||
['admin-api/guilds.mdx', {'table-identifier': 3}],
|
||||
['admin-api/index.mdx', {'table-fit': 1, 'table-identifier': 3}],
|
||||
['admin-api/instance.mdx', {'table-fit': 1, 'table-identifier': 6}],
|
||||
['admin-api/instance.mdx', {'table-fit': 1, 'table-identifier': 7}],
|
||||
['admin-api/messages.mdx', {'table-identifier': 1}],
|
||||
['admin-api/reports.mdx', {'table-fit': 1, 'table-identifier': 2}],
|
||||
['admin-api/users.mdx', {'table-fit': 1, 'table-identifier': 1}],
|
||||
|
||||
@@ -37,6 +37,7 @@ Missing settings use the defaults documented below. Invalid stored configuration
|
||||
| voice_noise_suppression | [voice noise suppression configuration](#voice-noise-suppression-configuration-object) object | Client-side noise suppression rollout |
|
||||
| push_service_delivery | [push service delivery configuration](#push-service-delivery-configuration-object) object | Push service delivery rollout |
|
||||
| domain_migration | [domain migration configuration](#domain-migration-configuration-object) object | Web domain migration rollout |
|
||||
| altcha_captcha | [ALTCHA captcha configuration](#altcha-captcha-configuration-object) object | ALTCHA proof-of-work captcha rollout |
|
||||
| experiment_delivery | [experiment delivery configuration](#experiment-delivery-configuration-object) object | Cadence every client polls the experiments route on |
|
||||
| registration | [registration configuration](#registration-configuration-object) object | Registration policy, issued URLs, and pending registrations |
|
||||
| self_hosted | boolean | Whether the deployment runs in self-hosted mode |
|
||||
@@ -136,9 +137,14 @@ The instance rollout of push service delivery.
|
||||
| rollout_salt | string | Salt of the sampling hash (1-64 printable ASCII characters, default `push-service-delivery-v1`) |
|
||||
| included_user_ids | array[snowflake] | Accounts the rollout always selects, up to 1000 entries (default empty) |
|
||||
| excluded_user_ids | array[snowflake] | Accounts the rollout never selects, up to 1000 entries (default empty) |
|
||||
| relay_consent_accepted | boolean | Whether the operator accepted the push relay supplemental privacy notice (default false) |
|
||||
| relay_consent_accepted_at | ?string | ISO 8601 timestamp of that acceptance, or null when the notice stands unaccepted (default null) |
|
||||
| relay_consent_accepted_by | ?snowflake | Admin account that accepted the notice, or null when the notice stands unaccepted (default null) |
|
||||
|
||||
Every field is present on read. An absent document or missing field uses the defaults above.
|
||||
|
||||
The notice covers only the Fluxer-run relay that reaches Apple and Google for the official mobile apps. A subscription whose endpoint points at another distributor, such as a self-hosted UnifiedPush server or ntfy, never reaches that relay and needs no acceptance.
|
||||
|
||||
## Domain migration configuration object
|
||||
|
||||
The instance rollout that moves the official web client from its legacy origin to a new one. [Experiments](/http-api/experiments/#domain-migration-assignment-object) defines what a signed-in client resolves from it. The [instance discovery document](/http-api/instance/#domain-migration-object) publishes `enabled`, `anonymous_rollout_basis_points`, `rollout_salt`, and `standalone_forwarding` for clients that are not signed in.
|
||||
@@ -168,6 +174,32 @@ Only the official web client acts on this configuration. On any other instance i
|
||||
Setting `enabled` to false stops new migrations and also stops forwarding from the legacy origin for clients that already moved. Excluding an account only stops a migration that has not started yet.
|
||||
:::
|
||||
|
||||
## ALTCHA captcha configuration object
|
||||
|
||||
The instance rollout that replaces the configured captcha provider with an ALTCHA proof-of-work challenge the API issues and verifies itself. [Experiments](/http-api/experiments/#altcha-captcha-assignment-object) defines what a signed-in client resolves from it, and [CAPTCHA handling](/topics/captcha/#altcha-proof-of-work) defines the challenge exchange.
|
||||
|
||||
### Structure
|
||||
|
||||
| Field | Type | Description |
|
||||
| --- | --- | --- |
|
||||
| enabled | boolean | Whether the rollout runs at all (default false) |
|
||||
| config_version | integer | Revision counter, raised by Fluxer and never accepted from a request |
|
||||
| rollout_basis_points | integer | Share of accounts the rollout selects, in basis points (0-10000, default 0) |
|
||||
| rollout_salt | string | Salt of the sampling hash (1-64 printable ASCII characters, default `altcha-captcha-v1`) |
|
||||
| included_user_ids | array[snowflake] | Accounts the rollout always selects, up to 1000 entries (default empty) |
|
||||
| excluded_user_ids | array[snowflake] | Accounts the rollout never selects, up to 1000 entries (default empty) |
|
||||
| anonymous_enabled | boolean | Whether logged-out requests get ALTCHA (default false) |
|
||||
| cost | integer | PBKDF2 iterations per solving attempt (1000-100000, default 5000) |
|
||||
| max_counter | integer | Upper bound of the hidden counter a client searches for (100-1000000, default 10000) |
|
||||
|
||||
Every field is present on read. An absent document or missing field uses the defaults above.
|
||||
|
||||
`excluded_user_ids` is applied before `included_user_ids`, so the rollout never selects an account in both. `anonymous_enabled` has no effect on signed-in requests, and the account rules have no effect on logged-out ones.
|
||||
|
||||
Each challenge hides its counter between half of `max_counter` and `max_counter`, and a client tries counters from 0 upward. Solve time grows with `cost` times `max_counter`. Fluxer spends one attempt at `cost` to issue each challenge.
|
||||
|
||||
The rollout only changes which provider answers a captcha that is already required. While the instance captcha provider is `none`, it has no effect.
|
||||
|
||||
## Experiment delivery configuration object
|
||||
|
||||
How often a client polls [Get experiment assignments](/http-api/experiments/#get-experiment-assignments), and how widely those polls are spread. The setting is instance-wide and applies to every experiment at once, so adding an experiment adds no second cadence to tune.
|
||||
@@ -578,6 +610,7 @@ The body has one optional object for each section. Fluxer leaves an absent secti
|
||||
| voice_noise_suppression? | object | Any subset of the [noise suppression](#voice-noise-suppression-configuration-object) fields |
|
||||
| push_service_delivery? | object | Any subset of the [push service delivery](#push-service-delivery-configuration-object) fields |
|
||||
| domain_migration? | object | Any subset of the [domain migration](#domain-migration-configuration-object) fields |
|
||||
| altcha_captcha? | object | Any subset of the [ALTCHA captcha](#altcha-captcha-configuration-object) fields |
|
||||
| experiment_delivery? | object | Any subset of the [experiment delivery](#experiment-delivery-configuration-object) fields |
|
||||
| registration? | object | `mode` and `admin_registration_urls_enabled` |
|
||||
| app_public?<sup>2</sup> | object | `branding`, `setup`, `legal`, and `registration` sub-objects, each merged field by field |
|
||||
@@ -591,10 +624,12 @@ The body has one optional object for each section. Fluxer leaves an absent secti
|
||||
|
||||
`voice_noise_suppression` takes every [voice noise suppression configuration](#voice-noise-suppression-configuration-object) field except `config_version`, each bound as documented there. Fluxer raises `config_version` by one on each request that supplies at least one of them. A section that is absent, or present with no field set, writes nothing and leaves `config_version` alone.
|
||||
|
||||
`push_service_delivery` works the same way, over the [push service delivery configuration](#push-service-delivery-configuration-object) fields and its own `config_version`.
|
||||
`push_service_delivery` works the same way, over the [push service delivery configuration](#push-service-delivery-configuration-object) fields and its own `config_version`. It also takes `relay_consent_accepted`. Fluxer sets `relay_consent_accepted_at` and `relay_consent_accepted_by` itself on the request that changes that flag, and accepts neither from a request: turning the flag on stamps the current time and the acting Admin account, and turning it off clears both to null. A request that repeats the flag it already holds leaves the stamp alone.
|
||||
|
||||
`domain_migration` works the same way, over the [domain migration configuration](#domain-migration-configuration-object) fields and its own `config_version`.
|
||||
|
||||
`altcha_captcha` works the same way, over the [ALTCHA captcha configuration](#altcha-captcha-configuration-object) fields and its own `config_version`.
|
||||
|
||||
`experiment_delivery` takes both [experiment delivery configuration](#experiment-delivery-configuration-object) fields, each bound as documented there. It is a section of its own, so a write to it changes no `config_version` and changes no assignment, only the cadence on which clients ask for one.
|
||||
|
||||
<sup>3</sup> A secret such as `klipy_api_key`, `api_key`, `hcaptcha_secret_key`, `turnstile_secret_key`, or the SMTP `password` is written when supplied and left alone when absent. `integrations.bluesky.keys` is the only way to write the Bluesky signing keys counted as `bluesky.key_count`. It takes up to 8 entries of `kid` (1-255 characters) and nullable `private_key` (up to 10000 characters), and replaces the stored key set outright
|
||||
@@ -631,7 +666,7 @@ Fluxer skips URL validation while the merged configuration leaves single sign-on
|
||||
| 400 | [error response](/admin-api/#error-response) | A policy transition is refused, returned as `INSTANCE_POLICY_TRANSITION_NOT_ALLOWED` |
|
||||
|
||||
:::caution[Sections are applied one after another]
|
||||
The order is `gateway_rollout`, `voice_noise_suppression`, `push_service_delivery`, `domain_migration`, `experiment_delivery`, `sso`, `registration`, `app_public` branding, legal, and registration fields, `integrations`, `media`, `policy`, and finally `app_public.setup`. A failure part way through leaves the earlier sections written.
|
||||
The order is `gateway_rollout`, `voice_noise_suppression`, `push_service_delivery`, `domain_migration`, `altcha_captcha`, `experiment_delivery`, `sso`, `registration`, `app_public` branding, legal, and registration fields, `integrations`, `media`, `policy`, and finally `app_public.setup`. A failure part way through leaves the earlier sections written.
|
||||
:::
|
||||
|
||||
### Side effects
|
||||
|
||||
@@ -6,7 +6,7 @@ description: The experiment assignments envelope, the revalidation and polling c
|
||||
|
||||
import RouteHeader from '@/components/RouteHeader.astro';
|
||||
|
||||
An experiment is an instance-wide rollout that an operator configures. For each account, Fluxer works out from that configuration whether the account is in the rollout and which settings the account receives. The single route on this page resolves every experiment the server defines and returns them in one envelope, together with the polling cadence they share. This server defines `voice_noise_suppression`, whose placement protocol [Voice](/voice/) defines, and `domain_migration`.
|
||||
An experiment is an instance-wide rollout that an operator configures. For each account, Fluxer works out from that configuration whether the account is in the rollout and which settings the account receives. The single route on this page resolves every experiment the server defines and returns them in one envelope, together with the polling cadence they share. This server defines `voice_noise_suppression`, whose placement protocol [Voice](/voice/) defines, `domain_migration` and `altcha_captcha`.
|
||||
|
||||
Every assignment is advice. A client that ignores one behaves as it does with the rollout off, and no route and no Gateway event reports what a client actually ran.
|
||||
|
||||
@@ -34,6 +34,7 @@ One entry per experiment. The envelope reports this object even when it is empty
|
||||
| --- | --- | --- |
|
||||
| voice_noise_suppression? | [noise suppression assignment](#noise-suppression-assignment-object) object | The caller's noise suppression assignment |
|
||||
| domain_migration? | [domain migration assignment](#domain-migration-assignment-object) object | The caller's web domain migration assignment |
|
||||
| altcha_captcha? | [ALTCHA captcha assignment](#altcha-captcha-assignment-object) object | The caller's captcha provider assignment |
|
||||
|
||||
Ignore unknown experiments and treat a missing experiment as off.
|
||||
|
||||
@@ -116,6 +117,20 @@ A caller is drawn either by the operator's allowlist or by the sampled share of
|
||||
|
||||
Only the official web client acts on this assignment, and only on its legacy origins. Every other client ignores it. The logged-out share and the instance-wide switch are published in the [instance discovery document](/http-api/instance/#domain-migration-object) instead, because a client that holds no credential cannot read this route.
|
||||
|
||||
## ALTCHA captcha assignment object
|
||||
|
||||
One resolution of the instance ALTCHA captcha rollout against one account. This server version writes the key on every response.
|
||||
|
||||
### Structure
|
||||
|
||||
| Field | Type | Description |
|
||||
| --- | --- | --- |
|
||||
| enabled | boolean | Whether the caller's captcha challenges are ALTCHA proof-of-work challenges |
|
||||
|
||||
A caller is drawn either by the operator's allowlist or by the sampled share of the account population. `enabled` is false in every other case, the rollout being off included.
|
||||
|
||||
The assignment is informational. The server applies the same resolution to every request that needs a captcha and names the provider in the [captcha error](/topics/captcha/#altcha-proof-of-work), so a client needs no copy of this value to answer a challenge.
|
||||
|
||||
## Get experiment assignments
|
||||
|
||||
<RouteHeader method="GET" path="/v1/experiments" bot />
|
||||
|
||||
@@ -323,7 +323,7 @@ No default. Legacy unprefixed aliases. Accepted only by `admin` and `app-proxy`,
|
||||
|
||||
#### `FLUXER_API_IP_BAN_EXEMPT_IPS`
|
||||
|
||||
Default empty. Addresses exempt from IP bans. Comma separated. Every entry must parse as an IP or the API fails at boot.
|
||||
Default empty. Addresses and CIDR ranges exempt from IP bans. Comma separated. A bare IPv4 address exempts that address, a bare IPv6 address exempts its /64, and a CIDR range such as `2001:db8:1200::/56` exempts every address in it. Every entry must parse as an IP or a CIDR range or the API fails at boot.
|
||||
|
||||
The API returns 403 for any request whose client-IP header is missing, empty, or not a parsable address, and for every request while `FLUXER_TRUST_CLIENT_IP_HEADER` is `false`. The exceptions are `/_health`, `/webhooks/livekit`, `/test`, and the Bluesky client metadata and JWKS routes. The edge sets the header on every upstream hop, so a missing or unparsable header happens only in a layout that puts something other than the edge directly in front of `api`. A proxy in front of the edge that never sets the header passes the check, and every request then looks as though it came from the proxy.
|
||||
|
||||
|
||||
@@ -37,11 +37,11 @@ Fluxer skips the check in three cases, and the operation then proceeds with no C
|
||||
| Field | Type | Description |
|
||||
| --- | --- | --- |
|
||||
| X-Captcha-Token?<sup>1</sup> | string | The solution issued by the provider widget |
|
||||
| X-Captcha-Type?<sup>2</sup> | string | The provider that produced the solution, accepting `hcaptcha` or `turnstile` |
|
||||
| X-Captcha-Type?<sup>2</sup> | string | The provider that produced the solution, accepting `hcaptcha`, `turnstile` or `altcha` |
|
||||
|
||||
<sup>1</sup> An absent or empty value on a gated operation returns 400 `CAPTCHA_REQUIRED`
|
||||
|
||||
<sup>2</sup> An absent value selects the instance's configured provider, and so does any value other than `hcaptcha` or `turnstile`. Naming a provider the instance holds no secret key for returns 400 `INVALID_CAPTCHA`.
|
||||
<sup>2</sup> An absent value selects the instance's configured provider, and so does any value other than `hcaptcha`, `turnstile` or `altcha`. Naming a provider the instance holds no secret key for returns 400 `INVALID_CAPTCHA`. The value `altcha` is accepted only from a requester the [ALTCHA rollout](#altcha-proof-of-work) selects.
|
||||
|
||||
## The retry handshake
|
||||
|
||||
@@ -53,6 +53,21 @@ An accepted solution allows the operation to proceed. A rejected solution return
|
||||
The provider treats an already redeemed solution as invalid. A client obtains a new solution before retrying after `INVALID_CAPTCHA` and MUST NOT replay the previous `X-Captcha-Token` value.
|
||||
:::
|
||||
|
||||
## ALTCHA proof-of-work
|
||||
|
||||
An operator can move selected requesters from the configured provider to an [ALTCHA](https://altcha.org) proof-of-work challenge that the API issues and verifies itself. The [ALTCHA captcha configuration](/admin-api/instance/#altcha-captcha-configuration-object) selects signed-in accounts by rollout share and allowlist, and logged-out requests with one switch. The check applies only where a captcha is already required, so an instance whose `provider` is `none` never serves it.
|
||||
|
||||
For a selected requester, the `CAPTCHA_REQUIRED` and `INVALID_CAPTCHA` bodies have two more fields.
|
||||
|
||||
| Field | Type | Description |
|
||||
| --- | --- | --- |
|
||||
| captcha_provider | string | Always `altcha` |
|
||||
| altcha_challenge | object | An ALTCHA v2 challenge, with `parameters` and `signature` |
|
||||
|
||||
The challenge uses `PBKDF2/SHA-256` and expires 10 minutes after it is issued. Solve it with an ALTCHA v2 solver, then retry with `X-Captcha-Type` set to `altcha` and `X-Captcha-Token` set to the base64 encoding of the JSON object `{"challenge": <the challenge>, "solution": <the solution>}`. Each challenge is accepted once. A replayed, expired or wrong solution returns 400 `INVALID_CAPTCHA` with a new challenge.
|
||||
|
||||
A selected requester can still answer with the configured provider, so a client that does not read these fields keeps working.
|
||||
|
||||
## Provider verification
|
||||
|
||||
A rejected solution or unavailable provider returns 400 `INVALID_CAPTCHA`. The response does not distinguish between these causes.
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
-type gateway_role() :: websocket | sessions | presence | guilds | calls | push | all.
|
||||
|
||||
-define(MAX_CLUSTER_STATIC_PEERS, 256).
|
||||
-define(DEFAULT_MANAGED_RELAY_HOSTS, <<"push.fluxer.com">>).
|
||||
|
||||
-spec load() -> config().
|
||||
load() ->
|
||||
@@ -87,6 +88,12 @@ env_gateway_base_config() ->
|
||||
<<"push_endpoint_guard_enabled">> => env_bool(
|
||||
"FLUXER_GATEWAY_PUSH_ENDPOINT_GUARD_ENABLED", true
|
||||
),
|
||||
<<"push_managed_relay_hosts">> => env_binary(
|
||||
"FLUXER_GATEWAY_PUSH_MANAGED_RELAY_HOSTS", ?DEFAULT_MANAGED_RELAY_HOSTS
|
||||
),
|
||||
<<"push_relay_consent_accepted">> => env_bool(
|
||||
"FLUXER_GATEWAY_PUSH_RELAY_CONSENT_ACCEPTED", false
|
||||
),
|
||||
<<"push_outbox_request_timeout_ms">> => env_int(
|
||||
"FLUXER_GATEWAY_PUSH_OUTBOX_REQUEST_TIMEOUT_MS", 100000
|
||||
),
|
||||
@@ -268,6 +275,12 @@ build_push_config(Service, Public) ->
|
||||
push_endpoint_guard_enabled => get_bool(
|
||||
Service, <<"push_endpoint_guard_enabled">>, true
|
||||
),
|
||||
push_managed_relay_hosts => parse_host_list(
|
||||
get_binary(Service, <<"push_managed_relay_hosts">>, ?DEFAULT_MANAGED_RELAY_HOSTS)
|
||||
),
|
||||
push_relay_consent_accepted => get_bool(
|
||||
Service, <<"push_relay_consent_accepted">>, false
|
||||
),
|
||||
push_outbox_max_queue => get_int(Service, <<"push_outbox_max_queue">>, 10000),
|
||||
push_outbox_max_inflight => get_int(Service, <<"push_outbox_max_inflight">>, 64),
|
||||
push_outbox_request_timeout_ms => get_int(
|
||||
@@ -590,6 +603,21 @@ to_binary(Str, _) when is_list(Str) -> list_to_binary(config_char_list(Str));
|
||||
to_binary(Atom, _) when is_atom(Atom) -> list_to_binary(atom_to_list(Atom));
|
||||
to_binary(_, Default) -> Default.
|
||||
|
||||
-spec parse_host_list(binary()) -> [binary()].
|
||||
parse_host_list(Bin) ->
|
||||
[
|
||||
lower_ascii(Host)
|
||||
|| Host <- binary:split(Bin, [<<",">>, <<" ">>, <<"\t">>], [global, trim_all])
|
||||
].
|
||||
|
||||
-spec lower_ascii(binary()) -> binary().
|
||||
lower_ascii(Bin) ->
|
||||
<<<<(lower_byte(Byte))>> || <<Byte>> <= Bin>>.
|
||||
|
||||
-spec lower_byte(byte()) -> byte().
|
||||
lower_byte(Byte) when Byte >= $A, Byte =< $Z -> Byte + 32;
|
||||
lower_byte(Byte) -> Byte.
|
||||
|
||||
-spec parse_node_list(binary() | undefined) -> [node()].
|
||||
parse_node_list(undefined) ->
|
||||
[];
|
||||
|
||||
@@ -37,7 +37,8 @@
|
||||
rollout_basis_points := non_neg_integer(),
|
||||
rollout_salt := binary(),
|
||||
included := user_id_set(),
|
||||
excluded := user_id_set()
|
||||
excluded := user_id_set(),
|
||||
relay_consent_accepted := boolean()
|
||||
}.
|
||||
-type state() :: #{
|
||||
nats_subscription := term(),
|
||||
@@ -170,7 +171,8 @@ default_config() ->
|
||||
rollout_basis_points => 0,
|
||||
rollout_salt => ?DEFAULT_SALT,
|
||||
included => #{},
|
||||
excluded => #{}
|
||||
excluded => #{},
|
||||
relay_consent_accepted => false
|
||||
}.
|
||||
|
||||
-spec fetch_config_from_api() -> store_result().
|
||||
@@ -254,7 +256,8 @@ config_fields() ->
|
||||
{rollout_basis_points, <<"rollout_basis_points">>, fun validate_basis_points/1},
|
||||
{rollout_salt, <<"rollout_salt">>, fun validate_salt/1},
|
||||
{included, <<"included_user_ids">>, fun validate_user_ids/1},
|
||||
{excluded, <<"excluded_user_ids">>, fun validate_user_ids/1}
|
||||
{excluded, <<"excluded_user_ids">>, fun validate_user_ids/1},
|
||||
{relay_consent_accepted, <<"relay_consent_accepted">>, fun validate_enabled/1}
|
||||
].
|
||||
|
||||
-spec validate_field(
|
||||
@@ -419,7 +422,7 @@ result_index(rejected) -> 4.
|
||||
log_config_transitions(Previous, Current) ->
|
||||
lists:foreach(
|
||||
fun(Key) -> log_key_transition(Key, Previous, Current) end,
|
||||
[enabled, rollout_basis_points, config_version]
|
||||
[enabled, rollout_basis_points, config_version, relay_consent_accepted]
|
||||
).
|
||||
|
||||
-spec log_key_transition(atom(), config(), config()) -> ok.
|
||||
@@ -435,3 +438,22 @@ log_key_transition(Key, Previous, Current) ->
|
||||
[Key, PreviousValue, CurrentValue]
|
||||
)
|
||||
end.
|
||||
|
||||
-ifdef(TEST).
|
||||
-include_lib("eunit/include/eunit.hrl").
|
||||
|
||||
an_accepted_relay_notice_is_read_off_the_wire_config_test() ->
|
||||
{ok, Config} = validate_config(#{<<"relay_consent_accepted">> => true}),
|
||||
?assertEqual(true, maps:get(relay_consent_accepted, Config)).
|
||||
|
||||
a_wire_config_without_a_relay_notice_has_not_been_accepted_test() ->
|
||||
{ok, Config} = validate_config(#{<<"enabled">> => true}),
|
||||
?assertEqual(false, maps:get(relay_consent_accepted, Config)).
|
||||
|
||||
a_relay_notice_that_is_not_a_boolean_is_refused_test() ->
|
||||
?assertMatch(
|
||||
{error, {invalid_field, <<"relay_consent_accepted">>, _}},
|
||||
validate_config(#{<<"relay_consent_accepted">> => <<"yes">>})
|
||||
).
|
||||
|
||||
-endif.
|
||||
|
||||
@@ -8,6 +8,9 @@
|
||||
-export_type([context/0]).
|
||||
|
||||
-define(MAX_GUILD_FEATURES, 64).
|
||||
-define(MESSAGE_TYPE_DEFAULT, 0).
|
||||
-define(MESSAGE_TYPE_REPLY, 19).
|
||||
-define(PUSHABLE_MESSAGE_TYPES, [?MESSAGE_TYPE_DEFAULT, ?MESSAGE_TYPE_REPLY]).
|
||||
|
||||
-type context() :: #{
|
||||
message_data := map(),
|
||||
@@ -110,7 +113,24 @@ validate(#{message_id := undefined}) ->
|
||||
validate(#{guild_default_notifications := undefined}) ->
|
||||
{error, invalid_guild_default_notifications};
|
||||
validate(Context) ->
|
||||
{ok, Context}.
|
||||
validate_message_type(message_type(Context), Context).
|
||||
|
||||
-spec validate_message_type(integer(), context()) -> {ok, context()} | {error, term()}.
|
||||
validate_message_type(Type, Context) ->
|
||||
case lists:member(Type, ?PUSHABLE_MESSAGE_TYPES) of
|
||||
true -> {ok, Context};
|
||||
false -> {error, {unpushable_message_type, Type}}
|
||||
end.
|
||||
|
||||
-spec message_type(context()) -> integer().
|
||||
message_type(#{message_data := MessageData}) ->
|
||||
normalize_message_type(maps:get(<<"type">>, MessageData, ?MESSAGE_TYPE_DEFAULT)).
|
||||
|
||||
-spec normalize_message_type(term()) -> integer().
|
||||
normalize_message_type(Type) when is_integer(Type) ->
|
||||
Type;
|
||||
normalize_message_type(_Type) ->
|
||||
?MESSAGE_TYPE_DEFAULT.
|
||||
|
||||
-spec owner_fallback_key(map()) -> term().
|
||||
owner_fallback_key(Params) ->
|
||||
@@ -163,3 +183,51 @@ markdown_context(MessageData, GuildId, RoleNames, _RawContext) when
|
||||
push_notification_format:build_markdown_context(MessageData, GuildId, RoleNames, #{});
|
||||
markdown_context(_MessageData, _GuildId, _RoleNames, RawContext) ->
|
||||
optional_map(RawContext).
|
||||
|
||||
-ifdef(TEST).
|
||||
-include_lib("eunit/include/eunit.hrl").
|
||||
|
||||
params_with_message_type(Type) ->
|
||||
#{
|
||||
message_data => #{
|
||||
<<"channel_id">> => <<"1472201127385612376">>,
|
||||
<<"id">> => <<"1472201127385612377">>,
|
||||
<<"type">> => Type
|
||||
},
|
||||
user_ids => [<<"1474262819227156566">>],
|
||||
guild_id => <<"1472200708085309475">>,
|
||||
author_id => <<"1472583967301656587">>,
|
||||
guild_default_notifications => 0
|
||||
}.
|
||||
|
||||
context_allows_a_default_message_test() ->
|
||||
?assertMatch({ok, _}, context(params_with_message_type(?MESSAGE_TYPE_DEFAULT))).
|
||||
|
||||
context_allows_a_reply_test() ->
|
||||
?assertMatch({ok, _}, context(params_with_message_type(?MESSAGE_TYPE_REPLY))).
|
||||
|
||||
context_rejects_a_call_system_message_test() ->
|
||||
?assertEqual(
|
||||
{error, {unpushable_message_type, 3}}, context(params_with_message_type(3))
|
||||
).
|
||||
|
||||
context_rejects_every_non_authored_message_type_test() ->
|
||||
lists:foreach(
|
||||
fun(Type) ->
|
||||
?assertEqual(
|
||||
{error, {unpushable_message_type, Type}},
|
||||
context(params_with_message_type(Type))
|
||||
)
|
||||
end,
|
||||
[1, 2, 3, 4, 5, 6, 7, 99]
|
||||
).
|
||||
|
||||
context_treats_a_missing_message_type_as_pushable_test() ->
|
||||
Params = params_with_message_type(?MESSAGE_TYPE_DEFAULT),
|
||||
MessageData = maps:remove(<<"type">>, maps:get(message_data, Params)),
|
||||
?assertMatch({ok, _}, context(Params#{message_data := MessageData})).
|
||||
|
||||
context_treats_a_malformed_message_type_as_pushable_test() ->
|
||||
?assertMatch({ok, _}, context(params_with_message_type(<<"nonsense">>))).
|
||||
|
||||
-endif.
|
||||
|
||||
@@ -33,7 +33,7 @@ build_content_preview(MessageData, MarkdownContext) ->
|
||||
Preview = push_markdown_plaintext:render_push_preview(Content, MarkdownContext),
|
||||
case Preview of
|
||||
<<>> ->
|
||||
truncate_preview(build_content_fallback_preview(MessageData));
|
||||
truncate_preview(build_content_fallback_preview(MessageData, MarkdownContext));
|
||||
_ ->
|
||||
truncate_preview(Preview)
|
||||
end.
|
||||
@@ -372,13 +372,37 @@ valid_utf8_prefix(Content) ->
|
||||
{error, Valid, _Rest} -> Valid
|
||||
end.
|
||||
|
||||
-spec build_content_fallback_preview(map()) -> binary().
|
||||
build_content_fallback_preview(MessageData) ->
|
||||
-spec build_content_fallback_preview(map(), map()) -> binary().
|
||||
build_content_fallback_preview(MessageData, MarkdownContext) ->
|
||||
case
|
||||
first_nonempty_binary([
|
||||
build_sticker_preview(maps:get(<<"stickers">>, MessageData, [])),
|
||||
build_attachment_preview(maps:get(<<"attachments">>, MessageData, [])),
|
||||
build_embed_preview(maps:get(<<"embeds">>, MessageData, []))
|
||||
build_embed_preview(maps:get(<<"embeds">>, MessageData, [])),
|
||||
build_snapshot_preview(
|
||||
maps:get(<<"message_snapshots">>, MessageData, []), MarkdownContext
|
||||
)
|
||||
])
|
||||
of
|
||||
Preview when is_binary(Preview) -> Preview;
|
||||
undefined -> <<>>
|
||||
end.
|
||||
|
||||
-spec build_snapshot_preview(term(), map()) -> binary().
|
||||
build_snapshot_preview([Snapshot | _Rest], MarkdownContext) when is_map(Snapshot) ->
|
||||
snapshot_preview(Snapshot, MarkdownContext);
|
||||
build_snapshot_preview(_Snapshots, _MarkdownContext) ->
|
||||
<<>>.
|
||||
|
||||
-spec snapshot_preview(map(), map()) -> binary().
|
||||
snapshot_preview(Snapshot, MarkdownContext) ->
|
||||
Content = push_utils:normalize_binary(maps:get(<<"content">>, Snapshot, <<>>), <<>>),
|
||||
case
|
||||
first_nonempty_binary([
|
||||
push_markdown_plaintext:render_push_preview(Content, MarkdownContext),
|
||||
build_sticker_preview(maps:get(<<"stickers">>, Snapshot, [])),
|
||||
build_attachment_preview(maps:get(<<"attachments">>, Snapshot, [])),
|
||||
build_embed_preview(maps:get(<<"embeds">>, Snapshot, []))
|
||||
])
|
||||
of
|
||||
Preview when is_binary(Preview) -> Preview;
|
||||
@@ -639,6 +663,48 @@ truncate_preview_keeps_short_valid_content_identical_test() ->
|
||||
Content = <<"hello \xC3\xA9 world">>,
|
||||
?assertEqual(Content, truncate_preview(Content)).
|
||||
|
||||
a_forwarded_message_previews_its_snapshot_content_test() ->
|
||||
MessageData = #{
|
||||
<<"content">> => <<>>,
|
||||
<<"message_snapshots">> => [#{<<"content">> => <<"forwarded text">>}]
|
||||
},
|
||||
?assertEqual(<<"forwarded text">>, build_content_preview(MessageData)).
|
||||
|
||||
a_forwarded_attachment_previews_its_filename_test() ->
|
||||
MessageData = #{
|
||||
<<"content">> => <<>>,
|
||||
<<"message_snapshots">> => [
|
||||
#{<<"content">> => null, <<"attachments">> => [#{<<"filename">> => <<"cat.png">>}]}
|
||||
]
|
||||
},
|
||||
?assertEqual(<<"Attachment: cat.png">>, build_content_preview(MessageData)).
|
||||
|
||||
a_forwarded_sticker_previews_its_name_test() ->
|
||||
MessageData = #{
|
||||
<<"content">> => <<>>,
|
||||
<<"message_snapshots">> => [
|
||||
#{<<"content">> => null, <<"stickers">> => [#{<<"name">> => <<"Wave">>}]}
|
||||
]
|
||||
},
|
||||
?assertEqual(<<"Sticker: Wave">>, build_content_preview(MessageData)).
|
||||
|
||||
own_content_wins_over_a_snapshot_test() ->
|
||||
MessageData = #{
|
||||
<<"content">> => <<"my words">>,
|
||||
<<"message_snapshots">> => [#{<<"content">> => <<"forwarded text">>}]
|
||||
},
|
||||
?assertEqual(<<"my words">>, build_content_preview(MessageData)).
|
||||
|
||||
an_empty_snapshot_list_previews_nothing_test() ->
|
||||
?assertEqual(
|
||||
<<>>, build_content_preview(#{<<"content">> => <<>>, <<"message_snapshots">> => []})
|
||||
).
|
||||
|
||||
a_null_snapshot_field_previews_nothing_test() ->
|
||||
?assertEqual(
|
||||
<<>>, build_content_preview(#{<<"content">> => <<>>, <<"message_snapshots">> => null})
|
||||
).
|
||||
|
||||
build_url_dm_test() ->
|
||||
?assertEqual(<<"/channels/@me/456/789">>, build_url(0, 456, 789)).
|
||||
|
||||
|
||||
@@ -21,6 +21,12 @@
|
||||
-define(OVERLOAD_MAX_DELAY_MS, 4000).
|
||||
-define(VAPID_TOKEN_TTL_SECONDS, 43200).
|
||||
-define(VAPID_TOKEN_SKEW_SECONDS, 60).
|
||||
-define(DEFAULT_MANAGED_RELAY_HOSTS, ["push.fluxer.com"]).
|
||||
-define(MANAGED_RELAY_PATH_PREFIXES, [
|
||||
"/relay/v1/apns/",
|
||||
"/relay/v1/apns-voip/",
|
||||
"/relay/v1/fcm/"
|
||||
]).
|
||||
|
||||
-type push_response() :: {ok, integer(), term(), binary()} | {error, term()}.
|
||||
|
||||
@@ -40,12 +46,94 @@ send_webpush_notification(UserId, Subscription, Payload) ->
|
||||
send_to_allowed_endpoint(UserId, Endpoint, P256dhKey, AuthKey, SubscriptionId, Payload) ->
|
||||
case push_endpoint_guard:check(Endpoint) of
|
||||
ok ->
|
||||
send_with_vapid(UserId, Endpoint, P256dhKey, AuthKey, SubscriptionId, Payload);
|
||||
send_to_consented_endpoint(
|
||||
UserId, Endpoint, P256dhKey, AuthKey, SubscriptionId, Payload
|
||||
);
|
||||
{error, Reason} ->
|
||||
log_endpoint_rejected(UserId, SubscriptionId, Reason),
|
||||
false
|
||||
end.
|
||||
|
||||
-spec send_to_consented_endpoint(integer(), binary(), binary(), binary(), binary(), map()) ->
|
||||
false | {true, map()}.
|
||||
send_to_consented_endpoint(UserId, Endpoint, P256dhKey, AuthKey, SubscriptionId, Payload) ->
|
||||
case relay_consent_missing(Endpoint) of
|
||||
false ->
|
||||
send_with_vapid(UserId, Endpoint, P256dhKey, AuthKey, SubscriptionId, Payload);
|
||||
true ->
|
||||
log_endpoint_rejected(UserId, SubscriptionId, relay_consent_required),
|
||||
false
|
||||
end.
|
||||
|
||||
-spec relay_consent_missing(binary()) -> boolean().
|
||||
relay_consent_missing(Endpoint) ->
|
||||
not relay_consent_accepted() andalso is_managed_relay_endpoint(Endpoint).
|
||||
|
||||
-spec relay_consent_accepted() -> boolean().
|
||||
relay_consent_accepted() ->
|
||||
env_relay_consent_accepted() orelse instance_relay_consent_accepted().
|
||||
|
||||
-spec env_relay_consent_accepted() -> boolean().
|
||||
env_relay_consent_accepted() ->
|
||||
case fluxer_gateway_env:get(push_relay_consent_accepted) of
|
||||
Accepted when is_boolean(Accepted) -> Accepted;
|
||||
_ -> false
|
||||
end.
|
||||
|
||||
-spec instance_relay_consent_accepted() -> boolean().
|
||||
instance_relay_consent_accepted() ->
|
||||
case maps:get(relay_consent_accepted, push_delivery_config:config(), false) of
|
||||
Accepted when is_boolean(Accepted) -> Accepted;
|
||||
_ -> false
|
||||
end.
|
||||
|
||||
-spec is_managed_relay_endpoint(binary()) -> boolean().
|
||||
is_managed_relay_endpoint(Endpoint) ->
|
||||
case safe_parse_endpoint(Endpoint) of
|
||||
{ok, Parsed} ->
|
||||
Scheme = lower_string(to_string(maps:get(scheme, Parsed, ""))),
|
||||
Host = lower_string(to_string(maps:get(host, Parsed, ""))),
|
||||
Path = to_string(maps:get(path, Parsed, "")),
|
||||
Scheme =:= "https" andalso
|
||||
lists:member(Host, managed_relay_hosts()) andalso
|
||||
is_managed_relay_path(Path);
|
||||
error ->
|
||||
false
|
||||
end.
|
||||
|
||||
-spec safe_parse_endpoint(binary()) -> {ok, map()} | error.
|
||||
safe_parse_endpoint(Endpoint) ->
|
||||
try uri_string:parse(binary_to_list(Endpoint)) of
|
||||
Parsed when is_map(Parsed) -> {ok, Parsed};
|
||||
_ -> error
|
||||
catch
|
||||
_:_ -> error
|
||||
end.
|
||||
|
||||
-spec is_managed_relay_path(string()) -> boolean().
|
||||
is_managed_relay_path(Path) ->
|
||||
lists:any(fun(Prefix) -> lists:prefix(Prefix, Path) end, ?MANAGED_RELAY_PATH_PREFIXES).
|
||||
|
||||
-spec managed_relay_hosts() -> [string()].
|
||||
managed_relay_hosts() ->
|
||||
case fluxer_gateway_env:get(push_managed_relay_hosts) of
|
||||
Hosts when is_list(Hosts) -> [lower_string(to_string(Host)) || Host <- Hosts];
|
||||
_ -> ?DEFAULT_MANAGED_RELAY_HOSTS
|
||||
end.
|
||||
|
||||
-spec to_string(term()) -> string().
|
||||
to_string(Value) when is_list(Value) -> Value;
|
||||
to_string(Value) when is_binary(Value) -> binary_to_list(Value);
|
||||
to_string(_Value) -> "".
|
||||
|
||||
-spec lower_string(string()) -> string().
|
||||
lower_string(Value) ->
|
||||
[lower_char(Char) || Char <- Value].
|
||||
|
||||
-spec lower_char(char()) -> char().
|
||||
lower_char(Char) when Char >= $A, Char =< $Z -> Char + 32;
|
||||
lower_char(Char) -> Char.
|
||||
|
||||
-spec log_endpoint_rejected(integer(), binary(), term()) -> ok.
|
||||
log_endpoint_rejected(UserId, SubscriptionId, Reason) ->
|
||||
logger:debug(
|
||||
@@ -775,10 +863,100 @@ capture_web_push(Payload) ->
|
||||
vapid_env_meck(vapid_email) -> <<"[email protected]">>;
|
||||
vapid_env_meck(vapid_public_key) -> <<"public-key">>;
|
||||
vapid_env_meck(vapid_private_key) -> <<"private-key">>;
|
||||
vapid_env_meck(push_relay_consent_accepted) -> true;
|
||||
vapid_env_meck(push_managed_relay_hosts) -> [<<"push.fluxer.com">>];
|
||||
vapid_env_meck(Key) -> meck:passthrough([Key]).
|
||||
|
||||
capture_request_meck(push, post, _Endpoint, Headers, Body, _Opts) ->
|
||||
self() ! {captured_push, Headers, Body},
|
||||
{ok, 201, [], <<>>}.
|
||||
|
||||
a_managed_relay_endpoint_is_refused_without_operator_consent_test() ->
|
||||
?assertEqual(no_push_request, attempt_push(false, managed_relay_endpoint(<<"apns">>))).
|
||||
|
||||
every_managed_relay_leg_is_refused_without_operator_consent_test() ->
|
||||
lists:foreach(
|
||||
fun(Leg) ->
|
||||
?assertEqual(no_push_request, attempt_push(false, managed_relay_endpoint(Leg)))
|
||||
end,
|
||||
[<<"apns">>, <<"apns-voip">>, <<"fcm">>]
|
||||
).
|
||||
|
||||
a_managed_relay_endpoint_is_delivered_once_the_operator_consents_test() ->
|
||||
Endpoint = managed_relay_endpoint(<<"apns">>),
|
||||
?assertEqual(Endpoint, attempt_push(true, Endpoint)).
|
||||
|
||||
a_notice_accepted_in_the_instance_config_lets_the_managed_relay_send_through_test() ->
|
||||
Endpoint = managed_relay_endpoint(<<"apns">>),
|
||||
?assertEqual(Endpoint, attempt_push(false, true, Endpoint)).
|
||||
|
||||
a_unified_push_endpoint_is_delivered_whatever_the_operator_accepted_test() ->
|
||||
Endpoint = <<"https://ntfy.sh/upZzH87cT9jJCc?up=1">>,
|
||||
?assertEqual(Endpoint, attempt_push(false, Endpoint)),
|
||||
?assertEqual(Endpoint, attempt_push(true, Endpoint)).
|
||||
|
||||
a_relay_we_do_not_operate_is_delivered_without_consent_test() ->
|
||||
Endpoint = <<"https://push.example.org/relay/v1/apns/stable/production/token">>,
|
||||
?assertEqual(Endpoint, attempt_push(false, Endpoint)).
|
||||
|
||||
managed_relay_endpoint(Leg) ->
|
||||
<<"https://push.fluxer.com/relay/v1/", Leg/binary, "/stable/production/",
|
||||
(binary:copy(<<"a">>, 64))/binary>>.
|
||||
|
||||
attempt_push(EnvConsent, Endpoint) ->
|
||||
attempt_push(EnvConsent, false, Endpoint).
|
||||
|
||||
attempt_push(EnvConsent, InstanceConsent, Endpoint) ->
|
||||
{PeerPub, _PeerPriv} = crypto:generate_key(ecdh, prime256v1),
|
||||
Subscription = #{
|
||||
<<"endpoint">> => Endpoint,
|
||||
<<"p256dh_key">> => push_utils:base64url_encode(PeerPub),
|
||||
<<"auth_key">> => push_utils:base64url_encode(crypto:strong_rand_bytes(16)),
|
||||
<<"subscription_id">> => <<"sub-1">>
|
||||
},
|
||||
ok = push_ets_cache:init(),
|
||||
ok = meck:new(fluxer_gateway_env, [passthrough, no_link]),
|
||||
ok = meck:new(push_utils, [passthrough, no_link]),
|
||||
ok = meck:new(gateway_http_client, [passthrough, no_link]),
|
||||
ok = meck:new(push_endpoint_guard, [passthrough, no_link]),
|
||||
ok = meck:new(push_delivery_config, [passthrough, no_link]),
|
||||
try
|
||||
ok = meck:expect(push_endpoint_guard, check, fun(_Endpoint) -> ok end),
|
||||
ok = meck:expect(push_delivery_config, config, fun() ->
|
||||
#{relay_consent_accepted => InstanceConsent}
|
||||
end),
|
||||
ok = meck:expect(fluxer_gateway_env, get, consent_env_meck(EnvConsent)),
|
||||
ok = meck:expect(push_utils, generate_vapid_token, fun(_Claims, _Public, _Private) ->
|
||||
<<"vapid-token">>
|
||||
end),
|
||||
ok = meck:expect(gateway_http_client, request, fun requested_endpoint_meck/6),
|
||||
?assertEqual(
|
||||
false, send_webpush_notification(42, Subscription, alert_payload(<<"Hello">>))
|
||||
),
|
||||
receive
|
||||
{push_requested, Requested} -> Requested
|
||||
after 100 ->
|
||||
no_push_request
|
||||
end
|
||||
after
|
||||
meck:unload(push_delivery_config),
|
||||
meck:unload(push_endpoint_guard),
|
||||
meck:unload(gateway_http_client),
|
||||
meck:unload(push_utils),
|
||||
meck:unload(fluxer_gateway_env)
|
||||
end.
|
||||
|
||||
consent_env_meck(EnvConsent) ->
|
||||
fun
|
||||
(push_relay_consent_accepted) -> EnvConsent;
|
||||
(push_managed_relay_hosts) -> [<<"push.fluxer.com">>];
|
||||
(Key) -> vapid_env_meck(Key)
|
||||
end.
|
||||
|
||||
-spec requested_endpoint_meck(atom(), atom(), binary(), list(), binary(), term()) ->
|
||||
{ok, non_neg_integer(), list(), binary()}.
|
||||
requested_endpoint_meck(push, post, Endpoint, _Headers, _Body, _Opts) ->
|
||||
self() ! {push_requested, Endpoint},
|
||||
{ok, 201, [], <<>>}.
|
||||
|
||||
-endif.
|
||||
|
||||
@@ -29,6 +29,7 @@ const DEFAULT_FCM_BASE_URL: &str = "https://fcm.googleapis.com";
|
||||
const DEFAULT_CLIENT_IP_HEADER_NAME: &str = "x-forwarded-for";
|
||||
const APNS_PRODUCTION_BASE_URL: &str = "https://api.push.apple.com";
|
||||
const APNS_DEVELOPMENT_BASE_URL: &str = "https://api.sandbox.push.apple.com";
|
||||
const DEFAULT_MANAGED_RELAY_HOST: &str = "push.fluxer.com";
|
||||
const VOIP_TOPIC_SUFFIX: &str = ".voip";
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, clap::ValueEnum)]
|
||||
@@ -184,6 +185,8 @@ pub struct DeliveryConfig {
|
||||
pub apns: Option<ApnsConfig>,
|
||||
pub fcm: Option<FcmConfig>,
|
||||
pub own_relay_hosts: Vec<String>,
|
||||
pub managed_relay_hosts: Vec<String>,
|
||||
pub relay_consent_accepted: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
@@ -264,6 +267,12 @@ impl DeliveryConfig {
|
||||
apns: apns_config(&env)?,
|
||||
fcm: fcm_config(&env)?,
|
||||
own_relay_hosts: own_relay_hosts(&env),
|
||||
managed_relay_hosts: managed_relay_hosts(&env),
|
||||
relay_consent_accepted: parse_bool(
|
||||
"FLUXER_PUSH_SERVICE_RELAY_CONSENT_ACCEPTED",
|
||||
env.get("FLUXER_PUSH_SERVICE_RELAY_CONSENT_ACCEPTED"),
|
||||
)?
|
||||
.unwrap_or(false),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -277,6 +286,16 @@ fn own_relay_hosts(env: &Env) -> Vec<String> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn managed_relay_hosts(env: &Env) -> Vec<String> {
|
||||
let Some(raw) = env.get("FLUXER_PUSH_SERVICE_MANAGED_RELAY_HOSTS") else {
|
||||
return vec![DEFAULT_MANAGED_RELAY_HOST.to_owned()];
|
||||
};
|
||||
raw.split(',')
|
||||
.map(|host| host.trim().to_ascii_lowercase())
|
||||
.filter(|host| !host.is_empty())
|
||||
.collect()
|
||||
}
|
||||
|
||||
impl RelayConfig {
|
||||
pub fn load_from_iter<I, K, V>(vars: I) -> anyhow::Result<Self>
|
||||
where
|
||||
|
||||
@@ -91,16 +91,20 @@ pub async fn send(state: &AppState, sub: &Subscription, envelope: &Value) -> Sen
|
||||
return SendOutcome::permanent("unsupported_platform");
|
||||
};
|
||||
let started_ms = now_ms();
|
||||
let direct = in_process_hop(&sub.endpoint, &state.cfg.own_relay_hosts);
|
||||
let outcome = match (route, direct) {
|
||||
(Route::WebPush, Some(hop)) => {
|
||||
let hopped = hop.as_subscription(sub);
|
||||
state.metrics.record_own_relay_shortcut();
|
||||
apns::send(state, &hopped, envelope).await
|
||||
let outcome = if relay_consent_missing(state, &sub.endpoint) {
|
||||
SendOutcome::permanent("relay_consent_required")
|
||||
} else {
|
||||
let direct = in_process_hop(&sub.endpoint, &state.cfg.own_relay_hosts);
|
||||
match (route, direct) {
|
||||
(Route::WebPush, Some(hop)) => {
|
||||
let hopped = hop.as_subscription(sub);
|
||||
state.metrics.record_own_relay_shortcut();
|
||||
apns::send(state, &hopped, envelope).await
|
||||
}
|
||||
(Route::WebPush, None) => web_push::send(state, sub, envelope).await,
|
||||
(Route::LegacyApns, _) => apns::send(state, sub, envelope).await,
|
||||
(Route::LegacyFcm, _) => fcm::send(state, sub, envelope).await,
|
||||
}
|
||||
(Route::WebPush, None) => web_push::send(state, sub, envelope).await,
|
||||
(Route::LegacyApns, _) => apns::send(state, sub, envelope).await,
|
||||
(Route::LegacyFcm, _) => fcm::send(state, sub, envelope).await,
|
||||
};
|
||||
state.metrics.record_send(
|
||||
provider_of(platform),
|
||||
@@ -113,6 +117,19 @@ pub async fn send(state: &AppState, sub: &Subscription, envelope: &Value) -> Sen
|
||||
outcome
|
||||
}
|
||||
|
||||
fn relay_consent_missing(state: &AppState, endpoint: &str) -> bool {
|
||||
!relay_consent_accepted(state)
|
||||
&& own_relay::is_managed(endpoint, &state.cfg.managed_relay_hosts)
|
||||
}
|
||||
|
||||
fn relay_consent_accepted(state: &AppState) -> bool {
|
||||
state.cfg.relay_consent_accepted
|
||||
|| state
|
||||
.rollout
|
||||
.snapshot()
|
||||
.is_some_and(|held| held.relay_consent_accepted)
|
||||
}
|
||||
|
||||
fn in_process_hop(endpoint: &str, hosts: &[String]) -> Option<own_relay::Hop> {
|
||||
own_relay::parse(endpoint, hosts).filter(|hop| matches!(hop.leg, own_relay::Leg::Apns))
|
||||
}
|
||||
@@ -172,3 +189,129 @@ mod hop_tests {
|
||||
assert!(in_process_hop(&voip, &ours()).is_none());
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod consent_tests {
|
||||
use super::*;
|
||||
use crate::config::DeliveryConfig;
|
||||
use crate::server::AppState;
|
||||
|
||||
const TOKEN: &str = "3dbc5a5ef1a1c1666afc26f466e1b3ebaaf4c66d92dddeb0fd1b69c49641d4cd";
|
||||
const NTFY_ENDPOINT: &str = "https://ntfy.sh/upZzH87cT9jJCc?up=1";
|
||||
|
||||
fn managed_endpoint() -> String {
|
||||
format!("https://push.fluxer.com/relay/v1/apns/stable/production/{TOKEN}")
|
||||
}
|
||||
|
||||
fn state(relay_consent_accepted: bool) -> AppState {
|
||||
let cfg = DeliveryConfig::load_from_iter([
|
||||
("FLUXER_INTERNAL_API_ENDPOINT", "http://127.0.0.1:8080"),
|
||||
("FLUXER_GATEWAY_RPC_AUTH_TOKEN", "rpc-token"),
|
||||
("FLUXER_VAPID_EMAIL", "[email protected]"),
|
||||
("FLUXER_VAPID_PUBLIC_KEY", "public-key"),
|
||||
("FLUXER_VAPID_PRIVATE_KEY", "private-key"),
|
||||
(
|
||||
"FLUXER_PUSH_SERVICE_RELAY_CONSENT_ACCEPTED",
|
||||
if relay_consent_accepted {
|
||||
"true"
|
||||
} else {
|
||||
"false"
|
||||
},
|
||||
),
|
||||
])
|
||||
.expect("the delivery config loads");
|
||||
AppState::try_new(cfg).expect("the delivery state builds")
|
||||
}
|
||||
|
||||
fn subscription(endpoint: &str) -> Subscription {
|
||||
Subscription {
|
||||
subscription_id: "sub-1".to_owned(),
|
||||
endpoint: endpoint.to_owned(),
|
||||
p256dh_key: None,
|
||||
auth_key: None,
|
||||
platform: Some("web_push".to_owned()),
|
||||
app_id: None,
|
||||
provider_environment: None,
|
||||
}
|
||||
}
|
||||
|
||||
async fn outcome_of(relay_consent_accepted: bool, endpoint: &str) -> SendOutcome {
|
||||
let state = state(relay_consent_accepted);
|
||||
send(&state, &subscription(endpoint), &Value::Null).await
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_managed_relay_send_waits_for_the_operator_to_accept_the_notice() {
|
||||
assert_eq!(
|
||||
outcome_of(false, &managed_endpoint()).await,
|
||||
SendOutcome::permanent("relay_consent_required")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_refused_managed_relay_send_keeps_the_registration() {
|
||||
assert!(!outcome_of(false, &managed_endpoint()).await.deletes_token());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn an_accepted_notice_lets_the_managed_relay_send_through() {
|
||||
assert_eq!(
|
||||
outcome_of(true, &managed_endpoint()).await,
|
||||
SendOutcome::permanent("missing_keys")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_unified_push_endpoint_is_sent_whatever_the_operator_accepted() {
|
||||
assert_eq!(
|
||||
outcome_of(false, NTFY_ENDPOINT).await,
|
||||
SendOutcome::permanent("missing_keys")
|
||||
);
|
||||
assert_eq!(
|
||||
outcome_of(true, NTFY_ENDPOINT).await,
|
||||
SendOutcome::permanent("missing_keys")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_notice_accepted_in_the_instance_config_lets_the_send_through() {
|
||||
let state = state(false);
|
||||
state.rollout.update(&serde_json::json!({
|
||||
"enabled": true,
|
||||
"config_version": 1,
|
||||
"relay_consent_accepted": true,
|
||||
}));
|
||||
assert_eq!(
|
||||
send(&state, &subscription(&managed_endpoint()), &Value::Null).await,
|
||||
SendOutcome::permanent("missing_keys")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn an_instance_config_that_has_not_accepted_still_refuses_the_send() {
|
||||
let state = state(false);
|
||||
state.rollout.update(&serde_json::json!({
|
||||
"enabled": true,
|
||||
"config_version": 1,
|
||||
"relay_consent_accepted": false,
|
||||
}));
|
||||
assert_eq!(
|
||||
send(&state, &subscription(&managed_endpoint()), &Value::Null).await,
|
||||
SendOutcome::permanent("relay_consent_required")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_refused_managed_relay_send_is_still_counted() {
|
||||
let state = state(false);
|
||||
let outcome = send(&state, &subscription(&managed_endpoint()), &Value::Null).await;
|
||||
assert_eq!(outcome, SendOutcome::permanent("relay_consent_required"));
|
||||
let rendered = state.metrics.render();
|
||||
for series in [
|
||||
"fluxer_push_sends_total{provider=\"web_push\",result=\"permanent\"} 1",
|
||||
"fluxer_push_delivery_routes_total{route=\"web_push\",result=\"permanent\"} 1",
|
||||
] {
|
||||
assert!(rendered.contains(series), "{series} must be recorded");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -97,6 +97,10 @@ pub fn parse(endpoint: &str, hosts: &[String]) -> Option<Hop> {
|
||||
})
|
||||
}
|
||||
|
||||
pub fn is_managed(endpoint: &str, hosts: &[String]) -> bool {
|
||||
parse(endpoint, hosts).is_some()
|
||||
}
|
||||
|
||||
fn decode(segment: &str) -> Option<String> {
|
||||
percent_encoding::percent_decode_str(segment)
|
||||
.decode_utf8()
|
||||
@@ -185,6 +189,26 @@ mod tests {
|
||||
assert!(parse("https://ntfy.sh/upZzH87cT9jJCc?up=1", &ours()).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_managed_relay_leg_is_recognised_and_nothing_else_is() {
|
||||
for path in [
|
||||
format!("apns/canary/production/{TOKEN}"),
|
||||
format!("apns-voip/canary/production/{TOKEN}"),
|
||||
"fcm/canary/dYC_x9gXTjyyrG8_Aw3nUM%3AAPA91bExample".to_owned(),
|
||||
] {
|
||||
let endpoint = format!("https://push.fluxer.com/relay/v1/{path}");
|
||||
assert!(
|
||||
is_managed(&endpoint, &ours()),
|
||||
"{endpoint} must be a managed relay endpoint"
|
||||
);
|
||||
}
|
||||
assert!(!is_managed("https://ntfy.sh/upZzH87cT9jJCc?up=1", &ours()));
|
||||
assert!(!is_managed(
|
||||
"https://updates.push.services.mozilla.com/wpush/v2/gAAAAA",
|
||||
&ours()
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_malformed_relay_path_is_refused() {
|
||||
for endpoint in [
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
use crate::crypto;
|
||||
use crate::payload::{self, RecordKind};
|
||||
use crate::providers::SendOutcome;
|
||||
use crate::providers::{SendOutcome, own_relay};
|
||||
use crate::resolver;
|
||||
use crate::server::AppState;
|
||||
use crate::subscription::Subscription;
|
||||
@@ -38,6 +38,8 @@ const AES128GCM: &str = "aes128gcm";
|
||||
const NOT_FOUND: u16 = 404;
|
||||
const GONE: u16 = 410;
|
||||
const INSUFFICIENT_STORAGE: u16 = 507;
|
||||
const TOO_MANY_REQUESTS: u16 = 429;
|
||||
const RELAY_RATE_LIMITED: &str = "relay_rate_limited";
|
||||
const MAX_HOSTNAME_BYTES: usize = 253;
|
||||
const MAX_LABEL_BYTES: usize = 63;
|
||||
|
||||
@@ -111,6 +113,9 @@ pub async fn send(state: &AppState, sub: &Subscription, envelope: &Value) -> Sen
|
||||
continue;
|
||||
}
|
||||
};
|
||||
if is_relay_quota_refusal(status, &sub.endpoint, &state.cfg.managed_relay_hosts) {
|
||||
return SendOutcome::permanent(RELAY_RATE_LIMITED);
|
||||
}
|
||||
if should_retry(status, attempt) {
|
||||
tokio::time::sleep(retry_delay(attempt)).await;
|
||||
attempt += 1;
|
||||
@@ -128,6 +133,10 @@ fn delivery_headers(envelope: &Value) -> (&'static str, &'static str) {
|
||||
}
|
||||
}
|
||||
|
||||
fn is_relay_quota_refusal(status: u16, endpoint: &str, managed_relay_hosts: &[String]) -> bool {
|
||||
status == TOO_MANY_REQUESTS && own_relay::is_managed(endpoint, managed_relay_hosts)
|
||||
}
|
||||
|
||||
fn should_retry(status: u16, attempt: u32) -> bool {
|
||||
is_transient_status(status) && status != INSUFFICIENT_STORAGE && attempt < MAX_TRANSIENT_RETRIES
|
||||
}
|
||||
@@ -242,6 +251,39 @@ mod retry_tests {
|
||||
assert!(!should_retry(INSUFFICIENT_STORAGE, 0));
|
||||
}
|
||||
|
||||
const TOKEN: &str = "3dbc5a5ef1a1c1666afc26f466e1b3ebaaf4c66d92dddeb0fd1b69c49641d4cd";
|
||||
|
||||
fn managed_hosts() -> Vec<String> {
|
||||
vec!["push.fluxer.com".to_owned()]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_relay_over_its_device_quota_is_not_retried() {
|
||||
let endpoint = format!("https://push.fluxer.com/relay/v1/fcm/stable/{TOKEN}");
|
||||
assert!(is_relay_quota_refusal(
|
||||
TOO_MANY_REQUESTS,
|
||||
&endpoint,
|
||||
&managed_hosts()
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_rate_limited_third_party_push_service_is_still_retried() {
|
||||
let endpoint = "https://ntfy.sh/upZzH87cT9jJCc?up=1";
|
||||
assert!(!is_relay_quota_refusal(
|
||||
TOO_MANY_REQUESTS,
|
||||
endpoint,
|
||||
&managed_hosts()
|
||||
));
|
||||
assert!(should_retry(TOO_MANY_REQUESTS, 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unavailable_relay_is_still_retried() {
|
||||
let endpoint = format!("https://push.fluxer.com/relay/v1/fcm/stable/{TOKEN}");
|
||||
assert!(!is_relay_quota_refusal(503, &endpoint, &managed_hosts()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unavailable_push_service_is_retried_until_the_budget_runs_out() {
|
||||
assert!(should_retry(503, 0));
|
||||
|
||||
@@ -54,6 +54,7 @@ pub struct RolloutSnapshot {
|
||||
pub enabled: bool,
|
||||
pub config_version: u64,
|
||||
pub rollout_basis_points: u32,
|
||||
pub relay_consent_accepted: bool,
|
||||
}
|
||||
|
||||
impl RolloutConfig for RolloutSnapshot {
|
||||
@@ -77,6 +78,7 @@ impl RolloutConfig for RolloutSnapshot {
|
||||
enabled: parse_enabled(config)?,
|
||||
config_version: parse_config_version(config)?,
|
||||
rollout_basis_points,
|
||||
relay_consent_accepted: parse_flag(config, "relay_consent_accepted")?,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -98,9 +100,13 @@ impl RolloutConfig for RolloutSnapshot {
|
||||
}
|
||||
|
||||
pub fn parse_enabled(config: &Value) -> Option<bool> {
|
||||
match config.get("enabled") {
|
||||
parse_flag(config, "enabled")
|
||||
}
|
||||
|
||||
fn parse_flag(config: &Value, key: &str) -> Option<bool> {
|
||||
match config.get(key) {
|
||||
None | Some(Value::Null) => Some(false),
|
||||
Some(Value::Bool(enabled)) => Some(*enabled),
|
||||
Some(Value::Bool(flag)) => Some(*flag),
|
||||
Some(_) => None,
|
||||
}
|
||||
}
|
||||
@@ -160,7 +166,7 @@ impl<C: RolloutConfig> RolloutStore<C> {
|
||||
self.update(config)
|
||||
}
|
||||
|
||||
fn update(&self, config: &Value) -> RolloutOutcome {
|
||||
pub(crate) fn update(&self, config: &Value) -> RolloutOutcome {
|
||||
let Some(offered) = C::parse(config) else {
|
||||
warn!(config = C::NAME, "rollout config rejected as invalid");
|
||||
return RolloutOutcome::Rejected;
|
||||
@@ -288,3 +294,33 @@ fn config_object<'a>(value: &'a Value, message_type: &str) -> Option<&'a Value>
|
||||
}
|
||||
value.is_object().then_some(value)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn the_operator_relay_consent_is_read_off_the_instance_config() {
|
||||
let config = json!({
|
||||
"enabled": true,
|
||||
"config_version": 3,
|
||||
"relay_consent_accepted": true,
|
||||
});
|
||||
let snapshot = RolloutSnapshot::parse(&config).expect("the config parses");
|
||||
assert!(snapshot.relay_consent_accepted);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_instance_config_without_the_consent_field_has_not_consented() {
|
||||
let config = json!({"enabled": true, "config_version": 3});
|
||||
let snapshot = RolloutSnapshot::parse(&config).expect("the config parses");
|
||||
assert!(!snapshot.relay_consent_accepted);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_consent_field_that_is_not_a_boolean_is_refused() {
|
||||
let config = json!({"enabled": true, "relay_consent_accepted": "yes"});
|
||||
assert!(RolloutSnapshot::parse(&config).is_none());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,17 +2,18 @@
|
||||
|
||||
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
|
||||
import {BadRequestError} from '@fluxer/errors/src/domains/core/BadRequestError';
|
||||
import type {FluxerErrorData} from '@fluxer/errors/src/FluxerError';
|
||||
|
||||
export class CaptchaRequiredError extends BadRequestError {
|
||||
constructor() {
|
||||
super({code: APIErrorCodes.CAPTCHA_REQUIRED});
|
||||
constructor(data?: FluxerErrorData) {
|
||||
super({code: APIErrorCodes.CAPTCHA_REQUIRED, data});
|
||||
this.name = 'CaptchaRequiredError';
|
||||
}
|
||||
}
|
||||
|
||||
export class InvalidCaptchaError extends BadRequestError {
|
||||
constructor() {
|
||||
super({code: APIErrorCodes.INVALID_CAPTCHA});
|
||||
constructor(data?: FluxerErrorData) {
|
||||
super({code: APIErrorCodes.INVALID_CAPTCHA, data});
|
||||
this.name = 'InvalidCaptchaError';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,10 @@ import {ADMIN_ACL_COUNT, AdminAclType} from '@fluxer/schema/src/domains/admin/Ad
|
||||
import {AdminArchiveResponseSchema} from '@fluxer/schema/src/domains/admin/AdminArchiveSchemas';
|
||||
import {GuildAdminResponse} from '@fluxer/schema/src/domains/admin/AdminGuildSchemas';
|
||||
import {UserAdminResponseSchema} from '@fluxer/schema/src/domains/admin/AdminUserSchemas';
|
||||
import {
|
||||
AltchaCaptchaConfigResponse,
|
||||
AltchaCaptchaConfigUpdateRequest,
|
||||
} from '@fluxer/schema/src/domains/admin/AltchaCaptchaSchemas';
|
||||
import {
|
||||
DomainMigrationConfigResponse,
|
||||
DomainMigrationConfigUpdateRequest,
|
||||
@@ -654,6 +658,7 @@ export const InstanceConfigResponse = z.object({
|
||||
voice_noise_suppression: VoiceNoiseSuppressionConfigResponse,
|
||||
push_service_delivery: PushServiceDeliveryConfigResponse,
|
||||
domain_migration: DomainMigrationConfigResponse,
|
||||
altcha_captcha: AltchaCaptchaConfigResponse,
|
||||
experiment_delivery: ExperimentDeliveryConfigResponse,
|
||||
registration: InstanceRegistrationResponse,
|
||||
self_hosted: z.boolean(),
|
||||
@@ -692,6 +697,7 @@ export const InstanceConfigUpdateRequest = z.object({
|
||||
voice_noise_suppression: VoiceNoiseSuppressionConfigUpdateRequest.nullish(),
|
||||
push_service_delivery: PushServiceDeliveryConfigUpdateRequest.nullish(),
|
||||
domain_migration: DomainMigrationConfigUpdateRequest.nullish(),
|
||||
altcha_captcha: AltchaCaptchaConfigUpdateRequest.nullish(),
|
||||
experiment_delivery: ExperimentDeliveryConfigUpdateRequest.nullish(),
|
||||
registration: z
|
||||
.object({
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {
|
||||
type AltchaCaptchaConfig,
|
||||
AltchaCaptchaConfigSchema,
|
||||
AltchaCaptchaConfigUpdateRequest,
|
||||
altchaCaptchaAppliesTo,
|
||||
DEFAULT_ALTCHA_CAPTCHA_CONFIG,
|
||||
resolveAltchaCaptchaAssignment,
|
||||
} from '@fluxer/schema/src/domains/admin/AltchaCaptchaSchemas';
|
||||
import {experimentBucket} from '@fluxer/schema/src/domains/experiment/ExperimentBucket';
|
||||
import {describe, expect, test} from 'vitest';
|
||||
|
||||
const TARGETED_USER_ID = '1000000000000000001';
|
||||
|
||||
function createConfig(overrides: Partial<AltchaCaptchaConfig> = {}): AltchaCaptchaConfig {
|
||||
return {...DEFAULT_ALTCHA_CAPTCHA_CONFIG, included_user_ids: [], excluded_user_ids: [], ...overrides};
|
||||
}
|
||||
|
||||
function syntheticUserIds(count: number): Array<string> {
|
||||
return Array.from({length: count}, (_, index) => (1400000000000000000n + BigInt(index)).toString());
|
||||
}
|
||||
|
||||
describe('altcha captcha configuration', () => {
|
||||
test('defaults to disabled with no anonymous traffic', () => {
|
||||
expect(AltchaCaptchaConfigSchema.parse({})).toEqual({
|
||||
enabled: false,
|
||||
config_version: 0,
|
||||
rollout_basis_points: 0,
|
||||
rollout_salt: 'altcha-captcha-v1',
|
||||
included_user_ids: [],
|
||||
excluded_user_ids: [],
|
||||
anonymous_enabled: false,
|
||||
cost: 5000,
|
||||
max_counter: 10000,
|
||||
});
|
||||
});
|
||||
|
||||
test('rejects difficulty outside the supported range', () => {
|
||||
expect(AltchaCaptchaConfigUpdateRequest.safeParse({cost: 999}).success).toBe(false);
|
||||
expect(AltchaCaptchaConfigUpdateRequest.safeParse({cost: 100001}).success).toBe(false);
|
||||
expect(AltchaCaptchaConfigUpdateRequest.safeParse({max_counter: 99}).success).toBe(false);
|
||||
expect(AltchaCaptchaConfigUpdateRequest.safeParse({max_counter: 1000001}).success).toBe(false);
|
||||
expect(AltchaCaptchaConfigUpdateRequest.safeParse({config_version: 3}).data).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveAltchaCaptchaAssignment', () => {
|
||||
test('serves nobody while disabled, even included users', () => {
|
||||
const config = createConfig({rollout_basis_points: 10000, included_user_ids: [TARGETED_USER_ID]});
|
||||
expect(resolveAltchaCaptchaAssignment(config, TARGETED_USER_ID)).toEqual({enabled: false});
|
||||
});
|
||||
|
||||
test('applies exclusions before inclusions', () => {
|
||||
const config = createConfig({
|
||||
enabled: true,
|
||||
included_user_ids: [TARGETED_USER_ID],
|
||||
excluded_user_ids: [TARGETED_USER_ID],
|
||||
});
|
||||
expect(resolveAltchaCaptchaAssignment(config, TARGETED_USER_ID)).toEqual({enabled: false});
|
||||
});
|
||||
|
||||
test('serves included users at zero rollout', () => {
|
||||
const config = createConfig({enabled: true, included_user_ids: [TARGETED_USER_ID]});
|
||||
expect(resolveAltchaCaptchaAssignment(config, TARGETED_USER_ID)).toEqual({enabled: true});
|
||||
});
|
||||
|
||||
test('buckets the rollout by salt and user id', () => {
|
||||
const config = createConfig({enabled: true, rollout_basis_points: 2500});
|
||||
for (const userId of syntheticUserIds(200)) {
|
||||
expect(resolveAltchaCaptchaAssignment(config, userId).enabled).toBe(
|
||||
experimentBucket(userId, config.rollout_salt) < 2500,
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('altchaCaptchaAppliesTo', () => {
|
||||
test('serves anonymous requests only when anonymous_enabled is set', () => {
|
||||
expect(altchaCaptchaAppliesTo(createConfig({enabled: true}), null)).toBe(false);
|
||||
expect(altchaCaptchaAppliesTo(createConfig({enabled: true, anonymous_enabled: true}), null)).toBe(true);
|
||||
expect(altchaCaptchaAppliesTo(createConfig({anonymous_enabled: true}), null)).toBe(false);
|
||||
});
|
||||
|
||||
test('keeps signed-in users on their own bucket regardless of the anonymous switch', () => {
|
||||
const config = createConfig({enabled: true, anonymous_enabled: true, excluded_user_ids: [TARGETED_USER_ID]});
|
||||
expect(altchaCaptchaAppliesTo(config, TARGETED_USER_ID)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,82 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {EXPERIMENT_BUCKET_RESOLUTION, experimentBucket} from '@fluxer/schema/src/domains/experiment/ExperimentBucket';
|
||||
import {z} from 'zod';
|
||||
|
||||
const ALTCHA_CAPTCHA_ROLLOUT_BASIS_POINTS_MAX = EXPERIMENT_BUCKET_RESOLUTION;
|
||||
const ALTCHA_CAPTCHA_MAX_TARGETED_USERS = 1000;
|
||||
const DEFAULT_ALTCHA_CAPTCHA_SALT = 'altcha-captcha-v1';
|
||||
|
||||
export const ALTCHA_CAPTCHA_MIN_COST = 1000;
|
||||
export const ALTCHA_CAPTCHA_MAX_COST = 100000;
|
||||
export const ALTCHA_CAPTCHA_MIN_MAX_COUNTER = 100;
|
||||
export const ALTCHA_CAPTCHA_MAX_MAX_COUNTER = 1000000;
|
||||
|
||||
const ALTCHA_CAPTCHA_SALT_PATTERN = /^[\x20-\x7e]+$/u;
|
||||
|
||||
const AltchaCaptchaTargetIdSchema = z.string().regex(/^\d{1,20}$/u);
|
||||
const AltchaCaptchaTargetedUserIdsSchema = z.array(AltchaCaptchaTargetIdSchema).max(ALTCHA_CAPTCHA_MAX_TARGETED_USERS);
|
||||
|
||||
const altchaCaptchaConfigFields = {
|
||||
enabled: z.boolean(),
|
||||
config_version: z.number().int().min(0),
|
||||
rollout_basis_points: z.number().int().min(0).max(ALTCHA_CAPTCHA_ROLLOUT_BASIS_POINTS_MAX),
|
||||
rollout_salt: z.string().trim().min(1).max(64).regex(ALTCHA_CAPTCHA_SALT_PATTERN),
|
||||
included_user_ids: AltchaCaptchaTargetedUserIdsSchema,
|
||||
excluded_user_ids: AltchaCaptchaTargetedUserIdsSchema,
|
||||
anonymous_enabled: z.boolean(),
|
||||
cost: z.number().int().min(ALTCHA_CAPTCHA_MIN_COST).max(ALTCHA_CAPTCHA_MAX_COST),
|
||||
max_counter: z.number().int().min(ALTCHA_CAPTCHA_MIN_MAX_COUNTER).max(ALTCHA_CAPTCHA_MAX_MAX_COUNTER),
|
||||
};
|
||||
|
||||
export const AltchaCaptchaConfigSchema = z.object({
|
||||
enabled: altchaCaptchaConfigFields.enabled.default(false),
|
||||
config_version: altchaCaptchaConfigFields.config_version.default(0),
|
||||
rollout_basis_points: altchaCaptchaConfigFields.rollout_basis_points.default(0),
|
||||
rollout_salt: altchaCaptchaConfigFields.rollout_salt.default(DEFAULT_ALTCHA_CAPTCHA_SALT),
|
||||
included_user_ids: altchaCaptchaConfigFields.included_user_ids.default([]),
|
||||
excluded_user_ids: altchaCaptchaConfigFields.excluded_user_ids.default([]),
|
||||
anonymous_enabled: altchaCaptchaConfigFields.anonymous_enabled.default(false),
|
||||
cost: altchaCaptchaConfigFields.cost.default(5000),
|
||||
max_counter: altchaCaptchaConfigFields.max_counter.default(10000),
|
||||
});
|
||||
|
||||
export type AltchaCaptchaConfig = z.infer<typeof AltchaCaptchaConfigSchema>;
|
||||
|
||||
export const DEFAULT_ALTCHA_CAPTCHA_CONFIG: AltchaCaptchaConfig = AltchaCaptchaConfigSchema.parse({});
|
||||
|
||||
export const AltchaCaptchaConfigUpdateRequest = z
|
||||
.object(altchaCaptchaConfigFields)
|
||||
.omit({config_version: true})
|
||||
.partial();
|
||||
|
||||
export type AltchaCaptchaConfigUpdateRequest = z.infer<typeof AltchaCaptchaConfigUpdateRequest>;
|
||||
|
||||
export const AltchaCaptchaConfigResponse = AltchaCaptchaConfigSchema;
|
||||
|
||||
export type AltchaCaptchaConfigResponse = z.infer<typeof AltchaCaptchaConfigResponse>;
|
||||
|
||||
export const AltchaCaptchaAssignmentResponse = z.object({
|
||||
enabled: altchaCaptchaConfigFields.enabled,
|
||||
});
|
||||
|
||||
export type AltchaCaptchaAssignmentResponse = z.infer<typeof AltchaCaptchaAssignmentResponse>;
|
||||
|
||||
export const INERT_ALTCHA_CAPTCHA_ASSIGNMENT: AltchaCaptchaAssignmentResponse = {
|
||||
enabled: false,
|
||||
};
|
||||
|
||||
export function resolveAltchaCaptchaAssignment(
|
||||
config: AltchaCaptchaConfig,
|
||||
userId: string,
|
||||
): AltchaCaptchaAssignmentResponse {
|
||||
if (!config.enabled) return {...INERT_ALTCHA_CAPTCHA_ASSIGNMENT};
|
||||
if (config.excluded_user_ids.includes(userId)) return {...INERT_ALTCHA_CAPTCHA_ASSIGNMENT};
|
||||
if (config.included_user_ids.includes(userId)) return {enabled: true};
|
||||
return {enabled: experimentBucket(userId, config.rollout_salt) < config.rollout_basis_points};
|
||||
}
|
||||
|
||||
export function altchaCaptchaAppliesTo(config: AltchaCaptchaConfig, userId: string | null): boolean {
|
||||
if (userId === null) return config.enabled && config.anonymous_enabled;
|
||||
return resolveAltchaCaptchaAssignment(config, userId).enabled;
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {
|
||||
DEFAULT_PUSH_SERVICE_DELIVERY_CONFIG,
|
||||
type PushServiceDeliveryConfig,
|
||||
PushServiceDeliveryConfigSchema,
|
||||
PushServiceDeliveryConfigUpdateRequest,
|
||||
pushServiceDeliveryEnrols,
|
||||
} from '@fluxer/schema/src/domains/admin/PushServiceDeliverySchemas';
|
||||
import {describe, expect, test} from 'vitest';
|
||||
|
||||
const ADMIN_USER_ID = '1500000000000000001';
|
||||
const TARGETED_USER_ID = '1500000000000000002';
|
||||
|
||||
function createConfig(overrides: Partial<PushServiceDeliveryConfig> = {}): PushServiceDeliveryConfig {
|
||||
return {
|
||||
...DEFAULT_PUSH_SERVICE_DELIVERY_CONFIG,
|
||||
included_user_ids: [],
|
||||
excluded_user_ids: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('push service delivery relay consent', () => {
|
||||
test('a stored configuration that predates relay consent reads back as not accepted', () => {
|
||||
expect(
|
||||
PushServiceDeliveryConfigSchema.parse({
|
||||
enabled: true,
|
||||
config_version: 4,
|
||||
rollout_basis_points: 10000,
|
||||
rollout_salt: 'push-service-delivery-v1',
|
||||
included_user_ids: [],
|
||||
excluded_user_ids: [],
|
||||
}),
|
||||
).toMatchObject({
|
||||
relay_consent_accepted: false,
|
||||
relay_consent_accepted_at: null,
|
||||
relay_consent_accepted_by: null,
|
||||
});
|
||||
});
|
||||
|
||||
test('the defaults export carries the unaccepted consent', () => {
|
||||
expect(DEFAULT_PUSH_SERVICE_DELIVERY_CONFIG.relay_consent_accepted).toBe(false);
|
||||
expect(DEFAULT_PUSH_SERVICE_DELIVERY_CONFIG.relay_consent_accepted_at).toBeNull();
|
||||
expect(DEFAULT_PUSH_SERVICE_DELIVERY_CONFIG.relay_consent_accepted_by).toBeNull();
|
||||
});
|
||||
|
||||
test('an accepted consent round-trips through the stored schema', () => {
|
||||
const accepted = {
|
||||
...DEFAULT_PUSH_SERVICE_DELIVERY_CONFIG,
|
||||
relay_consent_accepted: true,
|
||||
relay_consent_accepted_at: '2026-09-27T10:11:12.000Z',
|
||||
relay_consent_accepted_by: ADMIN_USER_ID,
|
||||
};
|
||||
expect(PushServiceDeliveryConfigSchema.parse(accepted)).toEqual(accepted);
|
||||
});
|
||||
|
||||
test('the update request takes the consent flag on its own', () => {
|
||||
expect(PushServiceDeliveryConfigUpdateRequest.parse({relay_consent_accepted: true})).toEqual({
|
||||
relay_consent_accepted: true,
|
||||
});
|
||||
});
|
||||
|
||||
test('the update request refuses a client-supplied acceptance stamp', () => {
|
||||
expect(
|
||||
PushServiceDeliveryConfigUpdateRequest.parse({
|
||||
relay_consent_accepted: true,
|
||||
relay_consent_accepted_at: '2020-01-01T00:00:00.000Z',
|
||||
relay_consent_accepted_by: ADMIN_USER_ID,
|
||||
}),
|
||||
).toEqual({relay_consent_accepted: true});
|
||||
});
|
||||
|
||||
test.each([
|
||||
{relay_consent_accepted_at: 'yesterday'},
|
||||
{relay_consent_accepted_at: '2026-09-27'},
|
||||
{relay_consent_accepted_by: 'not-an-id'},
|
||||
{relay_consent_accepted: 'yes'},
|
||||
])('rejects a malformed stored consent: %j', (value) => {
|
||||
expect(PushServiceDeliveryConfigSchema.safeParse(value).success).toBe(false);
|
||||
});
|
||||
|
||||
test('consent alone enrols nobody and refusing it excludes nobody', () => {
|
||||
const withConsent = createConfig({enabled: false, relay_consent_accepted: true});
|
||||
const withoutConsent = createConfig({enabled: true, rollout_basis_points: 10000});
|
||||
expect(pushServiceDeliveryEnrols(withConsent, TARGETED_USER_ID)).toBe(false);
|
||||
expect(pushServiceDeliveryEnrols(withoutConsent, TARGETED_USER_ID)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -21,6 +21,9 @@ const pushServiceDeliveryConfigFields = {
|
||||
rollout_salt: z.string().trim().min(1).max(64).regex(PUSH_SERVICE_DELIVERY_SALT_PATTERN),
|
||||
included_user_ids: PushServiceDeliveryTargetedUserIdsSchema,
|
||||
excluded_user_ids: PushServiceDeliveryTargetedUserIdsSchema,
|
||||
relay_consent_accepted: z.boolean(),
|
||||
relay_consent_accepted_at: z.iso.datetime().nullable(),
|
||||
relay_consent_accepted_by: PushServiceDeliveryTargetIdSchema.nullable(),
|
||||
};
|
||||
|
||||
export const PushServiceDeliveryConfigSchema = z.object({
|
||||
@@ -30,6 +33,9 @@ export const PushServiceDeliveryConfigSchema = z.object({
|
||||
rollout_salt: pushServiceDeliveryConfigFields.rollout_salt.default(DEFAULT_PUSH_SERVICE_DELIVERY_SALT),
|
||||
included_user_ids: pushServiceDeliveryConfigFields.included_user_ids.default([]),
|
||||
excluded_user_ids: pushServiceDeliveryConfigFields.excluded_user_ids.default([]),
|
||||
relay_consent_accepted: pushServiceDeliveryConfigFields.relay_consent_accepted.default(false),
|
||||
relay_consent_accepted_at: pushServiceDeliveryConfigFields.relay_consent_accepted_at.default(null),
|
||||
relay_consent_accepted_by: pushServiceDeliveryConfigFields.relay_consent_accepted_by.default(null),
|
||||
});
|
||||
|
||||
export type PushServiceDeliveryConfig = z.infer<typeof PushServiceDeliveryConfigSchema>;
|
||||
@@ -40,7 +46,7 @@ export const DEFAULT_PUSH_SERVICE_DELIVERY_CONFIG: PushServiceDeliveryConfig = P
|
||||
|
||||
export const PushServiceDeliveryConfigUpdateRequest = z
|
||||
.object(pushServiceDeliveryConfigFields)
|
||||
.omit({config_version: true})
|
||||
.omit({config_version: true, relay_consent_accepted_at: true, relay_consent_accepted_by: true})
|
||||
.partial();
|
||||
|
||||
export type PushServiceDeliveryConfigUpdateRequest = z.infer<typeof PushServiceDeliveryConfigUpdateRequest>;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user