mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-10 20:52:12 +09:00
Compare commits
39
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1c36a59b2c | ||
|
|
6730a242db | ||
|
|
e62ae77643 | ||
|
|
f4f39e6a89 | ||
|
|
00bf74cef5 | ||
|
|
c1c45d835f | ||
|
|
bbfe809bef | ||
|
|
e0843ac4f5 | ||
|
|
43741cdad8 | ||
|
|
b8e3807262 | ||
|
|
3304f01a84 | ||
|
|
2ba463235b | ||
|
|
15136fed59 | ||
|
|
6013581dd9 | ||
|
|
7a91f128e9 | ||
|
|
963ffc5550 | ||
|
|
a90991612c | ||
|
|
50ad23b760 | ||
|
|
425dab983b | ||
|
|
a0825e77c4 | ||
|
|
88038a1d5b | ||
|
|
c2c0fdb445 | ||
|
|
dcd5f09d6a | ||
|
|
590b1f36fd | ||
|
|
168ac727f1 | ||
|
|
deb86dd92e | ||
|
|
7ccec4d3b8 | ||
|
|
2f38bcdf26 | ||
|
|
f2785941aa | ||
|
|
ea9f83a443 | ||
|
|
bd6ca7290e | ||
|
|
b85e975fb5 | ||
|
|
b6e504f68c | ||
|
|
5fde6eb484 | ||
|
|
b16989d567 | ||
|
|
c9754ac11a | ||
|
|
f34e4a5115 | ||
|
|
44b3615298 | ||
|
|
211e98307d |
@@ -32,6 +32,7 @@
|
||||
/fluxer_docs/.astro/
|
||||
/fluxer_app/.devserver-cache.json
|
||||
/fluxer_app/pkgs/libfluxcore/
|
||||
/fluxer_app/pkgs/libfluxwebp/
|
||||
/fluxer_app/src/features/i18n/locales/*/messages.mjs
|
||||
/fluxer_app/src/features/messaging/utils/markdown/parser/MarkdownParserWasmBytes.ts
|
||||
/fluxer_app/src/features/theme/styles/generated/
|
||||
|
||||
@@ -123,12 +123,16 @@ jobs:
|
||||
with:
|
||||
path: |
|
||||
fluxer_app/pkgs/libfluxcore
|
||||
fluxer_app/pkgs/libfluxwebp
|
||||
fluxer_app/src/features/messaging/utils/markdown/parser/MarkdownParserWasmBytes.ts
|
||||
key: >-
|
||||
app-wasm-${{ runner.os }}-1.98.1-${{ hashFiles('Cargo.lock', 'tools/ci/src/app_wasm.rs',
|
||||
'tools/ci/templates/libfluxcore_wrapper.js', 'tools/ci/templates/libfluxcore_wrapper.d.ts',
|
||||
'fluxer_app/rust/libfluxcore/Cargo.toml', 'fluxer_app/rust/libfluxcore/Cargo.lock',
|
||||
'fluxer_app/rust/libfluxcore/.cargo/config.toml', 'fluxer_app/rust/libfluxcore/src/**',
|
||||
'fluxer_app/rust/libfluxwebp/Cargo.toml', 'fluxer_app/rust/libfluxwebp/Cargo.lock',
|
||||
'fluxer_app/rust/libfluxwebp/src/**', 'fluxer_app/rust/libfluxwebp/shim/**',
|
||||
'fluxer_app/rust/libfluxwebp/simd/**',
|
||||
'packages/markdown_parser/rust/Cargo.toml', 'packages/markdown_parser/rust/.cargo/config.toml',
|
||||
'packages/markdown_parser/rust/src/**') }}
|
||||
|
||||
@@ -142,12 +146,16 @@ jobs:
|
||||
with:
|
||||
path: |
|
||||
fluxer_app/pkgs/libfluxcore
|
||||
fluxer_app/pkgs/libfluxwebp
|
||||
fluxer_app/src/features/messaging/utils/markdown/parser/MarkdownParserWasmBytes.ts
|
||||
key: >-
|
||||
app-wasm-${{ runner.os }}-1.98.1-${{ hashFiles('Cargo.lock', 'tools/ci/src/app_wasm.rs',
|
||||
'tools/ci/templates/libfluxcore_wrapper.js', 'tools/ci/templates/libfluxcore_wrapper.d.ts',
|
||||
'fluxer_app/rust/libfluxcore/Cargo.toml', 'fluxer_app/rust/libfluxcore/Cargo.lock',
|
||||
'fluxer_app/rust/libfluxcore/.cargo/config.toml', 'fluxer_app/rust/libfluxcore/src/**',
|
||||
'fluxer_app/rust/libfluxwebp/Cargo.toml', 'fluxer_app/rust/libfluxwebp/Cargo.lock',
|
||||
'fluxer_app/rust/libfluxwebp/src/**', 'fluxer_app/rust/libfluxwebp/shim/**',
|
||||
'fluxer_app/rust/libfluxwebp/simd/**',
|
||||
'packages/markdown_parser/rust/Cargo.toml', 'packages/markdown_parser/rust/.cargo/config.toml',
|
||||
'packages/markdown_parser/rust/src/**') }}
|
||||
|
||||
@@ -190,6 +198,9 @@ jobs:
|
||||
- name: Check Rust dependencies
|
||||
run: cargo deny --locked check -D warnings
|
||||
|
||||
- name: Check libfluxwebp dependencies
|
||||
run: cargo deny --manifest-path fluxer_app/rust/libfluxwebp/Cargo.toml --config deny.toml --locked check licenses bans sources
|
||||
|
||||
- name: Check desktop native dependencies
|
||||
run: tools/ci/check-desktop-native-workspaces.sh dependencies
|
||||
|
||||
@@ -242,6 +253,9 @@ jobs:
|
||||
- name: Check formatting
|
||||
run: cargo fmt --all -- --check
|
||||
|
||||
- name: Check formatting (libfluxwebp)
|
||||
run: cargo fmt --manifest-path fluxer_app/rust/libfluxwebp/Cargo.toml -- --check
|
||||
|
||||
- name: Check formatting (desktop native workspaces)
|
||||
run: tools/ci/check-desktop-native-workspaces.sh fmt
|
||||
|
||||
@@ -398,12 +412,16 @@ jobs:
|
||||
with:
|
||||
path: |
|
||||
fluxer_app/pkgs/libfluxcore
|
||||
fluxer_app/pkgs/libfluxwebp
|
||||
fluxer_app/src/features/messaging/utils/markdown/parser/MarkdownParserWasmBytes.ts
|
||||
key: >-
|
||||
app-wasm-${{ runner.os }}-1.98.1-${{ hashFiles('Cargo.lock', 'tools/ci/src/app_wasm.rs',
|
||||
'tools/ci/templates/libfluxcore_wrapper.js', 'tools/ci/templates/libfluxcore_wrapper.d.ts',
|
||||
'fluxer_app/rust/libfluxcore/Cargo.toml', 'fluxer_app/rust/libfluxcore/Cargo.lock',
|
||||
'fluxer_app/rust/libfluxcore/.cargo/config.toml', 'fluxer_app/rust/libfluxcore/src/**',
|
||||
'fluxer_app/rust/libfluxwebp/Cargo.toml', 'fluxer_app/rust/libfluxwebp/Cargo.lock',
|
||||
'fluxer_app/rust/libfluxwebp/src/**', 'fluxer_app/rust/libfluxwebp/shim/**',
|
||||
'fluxer_app/rust/libfluxwebp/simd/**',
|
||||
'packages/markdown_parser/rust/Cargo.toml', 'packages/markdown_parser/rust/.cargo/config.toml',
|
||||
'packages/markdown_parser/rust/src/**') }}
|
||||
|
||||
@@ -417,12 +435,16 @@ jobs:
|
||||
with:
|
||||
path: |
|
||||
fluxer_app/pkgs/libfluxcore
|
||||
fluxer_app/pkgs/libfluxwebp
|
||||
fluxer_app/src/features/messaging/utils/markdown/parser/MarkdownParserWasmBytes.ts
|
||||
key: >-
|
||||
app-wasm-${{ runner.os }}-1.98.1-${{ hashFiles('Cargo.lock', 'tools/ci/src/app_wasm.rs',
|
||||
'tools/ci/templates/libfluxcore_wrapper.js', 'tools/ci/templates/libfluxcore_wrapper.d.ts',
|
||||
'fluxer_app/rust/libfluxcore/Cargo.toml', 'fluxer_app/rust/libfluxcore/Cargo.lock',
|
||||
'fluxer_app/rust/libfluxcore/.cargo/config.toml', 'fluxer_app/rust/libfluxcore/src/**',
|
||||
'fluxer_app/rust/libfluxwebp/Cargo.toml', 'fluxer_app/rust/libfluxwebp/Cargo.lock',
|
||||
'fluxer_app/rust/libfluxwebp/src/**', 'fluxer_app/rust/libfluxwebp/shim/**',
|
||||
'fluxer_app/rust/libfluxwebp/simd/**',
|
||||
'packages/markdown_parser/rust/Cargo.toml', 'packages/markdown_parser/rust/.cargo/config.toml',
|
||||
'packages/markdown_parser/rust/src/**') }}
|
||||
|
||||
|
||||
@@ -26,6 +26,7 @@
|
||||
|
||||
/fluxer_app/.devserver-cache.json
|
||||
/fluxer_app/pkgs/libfluxcore/
|
||||
/fluxer_app/pkgs/libfluxwebp/
|
||||
/fluxer_app/src/features/i18n/locales/*/messages.mjs
|
||||
/fluxer_app/src/features/messaging/utils/markdown/parser/MarkdownParserWasmBytes.ts
|
||||
/fluxer_app/src/features/theme/styles/generated/
|
||||
|
||||
Generated
+1
@@ -1894,6 +1894,7 @@ dependencies = [
|
||||
"futures",
|
||||
"hmac 0.13.0",
|
||||
"p256",
|
||||
"percent-encoding",
|
||||
"rand 0.10.2",
|
||||
"reqwest",
|
||||
"ring",
|
||||
|
||||
@@ -49,7 +49,7 @@ On Linux, prefer a repository over a single file so Fluxer updates with the rest
|
||||
|
||||
## Linux package repositories
|
||||
|
||||
Every repository serves both channels. The package is `fluxer` for stable, `fluxer-canary` for canary.
|
||||
The package is `fluxer` for stable and `fluxer-canary` for canary. apt and dnf subscribe to one channel per entry file. pacman and Flatpak serve both from one repository.
|
||||
|
||||
### Flatpak
|
||||
|
||||
@@ -59,7 +59,7 @@ Stable is on [Flathub][flathub], the easiest route on most desktops:
|
||||
flatpak install flathub app.fluxer.Fluxer
|
||||
```
|
||||
|
||||
Flathub has stable only. For canary, or to use Fluxer's own repository, open [this reference file][flatpak-ref] and your software manager takes over. Some desktops also accept `flatpak+https://pkgs.fluxer.com/flatpak/fluxer.flatpakref` in the address bar.
|
||||
Flathub has stable only. To use Fluxer's own repository, open [the stable][flatpak-ref] or [the canary][flatpak-canary-ref] reference file and your software manager takes over. Some desktops also accept `flatpak+https://pkgs.fluxer.com/flatpak/fluxer.flatpakref` in the address bar.
|
||||
|
||||
From a terminal:
|
||||
|
||||
@@ -76,6 +76,15 @@ sudo curl -fsSL -o /etc/apt/sources.list.d/fluxer.sources https://pkgs.fluxer.co
|
||||
sudo apt update && sudo apt install fluxer
|
||||
```
|
||||
|
||||
For canary, use the canary entry file and package.
|
||||
|
||||
```sh
|
||||
sudo curl -fsSL -o /etc/apt/sources.list.d/fluxer-canary.sources https://pkgs.fluxer.com/deb/fluxer-canary.sources
|
||||
sudo apt update && sudo apt install fluxer-canary
|
||||
```
|
||||
|
||||
A `.deb` installed from a download only updates once its channel's entry is added.
|
||||
|
||||
### Fedora and RHEL
|
||||
|
||||
```sh
|
||||
@@ -83,6 +92,13 @@ sudo curl -fsSL -o /etc/yum.repos.d/fluxer.repo https://pkgs.fluxer.com/rpm/flux
|
||||
sudo dnf install fluxer
|
||||
```
|
||||
|
||||
For canary, use the canary entry file and package.
|
||||
|
||||
```sh
|
||||
sudo curl -fsSL -o /etc/yum.repos.d/fluxer-canary.repo https://pkgs.fluxer.com/rpm/fluxer-canary.repo
|
||||
sudo dnf install fluxer-canary
|
||||
```
|
||||
|
||||
RHEL, Rocky, Alma and CentOS Stream need `sudo dnf install epel-release` first, because their base repositories lack `libXScrnSaver`. Fedora does not.
|
||||
|
||||
### Arch Linux
|
||||
@@ -150,6 +166,7 @@ endorsement rights.
|
||||
[linux-targz-x64]: https://pkgs.fluxer.com/desktop/stable/linux/x64/latest/tar_gz
|
||||
[linux-targz-arm64]: https://pkgs.fluxer.com/desktop/stable/linux/arm64/latest/tar_gz
|
||||
[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-apk]: https://github.com/fluxerapp/flutter_client/releases
|
||||
[obtainium]: https://obtainium.imranr.dev/
|
||||
|
||||
@@ -10524,8 +10524,8 @@
|
||||
},
|
||||
"gateway_rollout": {"$ref": "#/components/schemas/GatewayRolloutConfigResponse"},
|
||||
"voice_noise_suppression": {"$ref": "#/components/schemas/VoiceNoiseSuppressionConfigResponse"},
|
||||
"screen_share_delivery": {"$ref": "#/components/schemas/ScreenShareDeliveryConfigResponse"},
|
||||
"push_service_delivery": {"$ref": "#/components/schemas/PushServiceDeliveryConfigResponse"},
|
||||
"domain_migration": {"$ref": "#/components/schemas/DomainMigrationConfigResponse"},
|
||||
"experiment_delivery": {"$ref": "#/components/schemas/ExperimentDeliveryConfigResponse"},
|
||||
"registration": {
|
||||
"type": "object",
|
||||
@@ -10953,8 +10953,8 @@
|
||||
"sso",
|
||||
"gateway_rollout",
|
||||
"voice_noise_suppression",
|
||||
"screen_share_delivery",
|
||||
"push_service_delivery",
|
||||
"domain_migration",
|
||||
"experiment_delivery",
|
||||
"registration",
|
||||
"self_hosted",
|
||||
@@ -11089,14 +11089,14 @@
|
||||
"nullable": true,
|
||||
"allOf": [{"$ref": "#/components/schemas/VoiceNoiseSuppressionConfigUpdateRequest"}]
|
||||
},
|
||||
"screen_share_delivery": {
|
||||
"nullable": true,
|
||||
"allOf": [{"$ref": "#/components/schemas/ScreenShareDeliveryConfigUpdateRequest"}]
|
||||
},
|
||||
"push_service_delivery": {
|
||||
"nullable": true,
|
||||
"allOf": [{"$ref": "#/components/schemas/PushServiceDeliveryConfigUpdateRequest"}]
|
||||
},
|
||||
"domain_migration": {
|
||||
"nullable": true,
|
||||
"allOf": [{"$ref": "#/components/schemas/DomainMigrationConfigUpdateRequest"}]
|
||||
},
|
||||
"experiment_delivery": {
|
||||
"nullable": true,
|
||||
"allOf": [{"$ref": "#/components/schemas/ExperimentDeliveryConfigUpdateRequest"}]
|
||||
@@ -15190,7 +15190,7 @@
|
||||
"poll_jitter_percent": {"type": "integer", "minimum": 0, "maximum": 50}
|
||||
}
|
||||
},
|
||||
"PushServiceDeliveryConfigUpdateRequest": {
|
||||
"DomainMigrationConfigUpdateRequest": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"enabled": {"type": "boolean"},
|
||||
@@ -15205,15 +15205,17 @@
|
||||
"maxItems": 1000,
|
||||
"type": "array",
|
||||
"items": {"type": "string", "pattern": "^\\d{1,20}$"}
|
||||
}
|
||||
},
|
||||
"anonymous_rollout_basis_points": {"type": "integer", "minimum": 0, "maximum": 10000},
|
||||
"standalone_forwarding": {"type": "boolean"}
|
||||
}
|
||||
},
|
||||
"ScreenShareDeliveryConfigUpdateRequest": {
|
||||
"PushServiceDeliveryConfigUpdateRequest": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"enabled": {"type": "boolean"},
|
||||
"rollout_basis_points": {"type": "integer", "minimum": 0, "maximum": 10000},
|
||||
"rollout_salt": {"type": "string", "minLength": 1, "maxLength": 64},
|
||||
"rollout_salt": {"type": "string", "minLength": 1, "maxLength": 64, "pattern": "^[\\x20-\\x7e]+$"},
|
||||
"included_user_ids": {
|
||||
"maxItems": 1000,
|
||||
"type": "array",
|
||||
@@ -15291,14 +15293,14 @@
|
||||
"required": ["poll_interval_seconds", "poll_jitter_percent"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"PushServiceDeliveryConfigResponse": {
|
||||
"DomainMigrationConfigResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"enabled": {"default": false, "type": "boolean"},
|
||||
"config_version": {"default": 0, "type": "integer", "minimum": 0, "maximum": 9007199254740991},
|
||||
"rollout_basis_points": {"default": 0, "type": "integer", "minimum": 0, "maximum": 10000},
|
||||
"rollout_salt": {
|
||||
"default": "push-service-delivery-v1",
|
||||
"default": "domain-migration-v1",
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 64,
|
||||
@@ -15315,7 +15317,9 @@
|
||||
"maxItems": 1000,
|
||||
"type": "array",
|
||||
"items": {"type": "string", "pattern": "^\\d{1,20}$"}
|
||||
}
|
||||
},
|
||||
"anonymous_rollout_basis_points": {"default": 0, "type": "integer", "minimum": 0, "maximum": 10000},
|
||||
"standalone_forwarding": {"default": false, "type": "boolean"}
|
||||
},
|
||||
"required": [
|
||||
"enabled",
|
||||
@@ -15323,17 +15327,25 @@
|
||||
"rollout_basis_points",
|
||||
"rollout_salt",
|
||||
"included_user_ids",
|
||||
"excluded_user_ids"
|
||||
"excluded_user_ids",
|
||||
"anonymous_rollout_basis_points",
|
||||
"standalone_forwarding"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"ScreenShareDeliveryConfigResponse": {
|
||||
"PushServiceDeliveryConfigResponse": {
|
||||
"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": "screen-share-delivery-v1", "type": "string", "minLength": 1, "maxLength": 64},
|
||||
"rollout_salt": {
|
||||
"default": "push-service-delivery-v1",
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 64,
|
||||
"pattern": "^[\\x20-\\x7e]+$"
|
||||
},
|
||||
"included_user_ids": {
|
||||
"default": [],
|
||||
"maxItems": 1000,
|
||||
|
||||
@@ -23,10 +23,10 @@ pub struct InstanceConfigResponse {
|
||||
#[serde(default)]
|
||||
pub voice_noise_suppression: VoiceNoiseSuppressionConfigResponse,
|
||||
#[serde(default)]
|
||||
pub screen_share_delivery: ScreenShareDeliveryConfigResponse,
|
||||
#[serde(default)]
|
||||
pub push_service_delivery: PushServiceDeliveryConfigResponse,
|
||||
#[serde(default)]
|
||||
pub domain_migration: DomainMigrationConfigResponse,
|
||||
#[serde(default)]
|
||||
pub experiment_delivery: ExperimentDeliveryConfigResponse,
|
||||
}
|
||||
|
||||
@@ -452,7 +452,7 @@ impl VoiceE2eeScope {
|
||||
|
||||
pub const EXPERIMENT_MAX_TARGETED_USERS: usize = 1_000;
|
||||
pub const PUSH_SERVICE_DELIVERY_DEFAULT_SALT: &str = "push-service-delivery-v1";
|
||||
pub const SCREEN_SHARE_DELIVERY_DEFAULT_SALT: &str = "screen-share-delivery-v1";
|
||||
pub const DOMAIN_MIGRATION_DEFAULT_SALT: &str = "domain-migration-v1";
|
||||
pub const VOICE_NS_MAX_GUILD_OVERRIDES: usize = 200;
|
||||
|
||||
impl NoiseSuppressionBackend {
|
||||
@@ -543,44 +543,6 @@ pub struct VoiceNoiseSuppressionConfigUpdateRequest {
|
||||
pub suppression_strength: Option<u32>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
#[serde(default)]
|
||||
pub struct ScreenShareDeliveryConfigResponse {
|
||||
pub enabled: bool,
|
||||
pub config_version: u64,
|
||||
pub rollout_basis_points: u32,
|
||||
pub rollout_salt: String,
|
||||
pub included_user_ids: Vec<String>,
|
||||
pub excluded_user_ids: Vec<String>,
|
||||
}
|
||||
|
||||
impl Default for ScreenShareDeliveryConfigResponse {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: false,
|
||||
config_version: 0,
|
||||
rollout_basis_points: 0,
|
||||
rollout_salt: SCREEN_SHARE_DELIVERY_DEFAULT_SALT.to_owned(),
|
||||
included_user_ids: Vec::new(),
|
||||
excluded_user_ids: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Serialize)]
|
||||
pub struct ScreenShareDeliveryConfigUpdateRequest {
|
||||
#[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>>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
#[serde(default)]
|
||||
pub struct PushServiceDeliveryConfigResponse {
|
||||
@@ -619,6 +581,52 @@ pub struct PushServiceDeliveryConfigUpdateRequest {
|
||||
pub excluded_user_ids: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
#[serde(default)]
|
||||
pub struct DomainMigrationConfigResponse {
|
||||
pub enabled: bool,
|
||||
pub config_version: u64,
|
||||
pub rollout_basis_points: u32,
|
||||
pub rollout_salt: String,
|
||||
pub included_user_ids: Vec<String>,
|
||||
pub excluded_user_ids: Vec<String>,
|
||||
pub anonymous_rollout_basis_points: u32,
|
||||
pub standalone_forwarding: bool,
|
||||
}
|
||||
|
||||
impl Default for DomainMigrationConfigResponse {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: false,
|
||||
config_version: 0,
|
||||
rollout_basis_points: 0,
|
||||
rollout_salt: DOMAIN_MIGRATION_DEFAULT_SALT.to_owned(),
|
||||
included_user_ids: Vec::new(),
|
||||
excluded_user_ids: Vec::new(),
|
||||
anonymous_rollout_basis_points: 0,
|
||||
standalone_forwarding: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Serialize)]
|
||||
pub struct DomainMigrationConfigUpdateRequest {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub enabled: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub rollout_basis_points: Option<u32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub rollout_salt: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub included_user_ids: Option<Vec<String>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub excluded_user_ids: Option<Vec<String>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub anonymous_rollout_basis_points: Option<u32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub standalone_forwarding: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
#[serde(default)]
|
||||
pub struct ExperimentDeliveryConfigResponse {
|
||||
@@ -735,10 +743,10 @@ pub struct InstanceConfigUpdateRequest {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub voice_noise_suppression: Option<VoiceNoiseSuppressionConfigUpdateRequest>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub screen_share_delivery: Option<ScreenShareDeliveryConfigUpdateRequest>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub push_service_delivery: Option<PushServiceDeliveryConfigUpdateRequest>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub domain_migration: Option<DomainMigrationConfigUpdateRequest>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub experiment_delivery: Option<ExperimentDeliveryConfigUpdateRequest>,
|
||||
}
|
||||
|
||||
@@ -1076,19 +1084,19 @@ mod tests {
|
||||
.expect("admin schema");
|
||||
let noise = serde_json::from_value::<VoiceNoiseSuppressionConfigResponse>(json!({}))
|
||||
.expect("default noise config");
|
||||
let screen_share = serde_json::from_value::<ScreenShareDeliveryConfigResponse>(json!({}))
|
||||
.expect("default screen share config");
|
||||
let domain_migration = serde_json::from_value::<DomainMigrationConfigResponse>(json!({}))
|
||||
.expect("default domain migration config");
|
||||
let delivery = serde_json::from_value::<ExperimentDeliveryConfigResponse>(json!({}))
|
||||
.expect("default delivery config");
|
||||
let noise = serde_json::to_value(noise).expect("serializable noise config");
|
||||
let screen_share =
|
||||
serde_json::to_value(screen_share).expect("serializable screen share config");
|
||||
let domain_migration =
|
||||
serde_json::to_value(domain_migration).expect("serializable domain migration config");
|
||||
let delivery = serde_json::to_value(delivery).expect("serializable delivery config");
|
||||
let generated_noise: generated_types::VoiceNoiseSuppressionConfigResponse =
|
||||
serde_json::from_value(noise.clone()).expect("generated noise config contract");
|
||||
let generated_screen_share: generated_types::ScreenShareDeliveryConfigResponse =
|
||||
serde_json::from_value(screen_share.clone())
|
||||
.expect("generated screen share config contract");
|
||||
let generated_domain_migration: generated_types::DomainMigrationConfigResponse =
|
||||
serde_json::from_value(domain_migration.clone())
|
||||
.expect("generated domain migration config contract");
|
||||
let generated_delivery: generated_types::ExperimentDeliveryConfigResponse =
|
||||
serde_json::from_value(delivery.clone()).expect("generated delivery config contract");
|
||||
assert_eq!(
|
||||
@@ -1096,9 +1104,9 @@ mod tests {
|
||||
noise
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::to_value(generated_screen_share)
|
||||
.expect("serializable generated screen share config"),
|
||||
screen_share
|
||||
serde_json::to_value(generated_domain_migration)
|
||||
.expect("serializable generated domain migration config"),
|
||||
domain_migration
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::to_value(generated_delivery)
|
||||
@@ -1107,7 +1115,7 @@ mod tests {
|
||||
);
|
||||
for (name, value) in [
|
||||
("VoiceNoiseSuppressionConfigResponse", noise),
|
||||
("ScreenShareDeliveryConfigResponse", screen_share),
|
||||
("DomainMigrationConfigResponse", domain_migration),
|
||||
("ExperimentDeliveryConfigResponse", delivery),
|
||||
] {
|
||||
for (field, value) in value.as_object().expect("config object") {
|
||||
@@ -1119,29 +1127,6 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn screen_share_delivery_update_preserves_empty_lists_and_omitted_fields() {
|
||||
let update = ScreenShareDeliveryConfigUpdateRequest {
|
||||
included_user_ids: Some(Vec::new()),
|
||||
excluded_user_ids: Some(Vec::new()),
|
||||
..Default::default()
|
||||
};
|
||||
let value = serde_json::to_value(update).expect("serializable update");
|
||||
serde_json::from_value::<generated_types::ScreenShareDeliveryConfigUpdateRequest>(
|
||||
value.clone(),
|
||||
)
|
||||
.expect("generated update contract");
|
||||
assert_eq!(
|
||||
value,
|
||||
json!({"included_user_ids": [], "excluded_user_ids": []})
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::to_value(ScreenShareDeliveryConfigUpdateRequest::default())
|
||||
.expect("serializable update"),
|
||||
json!({})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn noise_suppression_update_preserves_empty_lists_and_omitted_fields() {
|
||||
let update = VoiceNoiseSuppressionConfigUpdateRequest {
|
||||
@@ -1166,4 +1151,27 @@ mod tests {
|
||||
json!({})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn domain_migration_update_preserves_empty_lists_and_omitted_fields() {
|
||||
let update = DomainMigrationConfigUpdateRequest {
|
||||
included_user_ids: Some(Vec::new()),
|
||||
excluded_user_ids: Some(Vec::new()),
|
||||
..Default::default()
|
||||
};
|
||||
let value = serde_json::to_value(update).expect("serializable update");
|
||||
serde_json::from_value::<generated_types::DomainMigrationConfigUpdateRequest>(
|
||||
value.clone(),
|
||||
)
|
||||
.expect("generated update contract");
|
||||
assert_eq!(
|
||||
value,
|
||||
json!({"included_user_ids": [], "excluded_user_ids": []})
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::to_value(DomainMigrationConfigUpdateRequest::default())
|
||||
.expect("serializable update"),
|
||||
json!({})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,19 +7,19 @@ use crate::{
|
||||
AppBrandingConfigUpdateRequest, AppLegalConfigUpdateRequest,
|
||||
AppPublicConfigUpdateRequest, AppRegistrationConfigUpdateRequest,
|
||||
AppSetupConfigUpdateRequest, CreateRegistrationUrlRequest,
|
||||
DeferredPhoneGateUpdateRequest, EXPERIMENT_MAX_TARGETED_USERS,
|
||||
ExperimentDeliveryConfigUpdateRequest, GatewayRolloutConfigUpdateRequest,
|
||||
GatewayRolloutMode, InstanceAttachmentDecayUpdateRequest,
|
||||
InstanceBlueskyIntegrationUpdateRequest, InstanceBlueskyKeyIntegrationUpdateRequest,
|
||||
InstanceCaptchaIntegrationUpdateRequest, InstanceConfigUpdateRequest,
|
||||
InstanceEmailIntegrationUpdateRequest, InstanceEmailSmtpIntegrationUpdateRequest,
|
||||
InstanceEmailSmtpTestRequest, InstanceGifIntegrationUpdateRequest,
|
||||
InstanceIntegrationsUpdateRequest, InstanceMediaUpdateRequest,
|
||||
InstancePolicyUpdateRequest, InstanceRegistrationConfigUpdateRequest,
|
||||
InstanceServicesUpdateRequest, InstanceYoutubeIntegrationUpdateRequest,
|
||||
LimitConfigUpdateRequest, LimitRule, LimitRuleFilters, NoiseSuppressionBackend,
|
||||
PremiumMode, PushServiceDeliveryConfigUpdateRequest, RegistrationMode,
|
||||
ScreenShareDeliveryConfigUpdateRequest, SsoConfigUpdateRequest,
|
||||
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,
|
||||
},
|
||||
@@ -208,11 +208,11 @@ pub async fn instance_config_post(
|
||||
Ok(update) => instance_config_result(client.update_instance_config(&update).await),
|
||||
Err(message) => FlashData::error(message),
|
||||
},
|
||||
"update_screen_share_delivery" => match build_screen_share_delivery_update(&form) {
|
||||
"update_push_service_delivery" => match build_push_service_delivery_update(&form) {
|
||||
Ok(update) => instance_config_result(client.update_instance_config(&update).await),
|
||||
Err(message) => FlashData::error(message),
|
||||
},
|
||||
"update_push_service_delivery" => match build_push_service_delivery_update(&form) {
|
||||
"update_domain_migration" => match build_domain_migration_update(&form) {
|
||||
Ok(update) => instance_config_result(client.update_instance_config(&update).await),
|
||||
Err(message) => FlashData::error(message),
|
||||
},
|
||||
@@ -501,7 +501,7 @@ fn parse_experiment_rollout_salt(
|
||||
Ok(Some(salt.to_owned()))
|
||||
}
|
||||
|
||||
fn parse_push_service_delivery_rollout_salt(
|
||||
fn parse_ascii_experiment_rollout_salt(
|
||||
form: &MultiValueForm,
|
||||
key: &str,
|
||||
) -> Result<Option<String>, String> {
|
||||
@@ -653,38 +653,6 @@ fn build_voice_noise_suppression_update(
|
||||
})
|
||||
}
|
||||
|
||||
fn build_screen_share_delivery_update(
|
||||
form: &MultiValueForm,
|
||||
) -> Result<InstanceConfigUpdateRequest, String> {
|
||||
Ok(InstanceConfigUpdateRequest {
|
||||
screen_share_delivery: Some(ScreenShareDeliveryConfigUpdateRequest {
|
||||
enabled: Some(form.bool_value("screen_share_delivery_enabled")),
|
||||
rollout_basis_points: parse_form_number(
|
||||
form,
|
||||
"screen_share_delivery_rollout_basis_points",
|
||||
"Rollout basis points",
|
||||
0,
|
||||
EXPERIMENT_ROLLOUT_BASIS_POINTS_MAX,
|
||||
)?,
|
||||
rollout_salt: parse_experiment_rollout_salt(
|
||||
form,
|
||||
"screen_share_delivery_rollout_salt",
|
||||
)?,
|
||||
included_user_ids: Some(parse_experiment_user_ids(
|
||||
form.first("screen_share_delivery_included_user_ids")
|
||||
.unwrap_or_default(),
|
||||
"Included user IDs",
|
||||
)?),
|
||||
excluded_user_ids: Some(parse_experiment_user_ids(
|
||||
form.first("screen_share_delivery_excluded_user_ids")
|
||||
.unwrap_or_default(),
|
||||
"Excluded user IDs",
|
||||
)?),
|
||||
}),
|
||||
..Default::default()
|
||||
})
|
||||
}
|
||||
|
||||
fn build_push_service_delivery_update(
|
||||
form: &MultiValueForm,
|
||||
) -> Result<InstanceConfigUpdateRequest, String> {
|
||||
@@ -698,7 +666,7 @@ fn build_push_service_delivery_update(
|
||||
0,
|
||||
EXPERIMENT_ROLLOUT_BASIS_POINTS_MAX,
|
||||
)?,
|
||||
rollout_salt: parse_push_service_delivery_rollout_salt(
|
||||
rollout_salt: parse_ascii_experiment_rollout_salt(
|
||||
form,
|
||||
"push_service_delivery_rollout_salt",
|
||||
)?,
|
||||
@@ -717,6 +685,46 @@ fn build_push_service_delivery_update(
|
||||
})
|
||||
}
|
||||
|
||||
fn build_domain_migration_update(
|
||||
form: &MultiValueForm,
|
||||
) -> Result<InstanceConfigUpdateRequest, String> {
|
||||
Ok(InstanceConfigUpdateRequest {
|
||||
domain_migration: Some(DomainMigrationConfigUpdateRequest {
|
||||
enabled: Some(form.bool_value("domain_migration_enabled")),
|
||||
rollout_basis_points: parse_form_number(
|
||||
form,
|
||||
"domain_migration_rollout_basis_points",
|
||||
"Rollout basis points",
|
||||
0,
|
||||
EXPERIMENT_ROLLOUT_BASIS_POINTS_MAX,
|
||||
)?,
|
||||
rollout_salt: parse_ascii_experiment_rollout_salt(
|
||||
form,
|
||||
"domain_migration_rollout_salt",
|
||||
)?,
|
||||
included_user_ids: Some(parse_experiment_user_ids(
|
||||
form.first("domain_migration_included_user_ids")
|
||||
.unwrap_or_default(),
|
||||
"Included user IDs",
|
||||
)?),
|
||||
excluded_user_ids: Some(parse_experiment_user_ids(
|
||||
form.first("domain_migration_excluded_user_ids")
|
||||
.unwrap_or_default(),
|
||||
"Excluded user IDs",
|
||||
)?),
|
||||
anonymous_rollout_basis_points: parse_form_number(
|
||||
form,
|
||||
"domain_migration_anonymous_rollout_basis_points",
|
||||
"Anonymous rollout basis points",
|
||||
0,
|
||||
EXPERIMENT_ROLLOUT_BASIS_POINTS_MAX,
|
||||
)?,
|
||||
standalone_forwarding: Some(form.bool_value("domain_migration_standalone_forwarding")),
|
||||
}),
|
||||
..Default::default()
|
||||
})
|
||||
}
|
||||
|
||||
fn build_experiment_delivery_update(
|
||||
form: &MultiValueForm,
|
||||
) -> Result<InstanceConfigUpdateRequest, String> {
|
||||
@@ -1639,20 +1647,17 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_screen_share_delivery_update_reads_the_rollout_fields() {
|
||||
fn build_domain_migration_update_reads_the_rollout_fields() {
|
||||
let form = MultiValueForm::parse(
|
||||
b"screen_share_delivery_enabled=true&screen_share_delivery_rollout_basis_points=%20250%20&screen_share_delivery_rollout_salt=%20screen-share-delivery-v2%20&screen_share_delivery_included_user_ids=1500000000000000001%0A1500000000000000002&screen_share_delivery_excluded_user_ids=1500000000000000003%2C%201500000000000000004",
|
||||
b"domain_migration_enabled=true&domain_migration_rollout_basis_points=%20250%20&domain_migration_rollout_salt=%20domain-migration-v2%20&domain_migration_included_user_ids=1500000000000000001%0A1500000000000000002&domain_migration_excluded_user_ids=1500000000000000003%2C%201500000000000000004&domain_migration_anonymous_rollout_basis_points=%20100%20&domain_migration_standalone_forwarding=true",
|
||||
);
|
||||
let update = build_screen_share_delivery_update(&form)
|
||||
let update = build_domain_migration_update(&form)
|
||||
.expect("valid form")
|
||||
.screen_share_delivery
|
||||
.expect("screen share delivery update");
|
||||
.domain_migration
|
||||
.expect("domain migration update");
|
||||
assert_eq!(update.enabled, Some(true));
|
||||
assert_eq!(update.rollout_basis_points, Some(250));
|
||||
assert_eq!(
|
||||
update.rollout_salt,
|
||||
Some("screen-share-delivery-v2".to_owned())
|
||||
);
|
||||
assert_eq!(update.rollout_salt, Some("domain-migration-v2".to_owned()));
|
||||
assert_eq!(
|
||||
update.included_user_ids,
|
||||
Some(vec![
|
||||
@@ -1667,49 +1672,60 @@ mod tests {
|
||||
"1500000000000000004".to_owned()
|
||||
])
|
||||
);
|
||||
assert_eq!(update.anonymous_rollout_basis_points, Some(100));
|
||||
assert_eq!(update.standalone_forwarding, Some(true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_screen_share_delivery_update_leaves_the_feature_inert_when_nothing_is_submitted() {
|
||||
fn build_domain_migration_update_leaves_the_feature_inert_when_nothing_is_submitted() {
|
||||
let form = MultiValueForm::parse(b"_csrf=token");
|
||||
let request = build_screen_share_delivery_update(&form).expect("valid form");
|
||||
let request = build_domain_migration_update(&form).expect("valid form");
|
||||
assert_eq!(
|
||||
serde_json::to_value(request).expect("serializable update"),
|
||||
serde_json::json!({"screen_share_delivery": {
|
||||
serde_json::json!({"domain_migration": {
|
||||
"enabled": false,
|
||||
"included_user_ids": [],
|
||||
"excluded_user_ids": [],
|
||||
"standalone_forwarding": false,
|
||||
}})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_screen_share_delivery_update_rejects_invalid_rollout_fields() {
|
||||
fn build_domain_migration_update_rejects_invalid_rollout_fields() {
|
||||
for (form, message) in [
|
||||
(
|
||||
"screen_share_delivery_rollout_basis_points=10001",
|
||||
"domain_migration_rollout_basis_points=10001",
|
||||
"Rollout basis points must be a whole number between 0 and 10000",
|
||||
),
|
||||
(
|
||||
"screen_share_delivery_rollout_basis_points=abc",
|
||||
"Rollout basis points must be a whole number between 0 and 10000",
|
||||
"domain_migration_anonymous_rollout_basis_points=10001",
|
||||
"Anonymous rollout basis points must be a whole number between 0 and 10000",
|
||||
),
|
||||
(
|
||||
"screen_share_delivery_rollout_salt=%20%20",
|
||||
"domain_migration_anonymous_rollout_basis_points=abc",
|
||||
"Anonymous rollout basis points must be a whole number between 0 and 10000",
|
||||
),
|
||||
(
|
||||
"domain_migration_rollout_salt=%20%20",
|
||||
"Rollout salt must be between 1 and 64 characters",
|
||||
),
|
||||
(
|
||||
"screen_share_delivery_included_user_ids=123%2Cinvalid",
|
||||
"domain_migration_rollout_salt=caf%C3%A9",
|
||||
"Rollout salt must use printable ASCII",
|
||||
),
|
||||
(
|
||||
"domain_migration_included_user_ids=123%2Cinvalid",
|
||||
"Included user IDs entry 2 must contain 1 to 20 decimal digits",
|
||||
),
|
||||
(
|
||||
"screen_share_delivery_excluded_user_ids=123%2Cinvalid",
|
||||
"domain_migration_excluded_user_ids=123%2Cinvalid",
|
||||
"Excluded user IDs entry 2 must contain 1 to 20 decimal digits",
|
||||
),
|
||||
] {
|
||||
let form = MultiValueForm::parse(form.as_bytes());
|
||||
assert_eq!(
|
||||
build_screen_share_delivery_update(&form).expect_err("invalid rollout field"),
|
||||
build_domain_migration_update(&form).expect_err("invalid rollout field"),
|
||||
message
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,13 +2,13 @@
|
||||
|
||||
use crate::{
|
||||
api::types::{
|
||||
AppPublicConfigResponse, EXPERIMENT_MAX_TARGETED_USERS, ExperimentDeliveryConfigResponse,
|
||||
AppPublicConfigResponse, DOMAIN_MIGRATION_DEFAULT_SALT, DomainMigrationConfigResponse,
|
||||
EXPERIMENT_MAX_TARGETED_USERS, ExperimentDeliveryConfigResponse,
|
||||
GatewayRolloutConfigResponse, InstanceConfigResponse, InstanceIntegrationsResponse,
|
||||
InstanceMediaResponse, InstancePolicyResponse, InstanceRegistrationResponse,
|
||||
LimitConfigResponse, NoiseSuppressionBackend, PUSH_SERVICE_DELIVERY_DEFAULT_SALT,
|
||||
PendingRegistrationResponse, PushServiceDeliveryConfigResponse, RegistrationUrlResponse,
|
||||
SCREEN_SHARE_DELIVERY_DEFAULT_SALT, ScreenShareDeliveryConfigResponse, SsoConfigResponse,
|
||||
VOICE_NS_MAX_GUILD_OVERRIDES, VoiceNoiseSuppressionConfigResponse,
|
||||
SsoConfigResponse, VOICE_NS_MAX_GUILD_OVERRIDES, VoiceNoiseSuppressionConfigResponse,
|
||||
},
|
||||
config::AdminConfig,
|
||||
middleware::auth::AuthContext,
|
||||
@@ -149,8 +149,8 @@ pub fn instance_config_page(
|
||||
html! {
|
||||
(gateway_rollout_section(base, csrf_token, &instance_config.gateway_rollout))
|
||||
(voice_noise_suppression_section(base, csrf_token, &instance_config.voice_noise_suppression))
|
||||
(screen_share_delivery_section(base, csrf_token, &instance_config.screen_share_delivery))
|
||||
(push_service_delivery_section(base, csrf_token, &instance_config.push_service_delivery))
|
||||
(domain_migration_section(base, csrf_token, &instance_config.domain_migration))
|
||||
(experiment_delivery_section(base, csrf_token, &instance_config.experiment_delivery))
|
||||
@if let Some(limit_config) = limit_config {
|
||||
(limit_config_section(base, limit_config))
|
||||
@@ -1179,117 +1179,6 @@ fn voice_noise_suppression_section(
|
||||
)
|
||||
}
|
||||
|
||||
fn screen_share_delivery_section(
|
||||
base: &str,
|
||||
csrf_token: &str,
|
||||
screen_share_delivery: &ScreenShareDeliveryConfigResponse,
|
||||
) -> Markup {
|
||||
let status = if screen_share_delivery.enabled {
|
||||
("Live", BadgeVariant::Success)
|
||||
} else {
|
||||
("Inert", BadgeVariant::Default)
|
||||
};
|
||||
let included_user_ids = screen_share_delivery.included_user_ids.join("\n");
|
||||
let excluded_user_ids = screen_share_delivery.excluded_user_ids.join("\n");
|
||||
section_card_with_description(
|
||||
"Screen Share Delivery",
|
||||
"Pick how many clients publish screen shares through the reworked delivery path. While \
|
||||
the master switch below is off nothing on this form reaches any client: every user \
|
||||
keeps the screen share pipeline they have today, whatever the rest of these fields say. \
|
||||
A client that is already sharing keeps the path it started on until the share ends.",
|
||||
html! {
|
||||
form method="post" action={(base) "/instance-config?action=update_screen_share_delivery"} {
|
||||
(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 " (screen_share_delivery.config_version)
|
||||
}
|
||||
}
|
||||
(checkbox(
|
||||
"screen_share_delivery_enabled",
|
||||
"true",
|
||||
"Serve screen share delivery assignments to clients",
|
||||
screen_share_delivery.enabled,
|
||||
true,
|
||||
))
|
||||
p class="text-xs text-neutral-500" {
|
||||
"Off is the safe state. With this unchecked every client is told the \
|
||||
feature is inert and keeps its current behavior, so the rollout and \
|
||||
targeting fields below have no effect at all."
|
||||
}
|
||||
|
||||
h3 class="text-sm font-semibold text-neutral-900" { "Rollout" }
|
||||
(number_field(
|
||||
"screen_share_delivery_rollout_basis_points",
|
||||
"Rollout (basis points)",
|
||||
&screen_share_delivery.rollout_basis_points.to_string(),
|
||||
Some(0), Some(10000), "1",
|
||||
Some("Share of users bucketed into the canary, in basis points: 0 is nobody, 100 is 1%, 10000 is everybody."),
|
||||
))
|
||||
div class="flex flex-col gap-2" {
|
||||
(text_input(
|
||||
"screen_share_delivery_rollout_salt",
|
||||
"Rollout Salt",
|
||||
&screen_share_delivery.rollout_salt,
|
||||
SCREEN_SHARE_DELIVERY_DEFAULT_SALT,
|
||||
))
|
||||
p class="text-xs text-neutral-500" {
|
||||
"Seeds the bucketing hash. Changing it reshuffles which users fall \
|
||||
inside the percentage above. Leave it alone to keep the current \
|
||||
cohort stable."
|
||||
}
|
||||
}
|
||||
div class="flex flex-col gap-2" {
|
||||
(textarea_input(
|
||||
"screen_share_delivery_included_user_ids",
|
||||
"Always-on User IDs",
|
||||
"1500000000000000001\n1500000000000000002",
|
||||
&included_user_ids,
|
||||
4,
|
||||
false,
|
||||
))
|
||||
(entry_count_hint(
|
||||
screen_share_delivery.included_user_ids.len(),
|
||||
EXPERIMENT_MAX_TARGETED_USERS,
|
||||
))
|
||||
p class="text-xs text-neutral-500" {
|
||||
"One snowflake per line, or comma separated. These users are targeted \
|
||||
regardless of the percentage above. IDs must contain 1 to 20 decimal \
|
||||
digits. Invalid entries prevent the save. Blank entries and duplicate \
|
||||
IDs are ignored."
|
||||
}
|
||||
}
|
||||
div class="flex flex-col gap-2" {
|
||||
(textarea_input(
|
||||
"screen_share_delivery_excluded_user_ids",
|
||||
"Never-on User IDs",
|
||||
"1500000000000000003\n1500000000000000004",
|
||||
&excluded_user_ids,
|
||||
4,
|
||||
false,
|
||||
))
|
||||
(entry_count_hint(
|
||||
screen_share_delivery.excluded_user_ids.len(),
|
||||
EXPERIMENT_MAX_TARGETED_USERS,
|
||||
))
|
||||
p class="text-xs text-neutral-500" {
|
||||
"Same format. Exclusion wins over both the always-on list and the \
|
||||
percentage. This is the per-user kill switch."
|
||||
}
|
||||
}
|
||||
|
||||
(form_actions(html! {
|
||||
(submit_button("Save Screen Share Delivery Configuration"))
|
||||
}))
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
fn push_service_delivery_section(
|
||||
base: &str,
|
||||
csrf_token: &str,
|
||||
@@ -1399,6 +1288,139 @@ fn push_service_delivery_section(
|
||||
)
|
||||
}
|
||||
|
||||
fn domain_migration_section(
|
||||
base: &str,
|
||||
csrf_token: &str,
|
||||
domain_migration: &DomainMigrationConfigResponse,
|
||||
) -> Markup {
|
||||
let status = if domain_migration.enabled {
|
||||
("Live", BadgeVariant::Success)
|
||||
} else {
|
||||
("Inert", BadgeVariant::Default)
|
||||
};
|
||||
let included_user_ids = domain_migration.included_user_ids.join("\n");
|
||||
let excluded_user_ids = domain_migration.excluded_user_ids.join("\n");
|
||||
section_card_with_description(
|
||||
"Domain Migration",
|
||||
"Moves web clients of the official instance from the legacy web app origin to the new \
|
||||
one. Selected accounts copy their local data across and continue on the new origin. \
|
||||
Clients of other instances read this configuration and ignore it.",
|
||||
html! {
|
||||
form method="post" action={(base) "/instance-config?action=update_domain_migration"} {
|
||||
(csrf_input(csrf_token))
|
||||
div class="space-y-6" {
|
||||
div class="flex flex-wrap items-center gap-2" {
|
||||
h3 class="text-sm font-semibold text-neutral-900" { "Master switch" }
|
||||
(badge(status.0, status.1))
|
||||
span class="text-xs text-neutral-500" {
|
||||
"Config version " (domain_migration.config_version)
|
||||
}
|
||||
}
|
||||
(checkbox(
|
||||
"domain_migration_enabled",
|
||||
"true",
|
||||
"Move selected web clients to the new origin",
|
||||
domain_migration.enabled,
|
||||
true,
|
||||
))
|
||||
p class="text-xs text-neutral-500" {
|
||||
"Off is the safe state and the kill switch. With this unchecked no client \
|
||||
starts a migration and clients that already migrated stop forwarding the \
|
||||
legacy origin, so the rollout and targeting fields below have no effect at all."
|
||||
}
|
||||
|
||||
h3 class="text-sm font-semibold text-neutral-900" { "Installed apps" }
|
||||
(checkbox(
|
||||
"domain_migration_standalone_forwarding",
|
||||
"true",
|
||||
"Forward installed desktop web apps to the new origin",
|
||||
domain_migration.standalone_forwarding,
|
||||
true,
|
||||
))
|
||||
p class="text-xs text-neutral-500" {
|
||||
"Leave this off until the manifest scope extension and the association file \
|
||||
are live and verified. While it is off, installed Chromium desktop apps copy \
|
||||
their data across but stay on the legacy origin and offer to install the new \
|
||||
app. Installed mobile and Safari apps never forward either way."
|
||||
}
|
||||
|
||||
h3 class="text-sm font-semibold text-neutral-900" { "Rollout" }
|
||||
(number_field(
|
||||
"domain_migration_rollout_basis_points",
|
||||
"Rollout (basis points)",
|
||||
&domain_migration.rollout_basis_points.to_string(),
|
||||
Some(0), Some(10000), "1",
|
||||
Some("Share of logged-in users bucketed into the migration, in basis points: 0 is nobody, 100 is 1%, 10000 is everybody."),
|
||||
))
|
||||
(number_field(
|
||||
"domain_migration_anonymous_rollout_basis_points",
|
||||
"Anonymous rollout (basis points)",
|
||||
&domain_migration.anonymous_rollout_basis_points.to_string(),
|
||||
Some(0), Some(10000), "1",
|
||||
Some("Share of logged-out devices sent to the new origin, in basis points. Each device is bucketed on its own random ID."),
|
||||
))
|
||||
div class="flex flex-col gap-2" {
|
||||
(text_input(
|
||||
"domain_migration_rollout_salt",
|
||||
"Rollout Salt",
|
||||
&domain_migration.rollout_salt,
|
||||
DOMAIN_MIGRATION_DEFAULT_SALT,
|
||||
))
|
||||
p class="text-xs text-neutral-500" {
|
||||
"Seeds the bucketing hash for users and devices. Changing it reshuffles \
|
||||
which users and devices fall inside the percentages above. Leave it \
|
||||
alone to keep the current cohort stable."
|
||||
}
|
||||
}
|
||||
div class="flex flex-col gap-2" {
|
||||
(textarea_input(
|
||||
"domain_migration_included_user_ids",
|
||||
"Always-on User IDs",
|
||||
"1500000000000000001\n1500000000000000002",
|
||||
&included_user_ids,
|
||||
4,
|
||||
false,
|
||||
))
|
||||
(entry_count_hint(
|
||||
domain_migration.included_user_ids.len(),
|
||||
EXPERIMENT_MAX_TARGETED_USERS,
|
||||
))
|
||||
p class="text-xs text-neutral-500" {
|
||||
"One snowflake per line, or comma separated. These users are targeted \
|
||||
regardless of the percentage above. IDs must contain 1 to 20 decimal \
|
||||
digits. Invalid entries prevent the save. Blank entries and duplicate \
|
||||
IDs are ignored."
|
||||
}
|
||||
}
|
||||
div class="flex flex-col gap-2" {
|
||||
(textarea_input(
|
||||
"domain_migration_excluded_user_ids",
|
||||
"Never-on User IDs",
|
||||
"1500000000000000003\n1500000000000000004",
|
||||
&excluded_user_ids,
|
||||
4,
|
||||
false,
|
||||
))
|
||||
(entry_count_hint(
|
||||
domain_migration.excluded_user_ids.len(),
|
||||
EXPERIMENT_MAX_TARGETED_USERS,
|
||||
))
|
||||
p class="text-xs text-neutral-500" {
|
||||
"Same format. Exclusion wins over both the always-on list and the \
|
||||
percentage. It stops new migrations only. A user who already moved \
|
||||
stays on the new origin."
|
||||
}
|
||||
}
|
||||
|
||||
(form_actions(html! {
|
||||
(submit_button("Save Domain Migration Configuration"))
|
||||
}))
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
fn experiment_delivery_section(
|
||||
base: &str,
|
||||
csrf_token: &str,
|
||||
@@ -2043,19 +2065,22 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn screen_share_delivery_section_shows_list_counts_and_the_master_switch() {
|
||||
let screen_share_delivery = ScreenShareDeliveryConfigResponse {
|
||||
fn domain_migration_section_shows_both_rollouts_and_list_counts() {
|
||||
let domain_migration = DomainMigrationConfigResponse {
|
||||
anonymous_rollout_basis_points: 250,
|
||||
included_user_ids: vec!["1500000000000000001".to_owned()],
|
||||
excluded_user_ids: vec![
|
||||
"1500000000000000002".to_owned(),
|
||||
"1500000000000000003".to_owned(),
|
||||
],
|
||||
..ScreenShareDeliveryConfigResponse::default()
|
||||
..DomainMigrationConfigResponse::default()
|
||||
};
|
||||
let markup =
|
||||
screen_share_delivery_section("/admin", "csrf", &screen_share_delivery).into_string();
|
||||
assert!(markup.contains("action=update_screen_share_delivery"));
|
||||
assert!(markup.contains("screen_share_delivery_enabled"));
|
||||
let markup = domain_migration_section("/admin", "csrf", &domain_migration).into_string();
|
||||
assert!(markup.contains("action=update_domain_migration"));
|
||||
assert!(markup.contains("domain_migration_enabled"));
|
||||
assert!(markup.contains("name=\"domain_migration_anonymous_rollout_basis_points\""));
|
||||
assert!(markup.contains("value=\"250\""));
|
||||
assert!(markup.contains("name=\"domain_migration_standalone_forwarding\""));
|
||||
assert!(markup.contains("1 of 1000 stored"));
|
||||
assert!(markup.contains("2 of 1000 stored"));
|
||||
assert!(!markup.contains("at the cap"));
|
||||
|
||||
@@ -114,7 +114,10 @@ pub fn users_list_page(
|
||||
let content = html! {
|
||||
div class="space-y-6" {
|
||||
(page_header("Users", None))
|
||||
div class="rounded-lg bg-white transition-all border border-neutral-200 p-4" {
|
||||
div class="rounded-lg bg-white transition-all border border-neutral-200 p-3" {
|
||||
p class="mb-1 text-xs text-neutral-500" {
|
||||
"For example, type " span class="font-mono" { "*" } " in to search for all users."
|
||||
}
|
||||
(search_form(base, params))
|
||||
}
|
||||
(results_markup)
|
||||
|
||||
@@ -409,15 +409,6 @@ fn deserialize_instance_config_response_with_unknown_keys() {
|
||||
"future_object_knob": {"nested": true},
|
||||
"future_list_knob": ["a", "b"]
|
||||
},
|
||||
"screen_share_delivery": {
|
||||
"enabled": true,
|
||||
"config_version": 2,
|
||||
"rollout_basis_points": 2500,
|
||||
"rollout_salt": "screen-share-delivery-v1",
|
||||
"included_user_ids": ["1500000000000000001"],
|
||||
"future_delivery_knob": 9,
|
||||
"excluded_user_ids": []
|
||||
},
|
||||
"push_service_delivery": {
|
||||
"enabled": true,
|
||||
"config_version": 3,
|
||||
@@ -426,6 +417,17 @@ fn deserialize_instance_config_response_with_unknown_keys() {
|
||||
"included_user_ids": ["1500000000000000002"],
|
||||
"excluded_user_ids": []
|
||||
},
|
||||
"domain_migration": {
|
||||
"enabled": true,
|
||||
"config_version": 2,
|
||||
"rollout_basis_points": 2500,
|
||||
"rollout_salt": "domain-migration-v1",
|
||||
"included_user_ids": ["1500000000000000001"],
|
||||
"excluded_user_ids": [],
|
||||
"future_migration_knob": 9,
|
||||
"anonymous_rollout_basis_points": 100,
|
||||
"standalone_forwarding": true
|
||||
},
|
||||
"experiment_delivery": {"poll_interval_seconds": 300, "poll_jitter_percent": 15},
|
||||
"registration": {
|
||||
"mode": "open",
|
||||
@@ -555,14 +557,13 @@ fn deserialize_instance_config_response_with_unknown_keys() {
|
||||
assert_eq!(resp.voice_noise_suppression.rollout_basis_points, 10000);
|
||||
assert_eq!(*resp.voice_noise_suppression.rollout_salt, "voice-ns-v1");
|
||||
assert_eq!(resp.voice_noise_suppression.enabled_backends.len(), 3);
|
||||
assert!(resp.screen_share_delivery.enabled);
|
||||
assert_eq!(resp.screen_share_delivery.config_version, 2);
|
||||
assert_eq!(resp.screen_share_delivery.rollout_basis_points, 2500);
|
||||
assert_eq!(
|
||||
*resp.screen_share_delivery.rollout_salt,
|
||||
"screen-share-delivery-v1"
|
||||
);
|
||||
assert_eq!(resp.screen_share_delivery.included_user_ids.len(), 1);
|
||||
assert!(resp.domain_migration.enabled);
|
||||
assert_eq!(resp.domain_migration.config_version, 2);
|
||||
assert_eq!(resp.domain_migration.rollout_basis_points, 2500);
|
||||
assert_eq!(*resp.domain_migration.rollout_salt, "domain-migration-v1");
|
||||
assert_eq!(resp.domain_migration.included_user_ids.len(), 1);
|
||||
assert_eq!(resp.domain_migration.anonymous_rollout_basis_points, 100);
|
||||
assert!(resp.domain_migration.standalone_forwarding);
|
||||
assert_eq!(resp.experiment_delivery.poll_interval_seconds, 300);
|
||||
assert!(resp.policy.single_community_guild_id.is_none());
|
||||
assert_eq!(resp.policy.services.gif_enabled, Some(true));
|
||||
@@ -573,7 +574,7 @@ fn deserialize_instance_config_response_with_unknown_keys() {
|
||||
.replace("\"future_rollout_knob\": 3,", "")
|
||||
.replace("\"future_presentation_knob\": \"verbose\",", "")
|
||||
.replace("\"future_knob\": 7,", "")
|
||||
.replace("\"future_delivery_knob\": 9,", "")
|
||||
.replace("\"future_migration_knob\": 9,", "")
|
||||
.replace("\"future_object_knob\": {\"nested\": true},", "")
|
||||
.replace("\"future_list_knob\": [\"a\", \"b\"],", "")
|
||||
.replace(
|
||||
|
||||
@@ -465,7 +465,7 @@ async fn mutating_admin_pages_render_usable_csrf_tokens() {
|
||||
"/instance-config?action=update_gateway_rollout",
|
||||
"/instance-config?action=update_sso",
|
||||
"/instance-config?action=update_voice_noise_suppression",
|
||||
"/instance-config?action=update_screen_share_delivery",
|
||||
"/instance-config?action=update_domain_migration",
|
||||
"/instance-config?action=update_experiment_delivery",
|
||||
][..],
|
||||
),
|
||||
@@ -1200,13 +1200,15 @@ fn instance_config() -> Value {
|
||||
"guild_overrides": [],
|
||||
"suppression_strength": 80
|
||||
},
|
||||
"screen_share_delivery": {
|
||||
"domain_migration": {
|
||||
"enabled": false,
|
||||
"config_version": 0,
|
||||
"rollout_basis_points": 0,
|
||||
"rollout_salt": "screen-share-delivery-v1",
|
||||
"rollout_salt": "domain-migration-v1",
|
||||
"included_user_ids": [],
|
||||
"excluded_user_ids": []
|
||||
"excluded_user_ids": [],
|
||||
"anonymous_rollout_basis_points": 0,
|
||||
"standalone_forwarding": false
|
||||
},
|
||||
"experiment_delivery": {
|
||||
"poll_interval_seconds": 300,
|
||||
|
||||
@@ -45,7 +45,7 @@ export async function createAPIApp(options: CreateAPIAppOptions): Promise<APIApp
|
||||
configureMiddleware(routes, {
|
||||
logger,
|
||||
nodeEnv: config.nodeEnv,
|
||||
corsOrigins: [config.endpoints.webApp, config.endpoints.marketing],
|
||||
corsOrigins: [...config.endpoints.webAppOrigins, config.endpoints.marketing],
|
||||
trustClientIpHeader: config.proxy.trust_client_ip_header,
|
||||
clientIpHeaderName: config.proxy.client_ip_header,
|
||||
maxInflightRequests: config.maxInflightRequests,
|
||||
|
||||
@@ -258,6 +258,7 @@ export function buildAPIConfigFromMaster(master: MasterConfig): APIConfig {
|
||||
apiPublic: master.endpoints.api,
|
||||
apiClient: master.endpoints.api_client,
|
||||
webApp: master.endpoints.app,
|
||||
webAppOrigins: [...new Set([new URL(master.endpoints.app).origin, ...master.services.api.app_origin_aliases])],
|
||||
gateway: master.endpoints.gateway,
|
||||
media: master.endpoints.media,
|
||||
marketing: master.endpoints.marketing,
|
||||
@@ -476,6 +477,10 @@ export function buildAPIConfigFromMaster(master: MasterConfig): APIConfig {
|
||||
},
|
||||
abusePolicy: {
|
||||
inboundPhoneCountryCodes: master.instance.abuse_policy.inbound_phone_country_codes,
|
||||
phoneFlagging: {
|
||||
enabled: master.instance.abuse_policy.phone_flagging.enabled,
|
||||
exemptCountryCodes: master.instance.abuse_policy.phone_flagging.exempt_country_codes,
|
||||
},
|
||||
phoneVerification: {
|
||||
inboundRequiredPrefixes: master.instance.abuse_policy.phone_verification.inbound_required_prefixes,
|
||||
},
|
||||
|
||||
@@ -34,9 +34,9 @@ import {
|
||||
PendingRegistrationActionRequest,
|
||||
RegistrationUrlIdParam,
|
||||
} from '@fluxer/schema/src/domains/admin/AdminSchemas';
|
||||
import {DomainMigrationConfigSchema} from '@fluxer/schema/src/domains/admin/DomainMigrationSchemas';
|
||||
import {GatewayRolloutConfigSchema} from '@fluxer/schema/src/domains/admin/GatewayRolloutSchemas';
|
||||
import {PushServiceDeliveryConfigSchema} from '@fluxer/schema/src/domains/admin/PushServiceDeliverySchemas';
|
||||
import {ScreenShareDeliveryConfigSchema} from '@fluxer/schema/src/domains/admin/ScreenShareDeliverySchemas';
|
||||
import {VoiceNoiseSuppressionConfigSchema} from '@fluxer/schema/src/domains/admin/VoiceNoiseSuppressionSchemas';
|
||||
import {UserIdParam} from '@fluxer/schema/src/domains/common/CommonParamSchemas';
|
||||
import {ExperimentDeliveryConfigSchema} from '@fluxer/schema/src/domains/experiment/ExperimentSchemas';
|
||||
@@ -65,8 +65,8 @@ async function buildInstanceConfigResponse(): Promise<InstanceConfigResponse> {
|
||||
ssoConfig,
|
||||
gatewayRollout,
|
||||
voiceNoiseSuppression,
|
||||
screenShareDelivery,
|
||||
pushServiceDelivery,
|
||||
domainMigration,
|
||||
experimentDelivery,
|
||||
registrationConfig,
|
||||
registrationUrls,
|
||||
@@ -75,8 +75,8 @@ async function buildInstanceConfigResponse(): Promise<InstanceConfigResponse> {
|
||||
instanceConfigRepository.getSsoConfig(),
|
||||
instanceConfigRepository.getGatewayRolloutConfig(),
|
||||
instanceConfigRepository.getVoiceNoiseSuppressionConfig(),
|
||||
instanceConfigRepository.getScreenShareDeliveryConfig(),
|
||||
instanceConfigRepository.getPushServiceDeliveryConfig(),
|
||||
instanceConfigRepository.getDomainMigrationConfig(),
|
||||
instanceConfigRepository.getExperimentDeliveryConfig(),
|
||||
instanceConfigRepository.getRegistrationConfig(),
|
||||
instanceConfigRepository.getRegistrationUrlsForAdmin(),
|
||||
@@ -108,8 +108,8 @@ async function buildInstanceConfigResponse(): Promise<InstanceConfigResponse> {
|
||||
},
|
||||
gateway_rollout: gatewayRollout,
|
||||
voice_noise_suppression: voiceNoiseSuppression,
|
||||
screen_share_delivery: screenShareDelivery,
|
||||
push_service_delivery: pushServiceDelivery,
|
||||
domain_migration: domainMigration,
|
||||
experiment_delivery: experimentDelivery,
|
||||
registration: {
|
||||
...registrationConfig,
|
||||
@@ -273,18 +273,6 @@ export function InstanceConfigAdminController(app: HonoApp) {
|
||||
);
|
||||
}
|
||||
}
|
||||
if (data.screen_share_delivery) {
|
||||
const patch = omitUndefinedFields(data.screen_share_delivery);
|
||||
if (Object.keys(patch).length > 0) {
|
||||
await instanceConfigRepository.updateScreenShareDeliveryConfig((current) =>
|
||||
ScreenShareDeliveryConfigSchema.parse({
|
||||
...current,
|
||||
...patch,
|
||||
config_version: current.config_version + 1,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
if (data.push_service_delivery) {
|
||||
const patch = omitUndefinedFields(data.push_service_delivery);
|
||||
if (Object.keys(patch).length > 0) {
|
||||
@@ -298,6 +286,18 @@ export function InstanceConfigAdminController(app: HonoApp) {
|
||||
await getPushServiceDeliveryConfigPublisher().publish(landed);
|
||||
}
|
||||
}
|
||||
if (data.domain_migration) {
|
||||
const patch = omitUndefinedFields(data.domain_migration);
|
||||
if (Object.keys(patch).length > 0) {
|
||||
await instanceConfigRepository.updateDomainMigrationConfig((current) =>
|
||||
DomainMigrationConfigSchema.parse({
|
||||
...current,
|
||||
...patch,
|
||||
config_version: current.config_version + 1,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
if (data.experiment_delivery) {
|
||||
const patch = data.experiment_delivery;
|
||||
await instanceConfigRepository.updateExperimentDeliveryConfig((current) =>
|
||||
|
||||
@@ -11,6 +11,7 @@ import {Logger} from '@app/api/Logger';
|
||||
import {getGuildSearchService, getUserSearchService} from '@app/api/SearchFactory';
|
||||
import {FeatureTemporarilyDisabledError} from '@fluxer/errors/src/domains/core/FeatureTemporarilyDisabledError';
|
||||
import {InputValidationError} from '@fluxer/errors/src/domains/core/InputValidationError';
|
||||
import type {UserSearchFilters} from '@fluxer/schema/src/contracts/search/SearchDocumentTypes';
|
||||
import type {WorkerJobPayload} from '@pkgs/worker/src/contracts/WorkerTypes';
|
||||
|
||||
interface RefreshSearchIndexJobPayload extends WorkerJobPayload {
|
||||
@@ -130,16 +131,28 @@ export class AdminSearchService {
|
||||
throw new FeatureTemporarilyDisabledError();
|
||||
}
|
||||
const query = data.query?.trim() || '';
|
||||
const isBrowseAll = query === '' || query === '*';
|
||||
const searchFilters: UserSearchFilters = isBrowseAll
|
||||
? {sortBy: 'createdAt', sortOrder: 'asc'}
|
||||
: {sortBy: 'relevance'};
|
||||
const directUserId = /^\d+$/.test(query) ? createUserID(BigInt(query)) : null;
|
||||
const canResolveDirectUser = directUserId !== null && !isSyntheticUserId(directUserId) && data.offset === 0;
|
||||
const [searchResult, directUser] = await Promise.all([
|
||||
userSearchService.search(query, {}, {limit: data.limit, offset: data.offset}),
|
||||
userSearchService.search(query, searchFilters, {limit: data.limit, offset: data.offset}),
|
||||
canResolveDirectUser ? userRepository.findUnique(directUserId).catch(() => null) : Promise.resolve(null),
|
||||
]);
|
||||
const {hits, total} = searchResult;
|
||||
const userIds = hits.map((hit) => createUserID(BigInt(hit.id)));
|
||||
const users = await userRepository.listUsers(userIds);
|
||||
const response = await Promise.all(users.map((user) => mapUserToAdminResponse(user, cacheService, acls)));
|
||||
const usersById = new Map(users.map((user) => [user.id.toString(), user]));
|
||||
const orderedUsers = [];
|
||||
for (const userId of userIds) {
|
||||
const user = usersById.get(userId.toString());
|
||||
if (user) {
|
||||
orderedUsers.push(user);
|
||||
}
|
||||
}
|
||||
const response = await Promise.all(orderedUsers.map((user) => mapUserToAdminResponse(user, cacheService, acls)));
|
||||
if (directUser && data.offset === 0) {
|
||||
const directId = directUser.id.toString();
|
||||
if (!response.some((u) => u.id === directId)) {
|
||||
|
||||
@@ -69,6 +69,20 @@ describe('instance config admin PATCH under concurrent writes', () => {
|
||||
return logs.filter((log) => log.action === 'update_instance_config');
|
||||
}
|
||||
|
||||
it('merges a standalone forwarding patch into the stored domain migration config', async () => {
|
||||
const admin = await createAdmin();
|
||||
await patchConfig(admin, {domain_migration: {enabled: true, rollout_basis_points: 250}}).execute();
|
||||
|
||||
const updated = await patchConfig(admin, {domain_migration: {standalone_forwarding: true}}).execute();
|
||||
|
||||
expect(updated.domain_migration).toMatchObject({
|
||||
enabled: true,
|
||||
rollout_basis_points: 250,
|
||||
standalone_forwarding: true,
|
||||
config_version: 2,
|
||||
});
|
||||
});
|
||||
|
||||
it('answers with a conflict and neither writes, publishes nor audits once every attempt has lost the race', async () => {
|
||||
const publish = spyOnPushDeliveryPublishes();
|
||||
const admin = await createAdmin();
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import {registerAdminControllers} from '@app/api/admin/controllers/index';
|
||||
import {AttachmentController} from '@app/api/attachment/AttachmentController';
|
||||
import {AuthController} from '@app/api/auth/AuthController';
|
||||
import {OriginHandoffController} from '@app/api/auth/OriginHandoffController';
|
||||
import {BlueskyOAuthController} from '@app/api/bluesky/BlueskyOAuthController';
|
||||
import {Config} from '@app/api/Config';
|
||||
import {ChannelController} from '@app/api/channel/ChannelController';
|
||||
@@ -46,6 +47,7 @@ export function registerControllers(routes: HonoApp, config: APIConfig): void {
|
||||
GeolocationController(routes);
|
||||
registerAdminControllers(routes);
|
||||
AuthController(routes);
|
||||
OriginHandoffController(routes);
|
||||
AttachmentController(routes);
|
||||
ChannelController(routes);
|
||||
ConnectionController(routes);
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {configureMiddleware} from '@app/api/app/MiddlewarePipeline';
|
||||
import {Config} from '@app/api/Config';
|
||||
import {setInjectedWorkerService} from '@app/api/middleware/ServiceRegistry';
|
||||
import {NoopLogger} from '@app/api/test/mocks/NoopLogger';
|
||||
import {NoopWorkerService} from '@app/api/test/NoopWorkerService';
|
||||
import type {HonoEnv} from '@app/api/types/HonoEnv';
|
||||
import {AppErrorHandler, AppNotFoundHandler} from '@fluxer/errors/src/domains/core/ErrorHandlers';
|
||||
import {Hono} from 'hono';
|
||||
import {afterEach, beforeAll, beforeEach, describe, expect, it} from 'vitest';
|
||||
|
||||
const CLIENT_IP_HEADER_NAME = 'x-real-ip';
|
||||
|
||||
function createProductionApp(): Hono<HonoEnv> {
|
||||
const routes = new Hono<HonoEnv>({strict: true});
|
||||
configureMiddleware(routes, {
|
||||
logger: new NoopLogger(),
|
||||
nodeEnv: 'production',
|
||||
corsOrigins: ['https://web.fluxer.app'],
|
||||
trustClientIpHeader: true,
|
||||
clientIpHeaderName: CLIENT_IP_HEADER_NAME,
|
||||
maxInflightRequests: 100,
|
||||
torExitBlockingEnabled: false,
|
||||
});
|
||||
routes.onError(AppErrorHandler);
|
||||
routes.notFound(AppNotFoundHandler);
|
||||
routes.post('/internal/rpc', (ctx) => ctx.json({ok: true}));
|
||||
routes.get('/connections/bluesky/jwks.json', (ctx) => ctx.json({keys: []}));
|
||||
routes.get('/users/@me', (ctx) => ctx.json({ok: true}));
|
||||
const app = new Hono<HonoEnv>({strict: true});
|
||||
app.route('/v1', routes);
|
||||
app.route('/', routes);
|
||||
app.onError(AppErrorHandler);
|
||||
app.notFound(AppNotFoundHandler);
|
||||
return app;
|
||||
}
|
||||
|
||||
describe('client ip requirements across the production middleware pipeline', () => {
|
||||
let previousTestModeEnabled: boolean;
|
||||
let previousTrustClientIpHeader: boolean;
|
||||
let previousClientIpHeader: string;
|
||||
|
||||
beforeAll(() => {
|
||||
setInjectedWorkerService(new NoopWorkerService());
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
previousTestModeEnabled = Config.dev.testModeEnabled;
|
||||
previousTrustClientIpHeader = Config.proxy.trust_client_ip_header;
|
||||
previousClientIpHeader = Config.proxy.client_ip_header;
|
||||
Config.dev.testModeEnabled = false;
|
||||
Config.proxy.trust_client_ip_header = true;
|
||||
Config.proxy.client_ip_header = CLIENT_IP_HEADER_NAME;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
Config.dev.testModeEnabled = previousTestModeEnabled;
|
||||
Config.proxy.trust_client_ip_header = previousTrustClientIpHeader;
|
||||
Config.proxy.client_ip_header = previousClientIpHeader;
|
||||
});
|
||||
|
||||
it('serves the internal rpc route without a client ip header', async () => {
|
||||
const app = createProductionApp();
|
||||
const response = await app.request('http://api:8080/internal/rpc', {
|
||||
method: 'POST',
|
||||
headers: {'content-type': 'application/json'},
|
||||
body: '{}',
|
||||
});
|
||||
expect(response.status).toBe(200);
|
||||
});
|
||||
|
||||
it('serves the internal rpc route with a client ip header', async () => {
|
||||
const app = createProductionApp();
|
||||
const response = await app.request('http://api:8080/internal/rpc', {
|
||||
method: 'POST',
|
||||
headers: {'content-type': 'application/json', [CLIENT_IP_HEADER_NAME]: '203.0.113.10'},
|
||||
body: '{}',
|
||||
});
|
||||
expect(response.status).toBe(200);
|
||||
});
|
||||
|
||||
it('serves an exempt public route without a client ip header', async () => {
|
||||
const app = createProductionApp();
|
||||
const response = await app.request('http://api:8080/connections/bluesky/jwks.json');
|
||||
expect(response.status).toBe(200);
|
||||
});
|
||||
|
||||
it('still rejects a non exempt route without a client ip header', async () => {
|
||||
const app = createProductionApp();
|
||||
const response = await app.request('http://api:8080/users/@me');
|
||||
expect(response.status).toBe(403);
|
||||
expect(await response.json()).toMatchObject({code: 'FORBIDDEN'});
|
||||
});
|
||||
});
|
||||
@@ -602,6 +602,7 @@ export function AuthController(app: HonoApp) {
|
||||
data: ctx.req.valid('json'),
|
||||
clientIp,
|
||||
authToken: ctx.get('authToken') ?? undefined,
|
||||
approverOrigin: ctx.req.header('origin'),
|
||||
});
|
||||
return ctx.body(null, 204);
|
||||
},
|
||||
|
||||
@@ -23,7 +23,7 @@ import {profileSubstringBlocklistCache} from '@app/api/middleware/ProfileSubstri
|
||||
import type {RequestCache} from '@app/api/middleware/RequestCacheMiddleware';
|
||||
import type {User} from '@app/api/models/User';
|
||||
import {UserSettings} from '@app/api/models/UserSettings';
|
||||
import {countryRequiresInboundPhoneVerification} from '@app/api/risk/AbusePolicy';
|
||||
import {countryRequiresInboundPhoneVerification, stripDisallowedPhoneFlags} from '@app/api/risk/AbusePolicy';
|
||||
import {
|
||||
type IAccountPolicyEvaluator,
|
||||
isAssessmentThresholdAuditEvent,
|
||||
@@ -362,7 +362,9 @@ export async function register(
|
||||
action: riskResult.recommendedAction,
|
||||
},
|
||||
});
|
||||
const combinedFlags = await deferPhoneFlagsUntilCommunityJoin(policyDecision.flagBits);
|
||||
const combinedFlags = await deferPhoneFlagsUntilCommunityJoin(
|
||||
await stripDisallowedPhoneFlags(policyDecision.flagBits, async () => countryCode),
|
||||
);
|
||||
const createdAt = new Date();
|
||||
const riskContext = deriveLatestRiskContext({
|
||||
userId: userId.toString(),
|
||||
|
||||
@@ -8,12 +8,19 @@ import * as AuthMfa from '@app/api/auth/AuthMfa';
|
||||
import * as AuthPassword from '@app/api/auth/AuthPassword';
|
||||
import * as AuthRegistration from '@app/api/auth/AuthRegistration';
|
||||
import * as AuthSession from '@app/api/auth/AuthSession';
|
||||
import {getTokenIdHash} from '@app/api/auth/AuthUtility';
|
||||
import type {DesktopHandoffService} from '@app/api/auth/services/DesktopHandoffService';
|
||||
import type {SsoService} from '@app/api/auth/services/SsoService';
|
||||
import {createUserID, type UserID} from '@app/api/BrandedTypes';
|
||||
import {Logger} from '@app/api/Logger';
|
||||
import type {RequestCache} from '@app/api/middleware/RequestCacheMiddleware';
|
||||
import {getInstanceConfigRepository} from '@app/api/middleware/ServiceSingletons';
|
||||
import type {User} from '@app/api/models/User';
|
||||
import {
|
||||
classifyWebPushOrigin,
|
||||
encodePushSessionIdHash,
|
||||
recordPushSessionPredecessor,
|
||||
} from '@app/api/user/services/WebPushOriginReplacement';
|
||||
import {mapUserToPartialResponse} from '@app/api/user/UserMappers';
|
||||
import {lookupGeoip} from '@app/api/utils/IpUtils';
|
||||
import {parseJsonRecord} from '@app/api/utils/JsonBoundaryUtils';
|
||||
@@ -91,6 +98,7 @@ interface AuthHandoffCompleteRequest {
|
||||
data: HandoffCompleteRequest;
|
||||
clientIp: string;
|
||||
authToken?: string;
|
||||
approverOrigin?: string | null;
|
||||
}
|
||||
|
||||
interface AuthAuthorizeIpRequest {
|
||||
@@ -305,7 +313,10 @@ export class AuthRequestService {
|
||||
|
||||
async initiateHandoff({request}: AuthHandoffInitiateRequest): Promise<HandoffInitiateResponse> {
|
||||
const origin = AuthSession.resolveSessionOrigin(this.apiContext, request);
|
||||
const result = await this.desktopHandoffService.initiateHandoff({origin});
|
||||
const result = await this.desktopHandoffService.initiateHandoff({
|
||||
origin,
|
||||
initiatorOrigin: request.headers.get('origin'),
|
||||
});
|
||||
return {
|
||||
code: result.code,
|
||||
expires_at: result.expiresAt.toISOString(),
|
||||
@@ -340,21 +351,53 @@ export class AuthRequestService {
|
||||
};
|
||||
}
|
||||
|
||||
async completeHandoff({data, clientIp, authToken}: AuthHandoffCompleteRequest): Promise<void> {
|
||||
async completeHandoff({data, clientIp, authToken, approverOrigin}: AuthHandoffCompleteRequest): Promise<void> {
|
||||
const sessionToken = data.token ?? authToken;
|
||||
if (!sessionToken) {
|
||||
throw new UnauthorizedError();
|
||||
}
|
||||
await this.desktopHandoffService.completeHandoff(
|
||||
let createdToken: string | null = null;
|
||||
const {initiatorOrigin} = await this.desktopHandoffService.completeHandoff(
|
||||
data.code,
|
||||
(origin) =>
|
||||
AuthSession.createAdditionalAuthSessionFromToken(this.apiContext, {
|
||||
async (origin) => {
|
||||
const created = await AuthSession.createAdditionalAuthSessionFromToken(this.apiContext, {
|
||||
token: sessionToken,
|
||||
expectedUserId: data.user_id,
|
||||
origin,
|
||||
}),
|
||||
});
|
||||
createdToken = created.token;
|
||||
return created;
|
||||
},
|
||||
clientIp,
|
||||
);
|
||||
if (createdToken !== null) {
|
||||
await this.recordPushSessionPredecessor(createdToken, sessionToken, initiatorOrigin, approverOrigin);
|
||||
}
|
||||
}
|
||||
|
||||
private async recordPushSessionPredecessor(
|
||||
createdToken: string,
|
||||
approverToken: string,
|
||||
initiatorOrigin: string | null,
|
||||
approverOrigin: string | null | undefined,
|
||||
): Promise<void> {
|
||||
const {config, kv} = this.apiContext.services;
|
||||
const {selfHosted} = config.instance;
|
||||
if (
|
||||
classifyWebPushOrigin(initiatorOrigin, selfHosted) !== 'target' ||
|
||||
classifyWebPushOrigin(approverOrigin, selfHosted) !== 'legacy'
|
||||
) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await recordPushSessionPredecessor(
|
||||
kv,
|
||||
encodePushSessionIdHash(getTokenIdHash(this.apiContext, createdToken)),
|
||||
encodePushSessionIdHash(getTokenIdHash(this.apiContext, approverToken)),
|
||||
);
|
||||
} catch (error) {
|
||||
Logger.warn({error}, 'Failed to record the push session predecessor');
|
||||
}
|
||||
}
|
||||
|
||||
async getHandoffStatus({code, clientIp, pollSecret}: AuthHandoffStatusRequest): Promise<HandoffStatusResponse> {
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {createOriginHandoff, redeemOriginHandoff} from '@app/api/auth/services/OriginHandoffService';
|
||||
import {Config} from '@app/api/Config';
|
||||
import {DefaultUserOnly, LoginRequired} from '@app/api/middleware/AuthMiddleware';
|
||||
import {RateLimitMiddleware} from '@app/api/middleware/RateLimitMiddleware';
|
||||
import {OpenAPI} from '@app/api/middleware/ResponseTypeMiddleware';
|
||||
import {RateLimitConfigs} from '@app/api/RateLimitConfig';
|
||||
import type {HonoApp} from '@app/api/types/HonoEnv';
|
||||
import {Validator} from '@app/api/Validator';
|
||||
import {FileSizeTooLargeError} from '@fluxer/errors/src/domains/core/FileSizeTooLargeError';
|
||||
import {InvalidApiOriginError} from '@fluxer/errors/src/domains/core/InvalidApiOriginError';
|
||||
import {
|
||||
ORIGIN_HANDOFF_MAX_PAYLOAD_LENGTH,
|
||||
OriginHandoffCreateRequest,
|
||||
OriginHandoffCreateResponse,
|
||||
OriginHandoffRedeemRequest,
|
||||
OriginHandoffRedeemResponse,
|
||||
} from '@fluxer/schema/src/domains/auth/OriginHandoffSchemas';
|
||||
import {bodyLimit} from 'hono/body-limit';
|
||||
|
||||
const ORIGIN_HANDOFF_CREATE_MAX_BODY_BYTES = ORIGIN_HANDOFF_MAX_PAYLOAD_LENGTH + 1024;
|
||||
|
||||
export function OriginHandoffController(app: HonoApp) {
|
||||
app.post(
|
||||
'/auth/origin-handoff',
|
||||
RateLimitMiddleware(RateLimitConfigs.AUTH_ORIGIN_HANDOFF_CREATE),
|
||||
LoginRequired,
|
||||
DefaultUserOnly,
|
||||
bodyLimit({
|
||||
maxSize: ORIGIN_HANDOFF_CREATE_MAX_BODY_BYTES,
|
||||
onError: () => {
|
||||
throw new FileSizeTooLargeError(ORIGIN_HANDOFF_CREATE_MAX_BODY_BYTES);
|
||||
},
|
||||
}),
|
||||
Validator('json', OriginHandoffCreateRequest),
|
||||
OpenAPI({
|
||||
operationId: 'create_origin_handoff',
|
||||
summary: 'Create origin handoff',
|
||||
responseSchema: OriginHandoffCreateResponse,
|
||||
statusCode: 200,
|
||||
security: ['sessionToken'],
|
||||
tags: ['Auth'],
|
||||
description:
|
||||
'Store encrypted client state for up to two minutes so another first-party web origin can redeem it once. The receiving origin must present the nonce whose SHA-256 digest is sent here.',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const body = ctx.req.valid('json');
|
||||
const handoffId = await createOriginHandoff(ctx.get('cacheService'), {
|
||||
userId: ctx.get('user').id,
|
||||
nonceHash: body.nonce_hash,
|
||||
payload: body.payload,
|
||||
});
|
||||
const response: OriginHandoffCreateResponse = {handoff_id: handoffId};
|
||||
return ctx.json(response);
|
||||
},
|
||||
);
|
||||
app.post(
|
||||
'/auth/origin-handoff/redeem',
|
||||
RateLimitMiddleware(RateLimitConfigs.AUTH_ORIGIN_HANDOFF_REDEEM),
|
||||
Validator('json', OriginHandoffRedeemRequest),
|
||||
OpenAPI({
|
||||
operationId: 'redeem_origin_handoff',
|
||||
summary: 'Redeem origin handoff',
|
||||
responseSchema: OriginHandoffRedeemResponse,
|
||||
statusCode: 200,
|
||||
security: [],
|
||||
tags: ['Auth'],
|
||||
description:
|
||||
'Return the encrypted client state stored by create origin handoff and delete it in the same step. A wrong nonce also consumes the handoff. On the official instance the request must come from a first-party web origin.',
|
||||
}),
|
||||
async (ctx) => {
|
||||
if (!Config.instance.selfHosted) {
|
||||
const origin = ctx.req.header('origin');
|
||||
if (origin === undefined || !Config.endpoints.webAppOrigins.includes(origin)) {
|
||||
throw new InvalidApiOriginError();
|
||||
}
|
||||
}
|
||||
const body = ctx.req.valid('json');
|
||||
const payload = await redeemOriginHandoff(ctx.get('cacheService'), {
|
||||
handoffId: body.handoff_id,
|
||||
nonce: body.nonce,
|
||||
});
|
||||
const response: OriginHandoffRedeemResponse = {payload};
|
||||
return ctx.json(response);
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -25,6 +25,7 @@ const POLL_SECRET_BYTES = 32;
|
||||
interface HandoffData {
|
||||
createdAt: number;
|
||||
origin: SessionOrigin;
|
||||
initiatorOrigin?: string | null;
|
||||
infoLookupCount: number;
|
||||
pollSecretHash: string;
|
||||
}
|
||||
@@ -84,7 +85,7 @@ function pollSecretMatches(presented: string | undefined, storedHash: string | u
|
||||
export class DesktopHandoffService {
|
||||
constructor(private readonly apiContext: ApiContext) {}
|
||||
|
||||
async initiateHandoff(args: {origin: SessionOrigin}): Promise<{
|
||||
async initiateHandoff(args: {origin: SessionOrigin; initiatorOrigin?: string | null}): Promise<{
|
||||
code: string;
|
||||
expiresAt: Date;
|
||||
pollSecret: string;
|
||||
@@ -95,6 +96,7 @@ export class DesktopHandoffService {
|
||||
const handoffData: HandoffData = {
|
||||
createdAt: Date.now(),
|
||||
origin: args.origin,
|
||||
initiatorOrigin: args.initiatorOrigin ?? null,
|
||||
infoLookupCount: 0,
|
||||
pollSecretHash: hashPollSecret(pollSecret),
|
||||
};
|
||||
@@ -108,7 +110,7 @@ export class DesktopHandoffService {
|
||||
code: string,
|
||||
createTokenData: (origin: SessionOrigin) => Promise<{token: string; userId: string}>,
|
||||
approverIp: string,
|
||||
): Promise<void> {
|
||||
): Promise<{initiatorOrigin: string | null}> {
|
||||
const {cache} = this.apiContext.services;
|
||||
const normalizedCode = requireNormalizedHandoffCode(code);
|
||||
await this.checkAttemptLimit(approverIp);
|
||||
@@ -138,6 +140,7 @@ export class DesktopHandoffService {
|
||||
await cache.set(`${HANDOFF_TOKEN_PREFIX}${normalizedCode}`, tokenData, remainingSeconds);
|
||||
await cache.delete(`${HANDOFF_CODE_PREFIX}${normalizedCode}`);
|
||||
await cache.delete(`${HANDOFF_APPROVER_PREFIX}${normalizedCode}`);
|
||||
return {initiatorOrigin: handoffData.initiatorOrigin ?? null};
|
||||
}
|
||||
|
||||
async getHandoffInfo(
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {createHash, randomBytes, timingSafeEqual} from 'node:crypto';
|
||||
import type {UserID} from '@app/api/BrandedTypes';
|
||||
import {InvalidOriginHandoffNonceError} from '@fluxer/errors/src/domains/auth/InvalidOriginHandoffNonceError';
|
||||
import {UnknownOriginHandoffError} from '@fluxer/errors/src/domains/auth/UnknownOriginHandoffError';
|
||||
import type {ICacheService} from '@pkgs/cache/src/ICacheService';
|
||||
import {seconds} from 'itty-time';
|
||||
|
||||
const ORIGIN_HANDOFF_KEY_PREFIX = 'origin_handoff:';
|
||||
const ORIGIN_HANDOFF_ID_BYTES = 32;
|
||||
|
||||
interface OriginHandoffRecord {
|
||||
nonce_hash: string;
|
||||
payload: string;
|
||||
user_id: string;
|
||||
created_at: number;
|
||||
}
|
||||
|
||||
function sha256Hex(value: string): string {
|
||||
return createHash('sha256').update(value).digest('hex');
|
||||
}
|
||||
|
||||
function originHandoffKey(handoffId: string): string {
|
||||
return `${ORIGIN_HANDOFF_KEY_PREFIX}${sha256Hex(handoffId)}`;
|
||||
}
|
||||
|
||||
export async function createOriginHandoff(
|
||||
cache: ICacheService,
|
||||
args: {userId: UserID; nonceHash: string; payload: string},
|
||||
): Promise<string> {
|
||||
const handoffId = randomBytes(ORIGIN_HANDOFF_ID_BYTES).toString('base64url');
|
||||
const record: OriginHandoffRecord = {
|
||||
nonce_hash: args.nonceHash,
|
||||
payload: args.payload,
|
||||
user_id: args.userId.toString(),
|
||||
created_at: Date.now(),
|
||||
};
|
||||
await cache.set(originHandoffKey(handoffId), record, seconds('2 minutes'));
|
||||
return handoffId;
|
||||
}
|
||||
|
||||
export async function redeemOriginHandoff(
|
||||
cache: ICacheService,
|
||||
args: {handoffId: string; nonce: string},
|
||||
): Promise<string> {
|
||||
const record = await cache.getAndDelete<OriginHandoffRecord>(originHandoffKey(args.handoffId));
|
||||
if (!record) {
|
||||
throw new UnknownOriginHandoffError();
|
||||
}
|
||||
const presented = Buffer.from(sha256Hex(args.nonce), 'hex');
|
||||
const stored = Buffer.from(record.nonce_hash, 'hex');
|
||||
if (presented.length !== stored.length || !timingSafeEqual(presented, stored)) {
|
||||
throw new InvalidOriginHandoffNonceError();
|
||||
}
|
||||
return record.payload;
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
loginAccount,
|
||||
registerUser,
|
||||
} from '@app/api/auth/tests/AuthTestUtils';
|
||||
import {Config} from '@app/api/Config';
|
||||
import {setInjectedRegistrationRiskEvaluator} from '@app/api/middleware/ServiceMiddleware';
|
||||
import {getInstanceConfigRepository} from '@app/api/middleware/ServiceSingletons';
|
||||
import {
|
||||
@@ -33,7 +34,7 @@ import {
|
||||
SuspiciousActivityFlags,
|
||||
} from '@fluxer/constants/src/UserConstants';
|
||||
import type {GuildResponse} from '@fluxer/schema/src/domains/guild/GuildResponseSchemas';
|
||||
import {afterAll, beforeAll, beforeEach, describe, expect, it, vi} from 'vitest';
|
||||
import {afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi} from 'vitest';
|
||||
|
||||
function phoneRiskEvaluator(level: RiskLevelType, riskScore: number): IRegistrationRiskEvaluator {
|
||||
return {
|
||||
@@ -241,6 +242,59 @@ describe('Deferred phone verification gate', () => {
|
||||
expect(flags & SuspiciousActivityFlags.REQUIRE_VERIFIED_PHONE).not.toBe(0);
|
||||
});
|
||||
|
||||
describe('with phone flagging disabled', () => {
|
||||
const originalPhoneFlagging = {...Config.abusePolicy.phoneFlagging};
|
||||
afterEach(() => {
|
||||
Config.abusePolicy.phoneFlagging = originalPhoneFlagging;
|
||||
});
|
||||
|
||||
it('sets no phone requirement and no deferral at registration', async () => {
|
||||
await getInstanceConfigRepository().setInstancePolicyConfig({deferred_phone_gate_enabled: true});
|
||||
Config.abusePolicy.phoneFlagging = {enabled: false, exemptCountryCodes: []};
|
||||
setInjectedRegistrationRiskEvaluator(phoneRiskEvaluator(RiskLevel.High, 70));
|
||||
const registration = await registerUser(harness, {
|
||||
email: createUniqueEmail('flagging-off'),
|
||||
username: createUniqueUsername('flagging_off'),
|
||||
global_name: 'Flagging Off',
|
||||
password: 'StrongPassword!123',
|
||||
date_of_birth: '2000-01-01',
|
||||
consent: true,
|
||||
});
|
||||
const flags = await readFlags(registration.user_id);
|
||||
expect(flags & SuspiciousActivityFlags.REQUIRE_VERIFIED_PHONE).toBe(0);
|
||||
expect(flags & DEFERRED_PHONE_ON_COMMUNITY_JOIN).toBe(0);
|
||||
});
|
||||
|
||||
it('keeps an existing deferral dormant on a qualifying join', async () => {
|
||||
await getInstanceConfigRepository().setInstancePolicyConfig({
|
||||
deferred_phone_gate_enabled: true,
|
||||
deferred_phone_gate_member_threshold: 1,
|
||||
deferred_phone_gate_window_hours: 24,
|
||||
});
|
||||
const {inviteCode} = await createGuildWithInvite(harness);
|
||||
const filler = await createTestAccount(harness);
|
||||
await createBuilder(harness, filler.token).post(`/invites/${inviteCode}`).expect(200).execute();
|
||||
setInjectedRegistrationRiskEvaluator(phoneRiskEvaluator(RiskLevel.High, 70));
|
||||
const registration = await registerUser(harness, {
|
||||
email: createUniqueEmail('flagging-off-join'),
|
||||
username: createUniqueUsername('flagging_off_join'),
|
||||
global_name: 'Flagging Off Join',
|
||||
password: 'StrongPassword!123',
|
||||
date_of_birth: '2000-01-01',
|
||||
consent: true,
|
||||
});
|
||||
setInjectedRegistrationRiskEvaluator(undefined);
|
||||
expect((await readFlags(registration.user_id)) & DEFERRED_PHONE_ON_COMMUNITY_JOIN).not.toBe(0);
|
||||
|
||||
Config.abusePolicy.phoneFlagging = {enabled: false, exemptCountryCodes: []};
|
||||
await createBuilder(harness, registration.token).post(`/invites/${inviteCode}`).expect(200).execute();
|
||||
|
||||
const flags = await readFlags(registration.user_id);
|
||||
expect(flags & DEFERRED_PHONE_ON_COMMUNITY_JOIN).not.toBe(0);
|
||||
expect(flags & PHONE_GATE_PROMOTED_FROM_DEFERRAL).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('phone gate escape', () => {
|
||||
async function configurePhoneGate(
|
||||
overrides: {
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {createHash, randomBytes} from 'node:crypto';
|
||||
import {createAuthHarness, createTestAccount} from '@app/api/auth/tests/AuthTestUtils';
|
||||
import {createTestBotAccount} from '@app/api/bot/tests/BotTestUtils';
|
||||
import {getConfig} from '@app/api/Config';
|
||||
import type {ApiTestHarness} from '@app/api/test/ApiTestHarness';
|
||||
import {HTTP_STATUS} from '@app/api/test/TestConstants';
|
||||
import {createBuilder, createBuilderWithoutAuth} from '@app/api/test/TestRequestBuilder';
|
||||
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
|
||||
import {SuspiciousActivityFlags} from '@fluxer/constants/src/UserConstants';
|
||||
import {
|
||||
ORIGIN_HANDOFF_MAX_PAYLOAD_LENGTH,
|
||||
type OriginHandoffCreateResponse,
|
||||
type OriginHandoffRedeemResponse,
|
||||
} from '@fluxer/schema/src/domains/auth/OriginHandoffSchemas';
|
||||
import {afterAll, afterEach, beforeAll, beforeEach, describe, expect, it} from 'vitest';
|
||||
|
||||
const CREATE_PATH = '/auth/origin-handoff';
|
||||
const REDEEM_PATH = '/auth/origin-handoff/redeem';
|
||||
const PAYLOAD = randomBytes(96).toString('base64url');
|
||||
|
||||
function createNonce(): {nonce: string; nonceHash: string} {
|
||||
const nonce = randomBytes(32).toString('base64url');
|
||||
return {nonce, nonceHash: createHash('sha256').update(nonce).digest('hex')};
|
||||
}
|
||||
|
||||
describe('Origin handoff', () => {
|
||||
let harness: ApiTestHarness;
|
||||
let webAppOrigin: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
harness = await createAuthHarness();
|
||||
webAppOrigin = getConfig().endpoints.webAppOrigins[0];
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await harness.reset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
getConfig().instance.selfHosted = false;
|
||||
getConfig().endpoints.webAppOrigins = [webAppOrigin];
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await harness?.shutdown();
|
||||
});
|
||||
|
||||
async function createHandoff(token: string, nonceHash: string): Promise<string> {
|
||||
const response = await createBuilder<OriginHandoffCreateResponse>(harness, token)
|
||||
.post(CREATE_PATH)
|
||||
.body({nonce_hash: nonceHash, payload: PAYLOAD})
|
||||
.execute();
|
||||
expect(response.handoff_id).toMatch(/^[A-Za-z0-9_-]{43}$/);
|
||||
return response.handoff_id;
|
||||
}
|
||||
|
||||
it('hands the payload over once to the origin that holds the nonce', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const {nonce, nonceHash} = createNonce();
|
||||
const handoffId = await createHandoff(account.token, nonceHash);
|
||||
|
||||
const redeemed = await createBuilderWithoutAuth<OriginHandoffRedeemResponse>(harness)
|
||||
.post(REDEEM_PATH)
|
||||
.header('origin', webAppOrigin)
|
||||
.body({handoff_id: handoffId, nonce})
|
||||
.execute();
|
||||
expect(redeemed).toEqual({payload: PAYLOAD});
|
||||
|
||||
await createBuilderWithoutAuth(harness)
|
||||
.post(REDEEM_PATH)
|
||||
.header('origin', webAppOrigin)
|
||||
.body({handoff_id: handoffId, nonce})
|
||||
.expect(HTTP_STATUS.NOT_FOUND, APIErrorCodes.UNKNOWN_ORIGIN_HANDOFF)
|
||||
.execute();
|
||||
});
|
||||
|
||||
it('consumes the handoff when the nonce does not match', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const {nonce, nonceHash} = createNonce();
|
||||
const handoffId = await createHandoff(account.token, nonceHash);
|
||||
|
||||
await createBuilderWithoutAuth(harness)
|
||||
.post(REDEEM_PATH)
|
||||
.header('origin', webAppOrigin)
|
||||
.body({handoff_id: handoffId, nonce: createNonce().nonce})
|
||||
.expect(HTTP_STATUS.BAD_REQUEST, APIErrorCodes.INVALID_ORIGIN_HANDOFF_NONCE)
|
||||
.execute();
|
||||
|
||||
await createBuilderWithoutAuth(harness)
|
||||
.post(REDEEM_PATH)
|
||||
.header('origin', webAppOrigin)
|
||||
.body({handoff_id: handoffId, nonce})
|
||||
.expect(HTTP_STATUS.NOT_FOUND, APIErrorCodes.UNKNOWN_ORIGIN_HANDOFF)
|
||||
.execute();
|
||||
});
|
||||
|
||||
it('answers an unknown handoff id with its own error code', async () => {
|
||||
await createBuilderWithoutAuth(harness)
|
||||
.post(REDEEM_PATH)
|
||||
.header('origin', webAppOrigin)
|
||||
.body({handoff_id: randomBytes(32).toString('base64url'), nonce: createNonce().nonce})
|
||||
.expect(HTTP_STATUS.NOT_FOUND, APIErrorCodes.UNKNOWN_ORIGIN_HANDOFF)
|
||||
.execute();
|
||||
});
|
||||
|
||||
it('requires a logged-in user to create a handoff', async () => {
|
||||
await createBuilderWithoutAuth(harness)
|
||||
.post(CREATE_PATH)
|
||||
.body({nonce_hash: createNonce().nonceHash, payload: PAYLOAD})
|
||||
.expect(HTTP_STATUS.UNAUTHORIZED)
|
||||
.execute();
|
||||
});
|
||||
|
||||
it('refuses to create a handoff for an account flagged as suspicious', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
await createBuilderWithoutAuth(harness)
|
||||
.post(`/test/users/${account.userId}/security-flags`)
|
||||
.body({suspicious_activity_flags: SuspiciousActivityFlags.REQUIRE_VERIFIED_PHONE})
|
||||
.execute();
|
||||
await createBuilder(harness, account.token)
|
||||
.post(CREATE_PATH)
|
||||
.body({nonce_hash: createNonce().nonceHash, payload: PAYLOAD})
|
||||
.expect(HTTP_STATUS.FORBIDDEN, APIErrorCodes.ACCOUNT_SUSPICIOUS_ACTIVITY)
|
||||
.execute();
|
||||
});
|
||||
|
||||
it('refuses a create body larger than the payload ceiling before parsing it', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
await createBuilder(harness, account.token)
|
||||
.post(CREATE_PATH)
|
||||
.body({nonce_hash: createNonce().nonceHash, payload: 'a'.repeat(ORIGIN_HANDOFF_MAX_PAYLOAD_LENGTH + 2048)})
|
||||
.expect(HTTP_STATUS.BAD_REQUEST, APIErrorCodes.FILE_SIZE_TOO_LARGE)
|
||||
.execute();
|
||||
});
|
||||
|
||||
it('refuses to create a handoff for a bot', async () => {
|
||||
const bot = await createTestBotAccount(harness);
|
||||
await createBuilder(harness, `Bot ${bot.botToken}`)
|
||||
.post(CREATE_PATH)
|
||||
.body({nonce_hash: createNonce().nonceHash, payload: PAYLOAD})
|
||||
.expect(HTTP_STATUS.FORBIDDEN)
|
||||
.execute();
|
||||
});
|
||||
|
||||
it.each([
|
||||
{name: 'an uppercase nonce hash', body: {nonce_hash: 'A'.repeat(64), payload: PAYLOAD}},
|
||||
{name: 'a short nonce hash', body: {nonce_hash: 'a'.repeat(63), payload: PAYLOAD}},
|
||||
{name: 'a payload outside base64url', body: {nonce_hash: 'a'.repeat(64), payload: 'not+base64/url='}},
|
||||
{name: 'an empty payload', body: {nonce_hash: 'a'.repeat(64), payload: ''}},
|
||||
])('rejects $name', async ({body}) => {
|
||||
const account = await createTestAccount(harness);
|
||||
await createBuilder(harness, account.token)
|
||||
.post(CREATE_PATH)
|
||||
.body(body)
|
||||
.expect(HTTP_STATUS.BAD_REQUEST, APIErrorCodes.INVALID_FORM_BODY)
|
||||
.execute();
|
||||
});
|
||||
|
||||
it('refuses a redeem from an origin outside the first-party web origins', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const {nonce, nonceHash} = createNonce();
|
||||
const handoffId = await createHandoff(account.token, nonceHash);
|
||||
|
||||
await createBuilderWithoutAuth(harness)
|
||||
.post(REDEEM_PATH)
|
||||
.header('origin', 'https://evil.example')
|
||||
.body({handoff_id: handoffId, nonce})
|
||||
.expect(HTTP_STATUS.FORBIDDEN, APIErrorCodes.INVALID_API_ORIGIN)
|
||||
.execute();
|
||||
|
||||
await createBuilderWithoutAuth(harness)
|
||||
.post(REDEEM_PATH)
|
||||
.body({handoff_id: handoffId, nonce})
|
||||
.expect(HTTP_STATUS.FORBIDDEN, APIErrorCodes.INVALID_API_ORIGIN)
|
||||
.execute();
|
||||
|
||||
const redeemed = await createBuilderWithoutAuth<OriginHandoffRedeemResponse>(harness)
|
||||
.post(REDEEM_PATH)
|
||||
.header('origin', webAppOrigin)
|
||||
.body({handoff_id: handoffId, nonce})
|
||||
.execute();
|
||||
expect(redeemed.payload).toBe(PAYLOAD);
|
||||
});
|
||||
|
||||
it('accepts a redeem from a configured web app origin alias', async () => {
|
||||
getConfig().endpoints.webAppOrigins = [webAppOrigin, 'https://fluxer.com'];
|
||||
const account = await createTestAccount(harness);
|
||||
const {nonce, nonceHash} = createNonce();
|
||||
const handoffId = await createHandoff(account.token, nonceHash);
|
||||
|
||||
const redeemed = await createBuilderWithoutAuth<OriginHandoffRedeemResponse>(harness)
|
||||
.post(REDEEM_PATH)
|
||||
.header('origin', 'https://fluxer.com')
|
||||
.body({handoff_id: handoffId, nonce})
|
||||
.execute();
|
||||
expect(redeemed.payload).toBe(PAYLOAD);
|
||||
});
|
||||
|
||||
it('skips the origin check on a self-hosted instance', async () => {
|
||||
getConfig().instance.selfHosted = true;
|
||||
const account = await createTestAccount(harness);
|
||||
const {nonce, nonceHash} = createNonce();
|
||||
const handoffId = await createHandoff(account.token, nonceHash);
|
||||
|
||||
const redeemed = await createBuilderWithoutAuth<OriginHandoffRedeemResponse>(harness)
|
||||
.post(REDEEM_PATH)
|
||||
.body({handoff_id: handoffId, nonce})
|
||||
.execute();
|
||||
expect(redeemed.payload).toBe(PAYLOAD);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,210 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {createChannelID, createUserID, type UserID} from '@app/api/BrandedTypes';
|
||||
import type {IChannelRepository} from '@app/api/channel/IChannelRepository';
|
||||
import {CallService} from '@app/api/channel/services/CallService';
|
||||
import type {IGuildRepositoryAggregate} from '@app/api/guild/repositories/IGuildRepositoryAggregate';
|
||||
import type {CallCaller, CallData, IGatewayService} from '@app/api/infrastructure/IGatewayService';
|
||||
import type {ISnowflakeService} from '@app/api/infrastructure/ISnowflakeService';
|
||||
import type {IVoiceRoomStore} from '@app/api/infrastructure/IVoiceRoomStore';
|
||||
import type {UserCacheService} from '@app/api/infrastructure/UserCacheService';
|
||||
import type {RequestCache} from '@app/api/middleware/RequestCacheMiddleware';
|
||||
import type {Channel} from '@app/api/models/Channel';
|
||||
import type {User} from '@app/api/models/User';
|
||||
import type {ReadStateService} from '@app/api/read_state/ReadStateService';
|
||||
import type {IUserRepository} from '@app/api/user/IUserRepository';
|
||||
import {ChannelTypes} from '@fluxer/constants/src/ChannelConstants';
|
||||
import type {UserPartialResponse} from '@fluxer/schema/src/domains/user/UserResponseSchemas';
|
||||
import {beforeEach, describe, expect, it} from 'vitest';
|
||||
|
||||
const CALLER_ID = createUserID(1n);
|
||||
const RECIPIENT_ID = createUserID(2n);
|
||||
const CHANNEL_ID = createChannelID(12n);
|
||||
|
||||
interface CallerOverrides {
|
||||
username?: string;
|
||||
globalName?: string | null;
|
||||
avatar?: string | null;
|
||||
nickname?: string;
|
||||
userRowMissing?: boolean;
|
||||
}
|
||||
|
||||
interface Harness {
|
||||
service: CallService;
|
||||
created: Array<CallCaller | undefined>;
|
||||
rung: Array<CallCaller | undefined>;
|
||||
}
|
||||
|
||||
const EXISTING_CALL: CallData = {
|
||||
channel_id: CHANNEL_ID.toString(),
|
||||
message_id: '99',
|
||||
region: 'automatic',
|
||||
ringing: [],
|
||||
recipients: [CALLER_ID.toString(), RECIPIENT_ID.toString()],
|
||||
voice_states: [],
|
||||
};
|
||||
|
||||
function harness(overrides: CallerOverrides, existingCall: CallData | null): Harness {
|
||||
const username = overrides.username ?? 'elias';
|
||||
const globalName = overrides.globalName === undefined ? 'Elias' : overrides.globalName;
|
||||
const avatar = overrides.avatar === undefined ? 'a1b2c3d4' : overrides.avatar;
|
||||
const nicknames = new Map<string, string>();
|
||||
if (overrides.nickname !== undefined) {
|
||||
nicknames.set(CALLER_ID.toString(), overrides.nickname);
|
||||
}
|
||||
const channel = {
|
||||
id: CHANNEL_ID,
|
||||
type: ChannelTypes.GROUP_DM,
|
||||
recipientIds: new Set<UserID>([CALLER_ID, RECIPIENT_ID]),
|
||||
nicknames,
|
||||
} as unknown as Channel;
|
||||
const created: Array<CallCaller | undefined> = [];
|
||||
const rung: Array<CallCaller | undefined> = [];
|
||||
const channelRepository = {
|
||||
findUnique: async () => channel,
|
||||
upsertMessage: async () => {},
|
||||
getMessage: async () => null,
|
||||
} as unknown as IChannelRepository;
|
||||
const userRepository = {
|
||||
findUnique: async () => (overrides.userRowMissing ? null : ({...callerUser(username, globalName, avatar)} as User)),
|
||||
listUsers: async () => [],
|
||||
findSettings: async () => null,
|
||||
isDmChannelOpen: async () => true,
|
||||
} as unknown as IUserRepository;
|
||||
const gatewayService = {
|
||||
getCall: async () => existingCall,
|
||||
createCall: async (
|
||||
_channelId: unknown,
|
||||
_messageId: string,
|
||||
_region: string,
|
||||
_ringing: Array<string>,
|
||||
_recipients: Array<string>,
|
||||
caller?: CallCaller,
|
||||
) => {
|
||||
created.push(caller);
|
||||
return EXISTING_CALL;
|
||||
},
|
||||
ringCallRecipients: async (_channelId: unknown, _recipients: Array<string>, caller?: CallCaller) => {
|
||||
rung.push(caller);
|
||||
return true;
|
||||
},
|
||||
} as unknown as IGatewayService;
|
||||
const userCacheService = {
|
||||
getUserPartialResponse: async (): Promise<UserPartialResponse> =>
|
||||
({
|
||||
id: CALLER_ID.toString(),
|
||||
username,
|
||||
discriminator: '0001',
|
||||
global_name: globalName,
|
||||
avatar,
|
||||
avatar_color: null,
|
||||
flags: 0,
|
||||
}) as unknown as UserPartialResponse,
|
||||
} as unknown as UserCacheService;
|
||||
const snowflakeService = {
|
||||
generateForChannel: async () => 7777n,
|
||||
} as unknown as ISnowflakeService;
|
||||
const readStateService = {
|
||||
ackMessage: async () => {},
|
||||
bulkIncrementMentionCounts: async () => {},
|
||||
} as unknown as ReadStateService;
|
||||
const service = new CallService(
|
||||
channelRepository,
|
||||
userRepository,
|
||||
{} as unknown as IGuildRepositoryAggregate,
|
||||
gatewayService,
|
||||
userCacheService,
|
||||
snowflakeService,
|
||||
readStateService,
|
||||
null,
|
||||
{} as unknown as IVoiceRoomStore,
|
||||
);
|
||||
return {service, created, rung};
|
||||
}
|
||||
|
||||
function callerUser(username: string, globalName: string | null, avatar: string | null): Partial<User> {
|
||||
return {
|
||||
id: CALLER_ID,
|
||||
username,
|
||||
globalName,
|
||||
avatarHash: avatar,
|
||||
isBot: false,
|
||||
};
|
||||
}
|
||||
|
||||
const requestCache = {
|
||||
userPartials: new Map(),
|
||||
} as unknown as RequestCache;
|
||||
|
||||
describe('CallService caller identity', () => {
|
||||
let harnessState: Harness;
|
||||
|
||||
const createCall = (overrides: CallerOverrides = {}) => {
|
||||
harnessState = harness(overrides, null);
|
||||
return harnessState.service.createOrGetCall({
|
||||
userId: CALLER_ID,
|
||||
channelId: CHANNEL_ID,
|
||||
ringing: [RECIPIENT_ID],
|
||||
requestCache,
|
||||
});
|
||||
};
|
||||
|
||||
const ringExistingCall = (overrides: CallerOverrides = {}) => {
|
||||
harnessState = harness(overrides, EXISTING_CALL);
|
||||
return harnessState.service.ringCallRecipients({
|
||||
userId: CALLER_ID,
|
||||
channelId: CHANNEL_ID,
|
||||
requestCache,
|
||||
});
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
requestCache.userPartials.clear();
|
||||
});
|
||||
|
||||
it('sends the caller id, display name and avatar hash to createCall', async () => {
|
||||
await createCall();
|
||||
expect(harnessState.created).toEqual([{id: '1', name: 'Elias', avatar: 'a1b2c3d4'}]);
|
||||
});
|
||||
|
||||
it('prefers the group dm nickname over the global name on createCall', async () => {
|
||||
await createCall({nickname: 'Eli'});
|
||||
expect(harnessState.created[0]?.name).toBe('Eli');
|
||||
});
|
||||
|
||||
it('falls back to the username when the caller has no nickname and no global name', async () => {
|
||||
await createCall({globalName: null});
|
||||
expect(harnessState.created[0]?.name).toBe('elias');
|
||||
});
|
||||
|
||||
it('sends a null avatar when the caller has no custom avatar', async () => {
|
||||
await createCall({avatar: null});
|
||||
expect(harnessState.created[0]).toEqual({id: '1', name: 'Elias', avatar: null});
|
||||
});
|
||||
|
||||
it('sends no caller at all when the caller user row is gone', async () => {
|
||||
await createCall({userRowMissing: true});
|
||||
expect(harnessState.created).toEqual([undefined]);
|
||||
});
|
||||
|
||||
it('sends the caller id, display name and avatar hash to ringCallRecipients', async () => {
|
||||
await ringExistingCall();
|
||||
expect(harnessState.rung).toEqual([{id: '1', name: 'Elias', avatar: 'a1b2c3d4'}]);
|
||||
});
|
||||
|
||||
it('prefers the group dm nickname over the global name on ringCallRecipients', async () => {
|
||||
await ringExistingCall({nickname: 'Eli'});
|
||||
expect(harnessState.rung[0]?.name).toBe('Eli');
|
||||
});
|
||||
|
||||
it('falls back to the username on ringCallRecipients', async () => {
|
||||
await ringExistingCall({globalName: null});
|
||||
expect(harnessState.rung[0]?.name).toBe('elias');
|
||||
});
|
||||
|
||||
it('resolves the caller on the ring branch and not on the create branch', async () => {
|
||||
await ringExistingCall();
|
||||
expect(harnessState.created).toEqual([]);
|
||||
expect(harnessState.rung).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
@@ -12,6 +12,7 @@ import type {ISnowflakeService} from '@app/api/infrastructure/ISnowflakeService'
|
||||
import type {IVoiceRoomStore} from '@app/api/infrastructure/IVoiceRoomStore';
|
||||
import type {UserCacheService} from '@app/api/infrastructure/UserCacheService';
|
||||
import type {RequestCache} from '@app/api/middleware/RequestCacheMiddleware';
|
||||
import type {Channel} from '@app/api/models/Channel';
|
||||
import type {ReadStateService} from '@app/api/read_state/ReadStateService';
|
||||
import type {IUserRepository} from '@app/api/user/IUserRepository';
|
||||
import type {VoiceAccessContext, VoiceAvailabilityService} from '@app/api/voice/VoiceAvailabilityService';
|
||||
@@ -208,14 +209,26 @@ export class CallService {
|
||||
has_reaction: false,
|
||||
version: 1,
|
||||
});
|
||||
const author = await this.userRepository.findUnique(userId);
|
||||
const call = await this.gatewayService.createCall(
|
||||
channelId,
|
||||
messageId.toString(),
|
||||
selectedRegion,
|
||||
ringing.map((id) => id.toString()),
|
||||
allRecipients.map((id) => id.toString()),
|
||||
author
|
||||
? {
|
||||
id: userId.toString(),
|
||||
name: this.resolveCallerName({
|
||||
channel,
|
||||
userId,
|
||||
globalName: author.globalName,
|
||||
username: author.username,
|
||||
}),
|
||||
avatar: author.avatarHash,
|
||||
}
|
||||
: undefined,
|
||||
);
|
||||
const author = await this.userRepository.findUnique(userId);
|
||||
await incrementDmMentionCounts({
|
||||
readStateService: this.readStateService,
|
||||
userRepository: this.userRepository,
|
||||
@@ -390,13 +403,45 @@ export class CallService {
|
||||
longitude,
|
||||
});
|
||||
} else {
|
||||
const caller = await this.userCacheService.getUserPartialResponse(userId, requestCache);
|
||||
await this.gatewayService.ringCallRecipients(
|
||||
channelId,
|
||||
recipientsToRing.map((id) => id.toString()),
|
||||
{
|
||||
id: userId.toString(),
|
||||
name: this.resolveCallerName({
|
||||
channel,
|
||||
userId,
|
||||
globalName: caller.global_name,
|
||||
username: caller.username,
|
||||
}),
|
||||
avatar: caller.avatar,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private resolveCallerName({
|
||||
channel,
|
||||
userId,
|
||||
globalName,
|
||||
username,
|
||||
}: {
|
||||
channel: Channel;
|
||||
userId: UserID;
|
||||
globalName: string | null;
|
||||
username: string;
|
||||
}): string {
|
||||
const nickname = channel.nicknames.get(userId.toString());
|
||||
if (nickname) {
|
||||
return nickname;
|
||||
}
|
||||
if (globalName) {
|
||||
return globalName;
|
||||
}
|
||||
return username;
|
||||
}
|
||||
|
||||
async stopRingingCallRecipients({
|
||||
userId,
|
||||
channelId,
|
||||
|
||||
@@ -252,9 +252,7 @@ export class MessageValidationService {
|
||||
const isAuthor = message.authorId === userId;
|
||||
if (!guild) return isAuthor;
|
||||
if (isAuthor) return true;
|
||||
const canManageMessages =
|
||||
(await hasPermission(Permissions.SEND_MESSAGES)) && (await hasPermission(Permissions.MANAGE_MESSAGES));
|
||||
return canManageMessages;
|
||||
return hasPermission(Permissions.MANAGE_MESSAGES);
|
||||
}
|
||||
|
||||
private validateVoiceMessageConstraints(
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import type {TestAccount} from '@app/api/auth/tests/AuthTestUtils';
|
||||
import {
|
||||
createPermissionOverwrite,
|
||||
sendChannelMessage,
|
||||
setupTestGuildWithMembers,
|
||||
} from '@app/api/channel/tests/ChannelTestUtils';
|
||||
import {type ApiTestHarness, createApiTestHarness} from '@app/api/test/ApiTestHarness';
|
||||
import {HTTP_STATUS} from '@app/api/test/TestConstants';
|
||||
import {createBuilder} from '@app/api/test/TestRequestBuilder';
|
||||
import {Permissions} from '@fluxer/constants/src/ChannelConstants';
|
||||
import {afterAll, beforeAll, beforeEach, describe, it} from 'vitest';
|
||||
|
||||
describe('Message delete permissions', () => {
|
||||
let harness: ApiTestHarness;
|
||||
|
||||
beforeAll(async () => {
|
||||
harness = await createApiTestHarness();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await harness.reset();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await harness?.shutdown();
|
||||
});
|
||||
|
||||
it('lets a member with MANAGE_MESSAGES but without SEND_MESSAGES delete another member message', async () => {
|
||||
const {owner, members, systemChannel} = await setupTestGuildWithMembers(harness, 2);
|
||||
const [author, moderator] = members as [TestAccount, TestAccount];
|
||||
const message = await sendChannelMessage(harness, author.token, systemChannel.id, 'delete me');
|
||||
await createPermissionOverwrite(harness, owner.token, systemChannel.id, moderator.userId, {
|
||||
type: 1,
|
||||
allow: Permissions.MANAGE_MESSAGES.toString(),
|
||||
deny: Permissions.SEND_MESSAGES.toString(),
|
||||
});
|
||||
|
||||
await createBuilder(harness, moderator.token)
|
||||
.delete(`/channels/${systemChannel.id}/messages/${message.id}`)
|
||||
.expect(HTTP_STATUS.NO_CONTENT)
|
||||
.execute();
|
||||
|
||||
await createBuilder(harness, author.token)
|
||||
.get(`/channels/${systemChannel.id}/messages/${message.id}`)
|
||||
.expect(HTTP_STATUS.NOT_FOUND)
|
||||
.execute();
|
||||
});
|
||||
|
||||
it('refuses a member without MANAGE_MESSAGES deleting another member message', async () => {
|
||||
const {members, systemChannel} = await setupTestGuildWithMembers(harness, 2);
|
||||
const [author, other] = members as [TestAccount, TestAccount];
|
||||
const message = await sendChannelMessage(harness, author.token, systemChannel.id, 'keep me');
|
||||
|
||||
await createBuilder(harness, other.token)
|
||||
.delete(`/channels/${systemChannel.id}/messages/${message.id}`)
|
||||
.expect(HTTP_STATUS.FORBIDDEN, 'MISSING_PERMISSIONS')
|
||||
.execute();
|
||||
});
|
||||
});
|
||||
@@ -129,6 +129,7 @@ export interface APIConfig {
|
||||
apiPublic: string;
|
||||
apiClient: string;
|
||||
webApp: string;
|
||||
webAppOrigins: Array<string>;
|
||||
gateway: string;
|
||||
media: string;
|
||||
staticCdn: string;
|
||||
@@ -336,6 +337,10 @@ export interface APIConfig {
|
||||
};
|
||||
abusePolicy: {
|
||||
inboundPhoneCountryCodes: Array<string>;
|
||||
phoneFlagging: {
|
||||
enabled: boolean;
|
||||
exemptCountryCodes: Array<string>;
|
||||
};
|
||||
phoneVerification: {
|
||||
inboundRequiredPrefixes: Array<string>;
|
||||
};
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
{
|
||||
"auth.unknown_location": "Ubicación desconocida",
|
||||
"billing.donation_description_monthly": "Donación mensual para apoyar a {product_name}",
|
||||
"billing.donation_description_one_time": "Donación única para apoyar a {product_name}",
|
||||
"billing.donation_description_yearly": "Donación anual para apoyar a {product_name}",
|
||||
"billing.donation_name_one_time": "Donación a {product_name}",
|
||||
"billing.donation_name_recurring": "Donación recurrente a {product_name}",
|
||||
"billing.eu_withdrawal_waiver_checkout": "Si soy un consumidor de la UE/EEE, doy mi consentimiento expreso para que el contenido digital de {product_name} {premium_tier_name} se proporcione de inmediato y reconozco que pierdo mi derecho legal de desistimiento una vez que se otorgue el acceso. Esto no afecta otros derechos de consumo obligatorios. Consulta los [Términos de servicio]({terms_url}).",
|
||||
"bulk_message_deletion.complete": "Terminamos de eliminar tus mensajes. Eliminamos {message_count, plural, =0 {0 mensajes} one {# mensaje} other {# mensajes}} de {channel_count, plural, =0 {0 lugares} one {# lugar} other {# lugares}}.",
|
||||
"content.virus_detected": "Ese archivo fue marcado como potencialmente inseguro y se ha eliminado.",
|
||||
"guild.bulk_create.emoji_limit": "Se alcanzó el límite máximo de emojis ({limit}).",
|
||||
"guild.bulk_create.sticker_limit": "Se alcanzó el límite máximo de stickers ({limit}).",
|
||||
"guild.bulk_create.unknown_error": "Error desconocido.",
|
||||
"guild.default_category_text": "Canales de texto",
|
||||
"guild.default_category_voice": "Canales de voz",
|
||||
"guild.default_channel_text": "general",
|
||||
"guild.default_channel_voice": "General"
|
||||
"auth.unknown_location": "Ubicación desconocida",
|
||||
"billing.donation_description_monthly": "Donación mensual para apoyar a {product_name}",
|
||||
"billing.donation_description_one_time": "Donación única para apoyar a {product_name}",
|
||||
"billing.donation_description_yearly": "Donación anual para apoyar a {product_name}",
|
||||
"billing.donation_name_one_time": "Donación a {product_name}",
|
||||
"billing.donation_name_recurring": "Donación recurrente a {product_name}",
|
||||
"billing.eu_withdrawal_waiver_checkout": "Si soy un consumidor de la UE/EEE, doy mi consentimiento expreso para que el contenido digital de {product_name} {premium_tier_name} se proporcione de inmediato y reconozco que pierdo mi derecho legal de desistimiento una vez que se otorgue el acceso. Esto no afecta otros derechos de consumo obligatorios. Consulta los [Términos de servicio]({terms_url}).",
|
||||
"bulk_message_deletion.complete": "Terminamos de eliminar tus mensajes. Eliminamos {message_count, plural, =0 {0 mensajes} one {# mensaje} other {# mensajes}} de {channel_count, plural, =0 {0 lugares} one {# lugar} other {# lugares}}.",
|
||||
"content.virus_detected": "Ese archivo fue marcado como potencialmente inseguro y se ha eliminado.",
|
||||
"guild.bulk_create.emoji_limit": "Se alcanzó el límite máximo de emojis ({limit}).",
|
||||
"guild.bulk_create.sticker_limit": "Se alcanzó el límite máximo de stickers ({limit}).",
|
||||
"guild.bulk_create.unknown_error": "Error desconocido.",
|
||||
"guild.default_category_text": "Canales de texto",
|
||||
"guild.default_category_voice": "Canales de voz",
|
||||
"guild.default_channel_text": "general",
|
||||
"guild.default_channel_voice": "General"
|
||||
}
|
||||
|
||||
@@ -15,7 +15,12 @@ import type {GuildFolderIcon, MentionReplyPreference} from '@fluxer/constants/sr
|
||||
import type {types} from 'cassandra-driver';
|
||||
|
||||
type Nullish<T> = T | null;
|
||||
export type PushSubscriptionPlatform = 'web_push' | 'android_fcm' | 'ios_apns' | 'android_unified_push';
|
||||
export type PushSubscriptionPlatform =
|
||||
| 'web_push'
|
||||
| 'android_fcm'
|
||||
| 'ios_apns'
|
||||
| 'ios_apns_voip'
|
||||
| 'android_unified_push';
|
||||
|
||||
export interface UserRow {
|
||||
user_id: UserID;
|
||||
|
||||
@@ -8,7 +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 {resolveScreenShareDeliveryAssignment} from '@fluxer/schema/src/domains/admin/ScreenShareDeliverySchemas';
|
||||
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 +29,10 @@ export function ExperimentController(app: HonoApp) {
|
||||
}),
|
||||
async (ctx) => {
|
||||
const instanceConfigRepository = ctx.get('instanceConfigRepository');
|
||||
const [delivery, voiceConfig, screenShareConfig] = await Promise.all([
|
||||
const [delivery, voiceConfig, domainMigrationConfig] = await Promise.all([
|
||||
instanceConfigRepository.getExperimentDeliveryConfig(),
|
||||
instanceConfigRepository.getVoiceNoiseSuppressionConfig(),
|
||||
instanceConfigRepository.getScreenShareDeliveryConfig(),
|
||||
instanceConfigRepository.getDomainMigrationConfig(),
|
||||
]);
|
||||
const userId = ctx.get('user').id.toString();
|
||||
const body: ExperimentAssignmentsResponse = {
|
||||
@@ -40,7 +40,7 @@ export function ExperimentController(app: HonoApp) {
|
||||
poll_jitter_percent: delivery.poll_jitter_percent,
|
||||
assignments: {
|
||||
voice_noise_suppression: resolveVoiceNoiseSuppressionAssignment(voiceConfig, userId),
|
||||
screen_share_delivery: resolveScreenShareDeliveryAssignment(screenShareConfig, userId),
|
||||
domain_migration: resolveDomainMigrationAssignment(domainMigrationConfig, userId),
|
||||
},
|
||||
};
|
||||
const etag = `"${createHash('sha256').update(JSON.stringify(body)).digest('hex')}"`;
|
||||
|
||||
@@ -7,9 +7,9 @@ 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_SCREEN_SHARE_DELIVERY_CONFIG,
|
||||
INERT_SCREEN_SHARE_DELIVERY_ASSIGNMENT,
|
||||
} from '@fluxer/schema/src/domains/admin/ScreenShareDeliverySchemas';
|
||||
DEFAULT_DOMAIN_MIGRATION_CONFIG,
|
||||
INERT_DOMAIN_MIGRATION_ASSIGNMENT,
|
||||
} from '@fluxer/schema/src/domains/admin/DomainMigrationSchemas';
|
||||
import {
|
||||
DEFAULT_VOICE_NOISE_SUPPRESSION_CONFIG,
|
||||
INERT_VOICE_NOISE_SUPPRESSION_ASSIGNMENT,
|
||||
@@ -19,7 +19,7 @@ import {
|
||||
DEFAULT_EXPERIMENT_POLL_JITTER_PERCENT,
|
||||
type ExperimentAssignmentsResponse,
|
||||
type ExperimentDeliveryConfigResponse,
|
||||
readScreenShareDeliveryAssignment,
|
||||
readDomainMigrationAssignment,
|
||||
readVoiceNoiseSuppressionAssignment,
|
||||
} from '@fluxer/schema/src/domains/experiment/ExperimentSchemas';
|
||||
import {afterAll, beforeAll, beforeEach, describe, expect, it} from 'vitest';
|
||||
@@ -56,7 +56,7 @@ describe('GET /experiments', () => {
|
||||
poll_jitter_percent: DEFAULT_EXPERIMENT_POLL_JITTER_PERCENT,
|
||||
assignments: {
|
||||
voice_noise_suppression: INERT_VOICE_NOISE_SUPPRESSION_ASSIGNMENT,
|
||||
screen_share_delivery: INERT_SCREEN_SHARE_DELIVERY_ASSIGNMENT,
|
||||
domain_migration: INERT_DOMAIN_MIGRATION_ASSIGNMENT,
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -88,20 +88,20 @@ describe('GET /experiments', () => {
|
||||
expect(readVoiceNoiseSuppressionAssignment(body).enabled).toBe(false);
|
||||
});
|
||||
|
||||
it('populates the screen share assignment key even when the rollout is disabled', async () => {
|
||||
it('populates the domain migration assignment key even when the rollout is disabled', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
|
||||
const body = await createBuilder<ExperimentAssignmentsResponse>(harness, account.token).get(ENDPOINT).execute();
|
||||
|
||||
expect(Object.hasOwn(body.assignments, 'screen_share_delivery')).toBe(true);
|
||||
expect(readScreenShareDeliveryAssignment(body).enabled).toBe(false);
|
||||
expect(Object.hasOwn(body.assignments, 'domain_migration')).toBe(true);
|
||||
expect(readDomainMigrationAssignment(body).enabled).toBe(false);
|
||||
});
|
||||
|
||||
it('resolves the screen share caller through the allowlist', async () => {
|
||||
it('resolves the domain migration caller through the allowlist', async () => {
|
||||
const targeted = await createTestAccount(harness);
|
||||
const untargeted = await createTestAccount(harness);
|
||||
await getInstanceConfigRepository().setScreenShareDeliveryConfig({
|
||||
...DEFAULT_SCREEN_SHARE_DELIVERY_CONFIG,
|
||||
await getInstanceConfigRepository().setDomainMigrationConfig({
|
||||
...DEFAULT_DOMAIN_MIGRATION_CONFIG,
|
||||
enabled: true,
|
||||
config_version: 4,
|
||||
rollout_basis_points: 0,
|
||||
@@ -111,18 +111,18 @@ describe('GET /experiments', () => {
|
||||
const targetedBody = await createBuilder<ExperimentAssignmentsResponse>(harness, targeted.token)
|
||||
.get(ENDPOINT)
|
||||
.execute();
|
||||
expect(targetedBody.assignments.screen_share_delivery).toEqual({enabled: true});
|
||||
expect(targetedBody.assignments.domain_migration).toEqual({enabled: true});
|
||||
|
||||
const untargetedBody = await createBuilder<ExperimentAssignmentsResponse>(harness, untargeted.token)
|
||||
.get(ENDPOINT)
|
||||
.execute();
|
||||
expect(untargetedBody.assignments.screen_share_delivery).toEqual({enabled: false});
|
||||
expect(untargetedBody.assignments.domain_migration).toEqual({enabled: false});
|
||||
});
|
||||
|
||||
it('keeps the screen share exclusion ahead of a full rollout', async () => {
|
||||
it('keeps the domain migration exclusion ahead of a full rollout', async () => {
|
||||
const excluded = await createTestAccount(harness);
|
||||
await getInstanceConfigRepository().setScreenShareDeliveryConfig({
|
||||
...DEFAULT_SCREEN_SHARE_DELIVERY_CONFIG,
|
||||
await getInstanceConfigRepository().setDomainMigrationConfig({
|
||||
...DEFAULT_DOMAIN_MIGRATION_CONFIG,
|
||||
enabled: true,
|
||||
rollout_basis_points: 10000,
|
||||
included_user_ids: [excluded.userId],
|
||||
@@ -131,7 +131,7 @@ describe('GET /experiments', () => {
|
||||
|
||||
const body = await createBuilder<ExperimentAssignmentsResponse>(harness, excluded.token).get(ENDPOINT).execute();
|
||||
|
||||
expect(body.assignments.screen_share_delivery).toEqual({enabled: false});
|
||||
expect(body.assignments.domain_migration).toEqual({enabled: false});
|
||||
});
|
||||
|
||||
it('serves the delivery cadence from the delivery config and not from the voice config', async () => {
|
||||
@@ -248,7 +248,7 @@ describe('GET /experiments', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('serves a fresh body once the screen share config changes', async () => {
|
||||
it('serves a fresh body once the domain migration config changes', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
|
||||
const first = await createBuilder<ExperimentAssignmentsResponse>(harness, account.token)
|
||||
@@ -256,8 +256,8 @@ describe('GET /experiments', () => {
|
||||
.executeWithResponse();
|
||||
const staleEtag = first.response.headers.get('etag') as string;
|
||||
|
||||
await getInstanceConfigRepository().setScreenShareDeliveryConfig({
|
||||
...DEFAULT_SCREEN_SHARE_DELIVERY_CONFIG,
|
||||
await getInstanceConfigRepository().setDomainMigrationConfig({
|
||||
...DEFAULT_DOMAIN_MIGRATION_CONFIG,
|
||||
enabled: true,
|
||||
config_version: 1,
|
||||
rollout_basis_points: 10000,
|
||||
@@ -269,7 +269,7 @@ describe('GET /experiments', () => {
|
||||
.executeWithResponse();
|
||||
expect(refreshed.response.status).toBe(HTTP_STATUS.OK);
|
||||
expect(refreshed.response.headers.get('etag')).not.toBe(staleEtag);
|
||||
expect(refreshed.json?.assignments.screen_share_delivery).toEqual({enabled: true});
|
||||
expect(refreshed.json?.assignments.domain_migration).toEqual({enabled: true});
|
||||
});
|
||||
|
||||
it('serves a fresh body once the delivery config changes', async () => {
|
||||
@@ -328,42 +328,45 @@ describe('GET /experiments', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('bumps the screen share config version on every admin update without the client sending one', async () => {
|
||||
it('bumps the domain migration config version on every admin update without the client sending one', async () => {
|
||||
const admin = await setUserACLs(harness, await createTestAccount(harness), [
|
||||
AdminACLs.AUTHENTICATE,
|
||||
AdminACLs.INSTANCE_CONFIG_VIEW,
|
||||
AdminACLs.INSTANCE_CONFIG_UPDATE,
|
||||
]);
|
||||
|
||||
const afterFirst = await createBuilder<{screen_share_delivery: {config_version: number; enabled: boolean}}>(
|
||||
const afterFirst = await createBuilder<{domain_migration: {config_version: number; enabled: boolean}}>(
|
||||
harness,
|
||||
admin.token,
|
||||
)
|
||||
.patch('/admin/instance/config')
|
||||
.body({screen_share_delivery: {enabled: true, rollout_basis_points: 10000}})
|
||||
.body({domain_migration: {enabled: true, rollout_basis_points: 10000}})
|
||||
.execute();
|
||||
expect(afterFirst.screen_share_delivery).toMatchObject({config_version: 1, enabled: true});
|
||||
expect(afterFirst.domain_migration).toMatchObject({config_version: 1, enabled: true});
|
||||
|
||||
const afterSecond = await createBuilder<{screen_share_delivery: {config_version: number; enabled: boolean}}>(
|
||||
harness,
|
||||
admin.token,
|
||||
)
|
||||
const afterSecond = await createBuilder<{
|
||||
domain_migration: {config_version: number; enabled: boolean; anonymous_rollout_basis_points: number};
|
||||
}>(harness, admin.token)
|
||||
.patch('/admin/instance/config')
|
||||
.body({screen_share_delivery: {rollout_salt: 'screen-share-delivery-v2'}})
|
||||
.body({domain_migration: {anonymous_rollout_basis_points: 2500}})
|
||||
.execute();
|
||||
expect(afterSecond.screen_share_delivery).toMatchObject({config_version: 2, enabled: true});
|
||||
expect(afterSecond.domain_migration).toMatchObject({
|
||||
config_version: 2,
|
||||
enabled: true,
|
||||
anonymous_rollout_basis_points: 2500,
|
||||
});
|
||||
|
||||
const afterEmpty = await createBuilder<{screen_share_delivery: {config_version: number; enabled: boolean}}>(
|
||||
const afterEmpty = await createBuilder<{domain_migration: {config_version: number; enabled: boolean}}>(
|
||||
harness,
|
||||
admin.token,
|
||||
)
|
||||
.patch('/admin/instance/config')
|
||||
.body({screen_share_delivery: {}})
|
||||
.body({domain_migration: {}})
|
||||
.execute();
|
||||
expect(afterEmpty.screen_share_delivery).toMatchObject({config_version: 2, enabled: true});
|
||||
expect(afterEmpty.domain_migration).toMatchObject({config_version: 2, enabled: true});
|
||||
|
||||
const body = await createBuilder<ExperimentAssignmentsResponse>(harness, admin.token).get(ENDPOINT).execute();
|
||||
expect(body.assignments.screen_share_delivery).toEqual({enabled: true});
|
||||
expect(body.assignments.domain_migration).toEqual({enabled: true});
|
||||
});
|
||||
|
||||
it('leaves the config version alone for an admin update that sets no field', async () => {
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import {requireEmailVerified} from '@app/api/auth/EmailVerificationUtils';
|
||||
import type {GuildID, InviteCode, RoleID, UserID} from '@app/api/BrandedTypes';
|
||||
import {createChannelID, createRoleID} from '@app/api/BrandedTypes';
|
||||
import {Config} from '@app/api/Config';
|
||||
import type {ChannelService} from '@app/api/channel/services/ChannelService';
|
||||
import {assertMutableUserId} from '@app/api/constants/Core';
|
||||
import type {GuildMemberRow} from '@app/api/database/types/GuildTypes';
|
||||
@@ -421,6 +422,13 @@ export class GuildMemberOperationsService {
|
||||
memberCount: guild.memberCount,
|
||||
accountAgeMs: Date.now() - snowflakeToDate(BigInt(user.id)).getTime(),
|
||||
};
|
||||
if (
|
||||
!Config.abusePolicy.phoneFlagging.enabled &&
|
||||
(getEffectiveSuspiciousFlags(user) & PHONE_REQUIREMENT_FLAGS) === 0
|
||||
) {
|
||||
Logger.info(logContext, 'deferred_phone_gate.skipped_phone_flagging_disabled');
|
||||
return;
|
||||
}
|
||||
if (status !== 'ok') {
|
||||
const undeferredFlags = getEffectiveSuspiciousFlags({
|
||||
...user,
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import {Config} from '@app/api/Config';
|
||||
import {GatewayRpcMethodError, GatewayRpcMethodErrorCodes} from '@app/api/infrastructure/GatewayRpcError';
|
||||
import type {IGatewayRpcTransport} from '@app/api/infrastructure/IGatewayRpcTransport';
|
||||
import type {CallData} from '@app/api/infrastructure/IGatewayService';
|
||||
import {type CallCaller, type CallData, callCallerRpcParams} from '@app/api/infrastructure/IGatewayService';
|
||||
import {NatsGatewayRpcTransport} from '@app/api/infrastructure/NatsGatewayRpcTransport';
|
||||
import {Logger} from '@app/api/Logger';
|
||||
import {NatsConnectionManager} from '@pkgs/nats/src/NatsConnectionManager';
|
||||
@@ -128,6 +128,7 @@ export class GatewayRpcClient {
|
||||
region: string,
|
||||
ringing: Array<string>,
|
||||
recipients: Array<string>,
|
||||
caller?: CallCaller,
|
||||
): Promise<CallData> {
|
||||
return this.call<CallData>('call.create', {
|
||||
channel_id: channelId,
|
||||
@@ -135,6 +136,7 @@ export class GatewayRpcClient {
|
||||
region,
|
||||
ringing,
|
||||
recipients,
|
||||
...callCallerRpcParams(caller),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -142,8 +144,8 @@ export class GatewayRpcClient {
|
||||
return this.call('call.update_region', {channel_id: channelId, region});
|
||||
}
|
||||
|
||||
async ringCallRecipients(channelId: string, recipients: Array<string>): Promise<boolean> {
|
||||
return this.call('call.ring', {channel_id: channelId, recipients});
|
||||
async ringCallRecipients(channelId: string, recipients: Array<string>, caller?: CallCaller): Promise<boolean> {
|
||||
return this.call('call.ring', {channel_id: channelId, recipients, ...callCallerRpcParams(caller)});
|
||||
}
|
||||
|
||||
async stopRingingCallRecipients(channelId: string, recipients: Array<string>): Promise<boolean> {
|
||||
|
||||
@@ -6,16 +6,18 @@ import {SYSTEM_USER_ID} from '@app/api/constants/Core';
|
||||
import type {GatewayDispatchEvent} from '@app/api/constants/Gateway';
|
||||
import {GatewayRpcClient} from '@app/api/infrastructure/GatewayRpcClient';
|
||||
import {GatewayRpcMethodError, GatewayRpcMethodErrorCodes} from '@app/api/infrastructure/GatewayRpcError';
|
||||
import type {
|
||||
CallData,
|
||||
GatewayChannelMention,
|
||||
GatewayGuildMemoryStats,
|
||||
GatewayMentionSources,
|
||||
GatewayMentionSourcesPage,
|
||||
GatewayNodeStats,
|
||||
GatewayVoiceStateCounts,
|
||||
GatewayVoiceStateEntry,
|
||||
GuildChannelAuthContext,
|
||||
import {
|
||||
type CallCaller,
|
||||
type CallData,
|
||||
callCallerRpcParams,
|
||||
type GatewayChannelMention,
|
||||
type GatewayGuildMemoryStats,
|
||||
type GatewayMentionSources,
|
||||
type GatewayMentionSourcesPage,
|
||||
type GatewayNodeStats,
|
||||
type GatewayVoiceStateCounts,
|
||||
type GatewayVoiceStateEntry,
|
||||
type GuildChannelAuthContext,
|
||||
} from '@app/api/infrastructure/IGatewayService';
|
||||
import {Logger} from '@app/api/Logger';
|
||||
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
|
||||
@@ -1695,6 +1697,7 @@ export class GatewayService {
|
||||
region: string,
|
||||
ringing: Array<string>,
|
||||
recipients: Array<string>,
|
||||
caller?: CallCaller,
|
||||
): Promise<CallData> {
|
||||
return this.call<CallData>('call.create', {
|
||||
channel_id: channelId.toString(),
|
||||
@@ -1702,6 +1705,7 @@ export class GatewayService {
|
||||
region,
|
||||
ringing,
|
||||
recipients,
|
||||
...callCallerRpcParams(caller),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1709,8 +1713,12 @@ export class GatewayService {
|
||||
return this.call<boolean>('call.update_region', {channel_id: channelId.toString(), region});
|
||||
}
|
||||
|
||||
async ringCallRecipients(channelId: ChannelID, recipients: Array<string>): Promise<boolean> {
|
||||
return this.call<boolean>('call.ring', {channel_id: channelId.toString(), recipients});
|
||||
async ringCallRecipients(channelId: ChannelID, recipients: Array<string>, caller?: CallCaller): Promise<boolean> {
|
||||
return this.call<boolean>('call.ring', {
|
||||
channel_id: channelId.toString(),
|
||||
recipients,
|
||||
...callCallerRpcParams(caller),
|
||||
});
|
||||
}
|
||||
|
||||
async stopRingingCallRecipients(channelId: ChannelID, recipients: Array<string>): Promise<boolean> {
|
||||
|
||||
@@ -24,6 +24,19 @@ export interface CallData {
|
||||
voice_states: Array<VoiceState>;
|
||||
}
|
||||
|
||||
export interface CallCaller {
|
||||
id: string;
|
||||
name: string;
|
||||
avatar: string | null;
|
||||
}
|
||||
|
||||
export function callCallerRpcParams(caller: CallCaller | undefined): Record<string, unknown> {
|
||||
if (!caller) {
|
||||
return {};
|
||||
}
|
||||
return {caller_id: caller.id, caller_name: caller.name, caller_avatar: caller.avatar};
|
||||
}
|
||||
|
||||
export interface GatewayGuildMemoryStatsEntry {
|
||||
node_id: string;
|
||||
guild_id: string | null;
|
||||
@@ -381,11 +394,12 @@ export abstract class IGatewayService {
|
||||
region: string,
|
||||
ringing: Array<string>,
|
||||
recipients: Array<string>,
|
||||
caller?: CallCaller,
|
||||
): Promise<CallData>;
|
||||
|
||||
abstract updateCallRegion(channelId: ChannelID, region: string | null): Promise<boolean>;
|
||||
|
||||
abstract ringCallRecipients(channelId: ChannelID, recipients: Array<string>): Promise<boolean>;
|
||||
abstract ringCallRecipients(channelId: ChannelID, recipients: Array<string>, caller?: CallCaller): Promise<boolean>;
|
||||
|
||||
abstract stopRingingCallRecipients(channelId: ChannelID, recipients: Array<string>): Promise<boolean>;
|
||||
|
||||
|
||||
@@ -84,6 +84,41 @@ describe('stripNonJpegImageMetadataForUpload', () => {
|
||||
});
|
||||
});
|
||||
|
||||
function riffChunk(type: string, data: Uint8Array): Uint8Array {
|
||||
const out = new Uint8Array(8 + data.length + (data.length & 1));
|
||||
out.set(textBytes(type), 0);
|
||||
new DataView(out.buffer).setUint32(4, data.length, true);
|
||||
out.set(data, 8);
|
||||
return out;
|
||||
}
|
||||
|
||||
function webp(chunks: ReadonlyArray<Uint8Array>): Uint8Array {
|
||||
const body = concatBytes(chunks);
|
||||
const header = concatBytes([textBytes('RIFF'), new Uint8Array(4), textBytes('WEBP')]);
|
||||
new DataView(header.buffer).setUint32(4, 4 + body.length, true);
|
||||
return concatBytes([header, body]);
|
||||
}
|
||||
|
||||
describe('stripNonJpegImageMetadataForUpload for WebP', () => {
|
||||
it('drops EXIF and XMP chunks without re-encoding frames', async () => {
|
||||
const vp8x = new Uint8Array(10);
|
||||
vp8x[0] = 0x02 | 0x08 | 0x04;
|
||||
const anmf = riffChunk('ANMF', new Uint8Array([9, 8, 7]));
|
||||
const input = webp([
|
||||
riffChunk('VP8X', vp8x),
|
||||
riffChunk('ANIM', new Uint8Array(6)),
|
||||
anmf,
|
||||
riffChunk('EXIF', textBytes('GPS=1,2')),
|
||||
riffChunk('XMP ', textBytes('private metadata')),
|
||||
]);
|
||||
const stripped = await stripNonJpegImageMetadataForUpload(input, 'image/webp');
|
||||
const expectedVp8x = new Uint8Array(10);
|
||||
expectedVp8x[0] = 0x02;
|
||||
expect(stripped.contentType).toBe('image/webp');
|
||||
expect(stripped.body).toEqual(webp([riffChunk('VP8X', expectedVp8x), riffChunk('ANIM', new Uint8Array(6)), anmf]));
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildProcessedMediaObject', () => {
|
||||
it('leaves non-media objects for plain copy', async () => {
|
||||
await expect(buildProcessedMediaObject(textBytes('plain text'), 'text/plain')).resolves.toBeNull();
|
||||
|
||||
@@ -162,6 +162,8 @@ export async function stripNonJpegImageMetadataForUpload(
|
||||
contentType: normalizedContentType === 'image/apng' ? 'image/apng' : 'image/png',
|
||||
};
|
||||
}
|
||||
const strippedWebp = isWebp(data) ? stripWebpMetadataChunks(data) : null;
|
||||
if (strippedWebp) return {body: strippedWebp, contentType: 'image/webp'};
|
||||
const image = sharp(data, {animated: true});
|
||||
const metadata = await image.metadata();
|
||||
switch (metadata.format) {
|
||||
@@ -242,6 +244,50 @@ function stripPngMetadataChunks(data: Uint8Array): Uint8Array {
|
||||
return output;
|
||||
}
|
||||
|
||||
const WEBP_CHUNKS_TO_KEEP = new Set(['VP8 ', 'VP8L', 'VP8X', 'ALPH', 'ANIM', 'ANMF', 'ICCP']);
|
||||
const WEBP_VP8X_EXIF_FLAG = 0x08;
|
||||
const WEBP_VP8X_XMP_FLAG = 0x04;
|
||||
|
||||
function readFourCc(data: Uint8Array, offset: number): string {
|
||||
return String.fromCharCode(data[offset]!, data[offset + 1]!, data[offset + 2]!, data[offset + 3]!);
|
||||
}
|
||||
|
||||
function readU32LE(data: Uint8Array, offset: number): number {
|
||||
return (data[offset]! | (data[offset + 1]! << 8) | (data[offset + 2]! << 16) | (data[offset + 3]! << 24)) >>> 0;
|
||||
}
|
||||
|
||||
function isWebp(data: Uint8Array): boolean {
|
||||
return data.length >= 12 && readFourCc(data, 0) === 'RIFF' && readFourCc(data, 8) === 'WEBP';
|
||||
}
|
||||
|
||||
function stripWebpMetadataChunks(data: Uint8Array): Uint8Array | null {
|
||||
const riffEnd = Math.min(data.length, 8 + readU32LE(data, 4));
|
||||
const chunks: Array<Uint8Array> = [];
|
||||
let offset = 12;
|
||||
while (offset + 8 <= riffEnd) {
|
||||
const length = readU32LE(data, offset + 4);
|
||||
const chunkEnd = offset + 8 + length + (length & 1);
|
||||
if (offset + 8 + length > riffEnd) return null;
|
||||
const type = readFourCc(data, offset);
|
||||
if (WEBP_CHUNKS_TO_KEEP.has(type)) {
|
||||
const chunk = data.slice(offset, Math.min(chunkEnd, riffEnd));
|
||||
if (type === 'VP8X' && length > 0) chunk[8] = (chunk[8] ?? 0) & ~(WEBP_VP8X_EXIF_FLAG | WEBP_VP8X_XMP_FLAG);
|
||||
chunks.push(chunk);
|
||||
}
|
||||
offset = chunkEnd;
|
||||
}
|
||||
const bodyLength = chunks.reduce((sum, chunk) => sum + chunk.length, 0);
|
||||
const output = new Uint8Array(12 + bodyLength);
|
||||
output.set(data.subarray(0, 12));
|
||||
new DataView(output.buffer).setUint32(4, 4 + bodyLength, true);
|
||||
let cursor = 12;
|
||||
for (const chunk of chunks) {
|
||||
output.set(chunk, cursor);
|
||||
cursor += chunk.length;
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
function imageExtensionForContentType(contentType: string): string {
|
||||
if (contentType.includes('svg')) return 'svg';
|
||||
if (contentType.includes('tiff')) return 'tiff';
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {createChannelID} from '@app/api/BrandedTypes';
|
||||
import {GatewayRpcClient} from '@app/api/infrastructure/GatewayRpcClient';
|
||||
import {GatewayService} from '@app/api/infrastructure/GatewayService';
|
||||
import type {IGatewayRpcTransport} from '@app/api/infrastructure/IGatewayRpcTransport';
|
||||
import {afterEach, describe, expect, it} from 'vitest';
|
||||
|
||||
const CHANNEL_ID = createChannelID(12n);
|
||||
|
||||
interface RecordedCall {
|
||||
method: string;
|
||||
params: Record<string, unknown>;
|
||||
}
|
||||
|
||||
function recordingService(recorded: Array<RecordedCall>): GatewayService {
|
||||
const transport: IGatewayRpcTransport = {
|
||||
async call(method: string, params: Record<string, unknown>): Promise<unknown> {
|
||||
recorded.push({method, params});
|
||||
return null;
|
||||
},
|
||||
async destroy(): Promise<void> {},
|
||||
};
|
||||
GatewayRpcClient.createForTests(transport);
|
||||
return new GatewayService();
|
||||
}
|
||||
|
||||
describe('call rpc caller params', () => {
|
||||
afterEach(async () => {
|
||||
await GatewayRpcClient.resetForTests();
|
||||
});
|
||||
|
||||
it('sends the caller to call.create as caller_id, caller_name and caller_avatar', async () => {
|
||||
const recorded: Array<RecordedCall> = [];
|
||||
const service = recordingService(recorded);
|
||||
await service.createCall(CHANNEL_ID, '99', 'automatic', ['2'], ['1', '2'], {
|
||||
id: '1',
|
||||
name: 'Elias',
|
||||
avatar: 'a1b2c3d4',
|
||||
});
|
||||
expect(recorded).toHaveLength(1);
|
||||
expect(recorded[0].method).toBe('call.create');
|
||||
expect(recorded[0].params.caller_id).toBe('1');
|
||||
expect(recorded[0].params.caller_name).toBe('Elias');
|
||||
expect(recorded[0].params.caller_avatar).toBe('a1b2c3d4');
|
||||
});
|
||||
|
||||
it('sends the caller to call.ring as caller_id, caller_name and caller_avatar', async () => {
|
||||
const recorded: Array<RecordedCall> = [];
|
||||
const service = recordingService(recorded);
|
||||
await service.ringCallRecipients(CHANNEL_ID, ['2'], {id: '1', name: 'Elias', avatar: 'a1b2c3d4'});
|
||||
expect(recorded).toHaveLength(1);
|
||||
expect(recorded[0].method).toBe('call.ring');
|
||||
expect(recorded[0].params.caller_id).toBe('1');
|
||||
expect(recorded[0].params.caller_name).toBe('Elias');
|
||||
expect(recorded[0].params.caller_avatar).toBe('a1b2c3d4');
|
||||
});
|
||||
|
||||
it('sends caller_avatar as null when the caller has no avatar', async () => {
|
||||
const recorded: Array<RecordedCall> = [];
|
||||
const service = recordingService(recorded);
|
||||
await service.ringCallRecipients(CHANNEL_ID, ['2'], {id: '1', name: 'Elias', avatar: null});
|
||||
expect(recorded[0].params.caller_avatar).toBeNull();
|
||||
expect(Object.hasOwn(recorded[0].params, 'caller_avatar')).toBe(true);
|
||||
});
|
||||
|
||||
it('omits every caller key from call.create when no caller was resolved', async () => {
|
||||
const recorded: Array<RecordedCall> = [];
|
||||
const service = recordingService(recorded);
|
||||
await service.createCall(CHANNEL_ID, '99', 'automatic', ['2'], ['1', '2']);
|
||||
expect(recorded[0].params).toEqual({
|
||||
channel_id: '12',
|
||||
message_id: '99',
|
||||
region: 'automatic',
|
||||
ringing: ['2'],
|
||||
recipients: ['1', '2'],
|
||||
});
|
||||
});
|
||||
|
||||
it('omits every caller key from call.ring when no caller was resolved', async () => {
|
||||
const recorded: Array<RecordedCall> = [];
|
||||
const service = recordingService(recorded);
|
||||
await service.ringCallRecipients(CHANNEL_ID, ['2']);
|
||||
expect(recorded[0].params).toEqual({channel_id: '12', recipients: ['2']});
|
||||
});
|
||||
});
|
||||
@@ -19,9 +19,9 @@ import {startDockerContainer} from '@app/api/test/DockerTestContainer';
|
||||
import {InMemoryCassandraQueryExecutor} from '@app/api/test/InMemoryCassandraQueryExecutor';
|
||||
import {MockKVProvider} from '@app/api/test/mocks/MockKVProvider';
|
||||
import {
|
||||
DEFAULT_SCREEN_SHARE_DELIVERY_CONFIG,
|
||||
type ScreenShareDeliveryConfig,
|
||||
} from '@fluxer/schema/src/domains/admin/ScreenShareDeliverySchemas';
|
||||
DEFAULT_DOMAIN_MIGRATION_CONFIG,
|
||||
type DomainMigrationConfig,
|
||||
} from '@fluxer/schema/src/domains/admin/DomainMigrationSchemas';
|
||||
import {
|
||||
DEFAULT_VOICE_NOISE_SUPPRESSION_CONFIG,
|
||||
type VoiceNoiseSuppressionConfig,
|
||||
@@ -39,7 +39,7 @@ import {
|
||||
import {afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi} from 'vitest';
|
||||
|
||||
const VOICE_NOISE_SUPPRESSION_CONFIG_KEY = 'voice_noise_suppression_config';
|
||||
const SCREEN_SHARE_DELIVERY_CONFIG_KEY = 'screen_share_delivery_config';
|
||||
const DOMAIN_MIGRATION_CONFIG_KEY = 'domain_migration_config';
|
||||
const EXPERIMENT_DELIVERY_CONFIG_KEY = 'experiment_delivery_config';
|
||||
const APP_PUBLIC_CONFIG_KEY = 'app_public_config';
|
||||
const INSTANCE_POLICY_CONFIG_KEY = 'instance_policy_config';
|
||||
@@ -356,13 +356,13 @@ describe('InstanceConfigRepository', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('returns the default screen share delivery config when the key is absent', async () => {
|
||||
it('returns the default domain migration config when the key is absent', async () => {
|
||||
const executor = new CountingInMemoryCassandraQueryExecutor();
|
||||
setCassandraQueryExecutorForTesting(executor);
|
||||
const kvProvider = new MockKVProvider();
|
||||
const repository = createRepository(kvProvider);
|
||||
|
||||
await expect(repository.getScreenShareDeliveryConfig()).resolves.toEqual(DEFAULT_SCREEN_SHARE_DELIVERY_CONFIG);
|
||||
await expect(repository.getDomainMigrationConfig()).resolves.toEqual(DEFAULT_DOMAIN_MIGRATION_CONFIG);
|
||||
});
|
||||
|
||||
it.each([
|
||||
@@ -370,56 +370,78 @@ describe('InstanceConfigRepository', () => {
|
||||
{name: 'a json array', stored: '[]'},
|
||||
{name: 'out-of-range values', stored: '{"rollout_basis_points":99999}'},
|
||||
{name: 'a non-boolean enabled flag', stored: '{"enabled":"yes"}'},
|
||||
])('falls back to the default screen share delivery config for $name', async ({stored}) => {
|
||||
])('falls back to the default domain migration config for $name', async ({stored}) => {
|
||||
const executor = new CountingInMemoryCassandraQueryExecutor();
|
||||
setCassandraQueryExecutorForTesting(executor);
|
||||
const kvProvider = new MockKVProvider();
|
||||
const repository = createRepository(kvProvider);
|
||||
|
||||
await repository.setConfig(SCREEN_SHARE_DELIVERY_CONFIG_KEY, stored);
|
||||
await repository.setConfig(DOMAIN_MIGRATION_CONFIG_KEY, stored);
|
||||
|
||||
await expect(repository.getScreenShareDeliveryConfig()).resolves.toEqual(DEFAULT_SCREEN_SHARE_DELIVERY_CONFIG);
|
||||
await expect(repository.getDomainMigrationConfig()).resolves.toEqual(DEFAULT_DOMAIN_MIGRATION_CONFIG);
|
||||
});
|
||||
|
||||
it('round-trips a stored screen share delivery config', async () => {
|
||||
it('round-trips a stored domain migration config', async () => {
|
||||
const executor = new CountingInMemoryCassandraQueryExecutor();
|
||||
setCassandraQueryExecutorForTesting(executor);
|
||||
const kvProvider = new MockKVProvider();
|
||||
const repository = createRepository(kvProvider);
|
||||
|
||||
const config: ScreenShareDeliveryConfig = {
|
||||
...DEFAULT_SCREEN_SHARE_DELIVERY_CONFIG,
|
||||
const config: DomainMigrationConfig = {
|
||||
...DEFAULT_DOMAIN_MIGRATION_CONFIG,
|
||||
enabled: true,
|
||||
config_version: 5,
|
||||
rollout_basis_points: 2500,
|
||||
rollout_salt: 'screen-share-delivery-v2',
|
||||
rollout_salt: 'domain-migration-v2',
|
||||
included_user_ids: ['1400000000000000001'],
|
||||
excluded_user_ids: ['1400000000000000002'],
|
||||
anonymous_rollout_basis_points: 300,
|
||||
standalone_forwarding: true,
|
||||
};
|
||||
await repository.setScreenShareDeliveryConfig(config);
|
||||
await repository.setDomainMigrationConfig(config);
|
||||
|
||||
await expect(repository.getScreenShareDeliveryConfig()).resolves.toEqual(config);
|
||||
await expect(repository.getDomainMigrationConfig()).resolves.toEqual(config);
|
||||
});
|
||||
|
||||
it('fills newly added screen share delivery fields from the schema defaults', async () => {
|
||||
it('fills newly added domain migration fields from the schema defaults', async () => {
|
||||
const executor = new CountingInMemoryCassandraQueryExecutor();
|
||||
setCassandraQueryExecutorForTesting(executor);
|
||||
const kvProvider = new MockKVProvider();
|
||||
const repository = createRepository(kvProvider);
|
||||
|
||||
await repository.setConfig(
|
||||
SCREEN_SHARE_DELIVERY_CONFIG_KEY,
|
||||
DOMAIN_MIGRATION_CONFIG_KEY,
|
||||
JSON.stringify({enabled: true, config_version: 2, rollout_basis_points: 1000}),
|
||||
);
|
||||
|
||||
await expect(repository.getScreenShareDeliveryConfig()).resolves.toEqual({
|
||||
...DEFAULT_SCREEN_SHARE_DELIVERY_CONFIG,
|
||||
await expect(repository.getDomainMigrationConfig()).resolves.toEqual({
|
||||
...DEFAULT_DOMAIN_MIGRATION_CONFIG,
|
||||
enabled: true,
|
||||
config_version: 2,
|
||||
rollout_basis_points: 1000,
|
||||
});
|
||||
});
|
||||
|
||||
it('publishes a refresh so another repository observes the domain migration config', async () => {
|
||||
const executor = new CountingInMemoryCassandraQueryExecutor();
|
||||
setCassandraQueryExecutorForTesting(executor);
|
||||
const kvProvider = new MockKVProvider();
|
||||
const reader = createRepository(kvProvider);
|
||||
const writer = createRepository(kvProvider);
|
||||
|
||||
await expect(reader.getDomainMigrationConfig()).resolves.toEqual(DEFAULT_DOMAIN_MIGRATION_CONFIG);
|
||||
|
||||
await writer.setDomainMigrationConfig({
|
||||
...DEFAULT_DOMAIN_MIGRATION_CONFIG,
|
||||
enabled: true,
|
||||
config_version: 1,
|
||||
});
|
||||
|
||||
await vi.waitFor(async () => {
|
||||
expect(await reader.getDomainMigrationConfig()).toMatchObject({enabled: true, config_version: 1});
|
||||
});
|
||||
});
|
||||
|
||||
it('returns the default experiment delivery config when the key is absent', async () => {
|
||||
const executor = new CountingInMemoryCassandraQueryExecutor();
|
||||
setCassandraQueryExecutorForTesting(executor);
|
||||
@@ -492,26 +514,6 @@ describe('InstanceConfigRepository', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('publishes a refresh so another repository observes the screen share delivery config', async () => {
|
||||
const executor = new CountingInMemoryCassandraQueryExecutor();
|
||||
setCassandraQueryExecutorForTesting(executor);
|
||||
const kvProvider = new MockKVProvider();
|
||||
const reader = createRepository(kvProvider);
|
||||
const writer = createRepository(kvProvider);
|
||||
|
||||
await expect(reader.getScreenShareDeliveryConfig()).resolves.toEqual(DEFAULT_SCREEN_SHARE_DELIVERY_CONFIG);
|
||||
|
||||
await writer.setScreenShareDeliveryConfig({
|
||||
...DEFAULT_SCREEN_SHARE_DELIVERY_CONFIG,
|
||||
enabled: true,
|
||||
config_version: 1,
|
||||
});
|
||||
|
||||
await vi.waitFor(async () => {
|
||||
expect(await reader.getScreenShareDeliveryConfig()).toMatchObject({enabled: true, config_version: 1});
|
||||
});
|
||||
});
|
||||
|
||||
it('uses the registration URL id as the admin-visible registration code', async () => {
|
||||
const executor = new CountingInMemoryCassandraQueryExecutor();
|
||||
setCassandraQueryExecutorForTesting(executor);
|
||||
|
||||
@@ -28,6 +28,10 @@ import {
|
||||
type PendingRegistrationResponse,
|
||||
type RegistrationUrlResponse,
|
||||
} from '@fluxer/schema/src/domains/admin/AdminSchemas';
|
||||
import {
|
||||
type DomainMigrationConfig,
|
||||
DomainMigrationConfigSchema,
|
||||
} from '@fluxer/schema/src/domains/admin/DomainMigrationSchemas';
|
||||
import {
|
||||
type GatewayRolloutConfig,
|
||||
GatewayRolloutConfigSchema,
|
||||
@@ -36,10 +40,6 @@ import {
|
||||
type PushServiceDeliveryConfig,
|
||||
PushServiceDeliveryConfigSchema,
|
||||
} from '@fluxer/schema/src/domains/admin/PushServiceDeliverySchemas';
|
||||
import {
|
||||
type ScreenShareDeliveryConfig,
|
||||
ScreenShareDeliveryConfigSchema,
|
||||
} from '@fluxer/schema/src/domains/admin/ScreenShareDeliverySchemas';
|
||||
import {
|
||||
type VoiceNoiseSuppressionConfig,
|
||||
VoiceNoiseSuppressionConfigSchema,
|
||||
@@ -66,8 +66,8 @@ import {z} from 'zod';
|
||||
|
||||
const GATEWAY_ROLLOUT_CONFIG_KEY = 'gateway_rollout_config';
|
||||
const VOICE_NOISE_SUPPRESSION_CONFIG_KEY = 'voice_noise_suppression_config';
|
||||
const SCREEN_SHARE_DELIVERY_CONFIG_KEY = 'screen_share_delivery_config';
|
||||
const PUSH_SERVICE_DELIVERY_CONFIG_KEY = 'push_service_delivery_config';
|
||||
const DOMAIN_MIGRATION_CONFIG_KEY = 'domain_migration_config';
|
||||
const EXPERIMENT_DELIVERY_CONFIG_KEY = 'experiment_delivery_config';
|
||||
const REGISTRATION_CONFIG_KEY = 'registration_config';
|
||||
const REGISTRATION_URLS_KEY = 'registration_urls';
|
||||
@@ -374,8 +374,8 @@ type StoredConfigSection =
|
||||
| 'app public'
|
||||
| 'gateway rollout'
|
||||
| 'voice noise suppression'
|
||||
| 'screen share delivery'
|
||||
| 'push service delivery'
|
||||
| 'domain migration'
|
||||
| 'experiment delivery'
|
||||
| 'instance policy'
|
||||
| 'integrations'
|
||||
@@ -514,14 +514,14 @@ function parseStoredVoiceNoiseSuppressionConfig(raw: string | null): VoiceNoiseS
|
||||
return parseStoredConfigOrDefault(VoiceNoiseSuppressionConfigSchema, raw, 'voice noise suppression');
|
||||
}
|
||||
|
||||
function parseStoredScreenShareDeliveryConfig(raw: string | null): ScreenShareDeliveryConfig {
|
||||
return parseStoredConfigOrDefault(ScreenShareDeliveryConfigSchema, raw, 'screen share delivery');
|
||||
}
|
||||
|
||||
function parseStoredPushServiceDeliveryConfig(raw: string | null): PushServiceDeliveryConfig {
|
||||
return parseStoredConfigOrDefault(PushServiceDeliveryConfigSchema, raw, 'push service delivery');
|
||||
}
|
||||
|
||||
function parseStoredDomainMigrationConfig(raw: string | null): DomainMigrationConfig {
|
||||
return parseStoredConfigOrDefault(DomainMigrationConfigSchema, raw, 'domain migration');
|
||||
}
|
||||
|
||||
function parseStoredExperimentDeliveryConfig(raw: string | null): ExperimentDeliveryConfig {
|
||||
return parseStoredConfigOrDefault(ExperimentDeliveryConfigSchema, raw, 'experiment delivery');
|
||||
}
|
||||
@@ -1169,8 +1169,8 @@ export class InstanceConfigRepository {
|
||||
parseStoredGatewayRolloutConfig(snapshot.get(GATEWAY_ROLLOUT_CONFIG_KEY) ?? null),
|
||||
);
|
||||
parseStoredVoiceNoiseSuppressionConfig(snapshot.get(VOICE_NOISE_SUPPRESSION_CONFIG_KEY) ?? null);
|
||||
parseStoredScreenShareDeliveryConfig(snapshot.get(SCREEN_SHARE_DELIVERY_CONFIG_KEY) ?? null);
|
||||
parseStoredPushServiceDeliveryConfig(snapshot.get(PUSH_SERVICE_DELIVERY_CONFIG_KEY) ?? null);
|
||||
parseStoredDomainMigrationConfig(snapshot.get(DOMAIN_MIGRATION_CONFIG_KEY) ?? null);
|
||||
parseStoredExperimentDeliveryConfig(snapshot.get(EXPERIMENT_DELIVERY_CONFIG_KEY) ?? null);
|
||||
const policy = parseStoredInstancePolicyConfig(snapshot.get(INSTANCE_POLICY_CONFIG_KEY) ?? null);
|
||||
checkStoredConfig('registration', () =>
|
||||
@@ -1267,27 +1267,6 @@ export class InstanceConfigRepository {
|
||||
);
|
||||
}
|
||||
|
||||
async getScreenShareDeliveryConfig(): Promise<ScreenShareDeliveryConfig> {
|
||||
const raw = await this.getConfig(SCREEN_SHARE_DELIVERY_CONFIG_KEY);
|
||||
return parseStoredScreenShareDeliveryConfig(raw);
|
||||
}
|
||||
|
||||
async setScreenShareDeliveryConfig(config: ScreenShareDeliveryConfig): Promise<void> {
|
||||
await this.updateScreenShareDeliveryConfig(() => config);
|
||||
}
|
||||
|
||||
updateScreenShareDeliveryConfig(
|
||||
update: (current: ScreenShareDeliveryConfig) => ScreenShareDeliveryConfig,
|
||||
): Promise<ScreenShareDeliveryConfig> {
|
||||
return this.updateStoredConfig(SCREEN_SHARE_DELIVERY_CONFIG_KEY, (raw) =>
|
||||
validateStoredConfig(
|
||||
ScreenShareDeliveryConfigSchema,
|
||||
update(parseStoredScreenShareDeliveryConfig(raw)),
|
||||
'screen share delivery',
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
async getPushServiceDeliveryConfig(): Promise<PushServiceDeliveryConfig> {
|
||||
const raw = await this.getConfig(PUSH_SERVICE_DELIVERY_CONFIG_KEY);
|
||||
return parseStoredPushServiceDeliveryConfig(raw);
|
||||
@@ -1305,6 +1284,27 @@ export class InstanceConfigRepository {
|
||||
);
|
||||
}
|
||||
|
||||
async getDomainMigrationConfig(): Promise<DomainMigrationConfig> {
|
||||
const raw = await this.getConfig(DOMAIN_MIGRATION_CONFIG_KEY);
|
||||
return parseStoredDomainMigrationConfig(raw);
|
||||
}
|
||||
|
||||
async setDomainMigrationConfig(config: DomainMigrationConfig): Promise<void> {
|
||||
await this.updateDomainMigrationConfig(() => config);
|
||||
}
|
||||
|
||||
updateDomainMigrationConfig(
|
||||
update: (current: DomainMigrationConfig) => DomainMigrationConfig,
|
||||
): Promise<DomainMigrationConfig> {
|
||||
return this.updateStoredConfig(DOMAIN_MIGRATION_CONFIG_KEY, (raw) =>
|
||||
validateStoredConfig(
|
||||
DomainMigrationConfigSchema,
|
||||
update(parseStoredDomainMigrationConfig(raw)),
|
||||
'domain migration',
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
async getExperimentDeliveryConfig(): Promise<ExperimentDeliveryConfig> {
|
||||
const raw = await this.getConfig(EXPERIMENT_DELIVERY_CONFIG_KEY);
|
||||
return parseStoredExperimentDeliveryConfig(raw);
|
||||
|
||||
@@ -8,6 +8,7 @@ import type {LimitConfigService} from '@app/api/limits/LimitConfigService';
|
||||
import {InMemoryCassandraQueryExecutor} from '@app/api/test/InMemoryCassandraQueryExecutor';
|
||||
import {MockKVProvider} from '@app/api/test/mocks/MockKVProvider';
|
||||
import type {HonoEnv} from '@app/api/types/HonoEnv';
|
||||
import {DEFAULT_DOMAIN_MIGRATION_CONFIG} from '@fluxer/schema/src/domains/admin/DomainMigrationSchemas';
|
||||
import {Hono} from 'hono';
|
||||
import {afterEach, describe, expect, it} from 'vitest';
|
||||
|
||||
@@ -96,4 +97,36 @@ describe('InstanceController discovery captcha', () => {
|
||||
turnstile_site_key: 'turnstile-site-key',
|
||||
});
|
||||
});
|
||||
|
||||
it('publishes the domain migration kill switch and anonymous rollout without the targeting lists', async () => {
|
||||
const repository = createRepository();
|
||||
const app = createApp(repository);
|
||||
|
||||
const initial = await app.request('http://localhost/.well-known/fluxer');
|
||||
expect(((await initial.json()) as {domain_migration: unknown}).domain_migration).toEqual({
|
||||
enabled: false,
|
||||
anonymous_rollout_basis_points: 0,
|
||||
rollout_salt: 'domain-migration-v1',
|
||||
standalone_forwarding: false,
|
||||
});
|
||||
|
||||
await repository.setDomainMigrationConfig({
|
||||
...DEFAULT_DOMAIN_MIGRATION_CONFIG,
|
||||
enabled: true,
|
||||
config_version: 2,
|
||||
rollout_basis_points: 100,
|
||||
anonymous_rollout_basis_points: 1500,
|
||||
included_user_ids: ['1400000000000000001'],
|
||||
standalone_forwarding: true,
|
||||
});
|
||||
|
||||
const updated = await app.request('http://localhost/.well-known/fluxer');
|
||||
expect(updated.headers.get('etag')).not.toBe(initial.headers.get('etag'));
|
||||
expect(((await updated.json()) as {domain_migration: unknown}).domain_migration).toEqual({
|
||||
enabled: true,
|
||||
anonymous_rollout_basis_points: 1500,
|
||||
rollout_salt: 'domain-migration-v1',
|
||||
standalone_forwarding: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -16,6 +16,7 @@ import type {HonoEnv} from '@app/api/types/HonoEnv';
|
||||
import {API_CODE_VERSION} from '@fluxer/constants/src/AppConstants';
|
||||
import {buildDiscoveryResponse, type DiscoveryStaticInput} from '@fluxer/instance_bootstrap/src/BuildDiscovery';
|
||||
import type {InstanceAppPublic} from '@fluxer/instance_bootstrap/src/Types';
|
||||
import {toDomainMigrationDiscovery} from '@fluxer/schema/src/domains/admin/DomainMigrationSchemas';
|
||||
import {WellKnownFluxerResponse} from '@fluxer/schema/src/domains/instance/InstanceSchemas';
|
||||
import type {Hono} from 'hono';
|
||||
|
||||
@@ -102,15 +103,16 @@ export function InstanceController(app: Hono<HonoEnv>) {
|
||||
const limits = ctx.get('limitConfigService').getConfigWireFormat();
|
||||
const sso = await ctx.get('ssoService').getPublicStatus();
|
||||
const instanceConfigRepository = ctx.get('instanceConfigRepository');
|
||||
const [registration, community, services, appPublicConfig, captcha, email] = await Promise.all([
|
||||
const [registration, community, services, appPublicConfig, captcha, email, domainMigration] = await Promise.all([
|
||||
instanceConfigRepository.getRegistrationPublicConfig(),
|
||||
instanceConfigRepository.getInstanceCommunityPublicConfig(),
|
||||
instanceConfigRepository.getResolvedServicesConfig(),
|
||||
instanceConfigRepository.getAppPublicConfig(),
|
||||
instanceConfigRepository.getEffectiveCaptchaConfig(),
|
||||
instanceConfigRepository.getEffectiveEmailConfig(),
|
||||
instanceConfigRepository.getDomainMigrationConfig(),
|
||||
]);
|
||||
const response = buildDiscoveryResponse(
|
||||
const discovery = buildDiscoveryResponse(
|
||||
buildDiscoveryStaticInput(
|
||||
gifService,
|
||||
{
|
||||
@@ -133,6 +135,7 @@ export function InstanceController(app: Hono<HonoEnv>) {
|
||||
limits,
|
||||
},
|
||||
);
|
||||
const response = {...discovery, domain_migration: toDomainMigrationDiscovery(domainMigration)};
|
||||
discoveryValidators = nextDiscoveryValidators(response, discoveryValidators);
|
||||
ctx.header('ETag', discoveryValidators.etag);
|
||||
ctx.header('Last-Modified', discoveryValidators.lastModified.toUTCString());
|
||||
|
||||
@@ -1,11 +1,17 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {Config} from '@app/api/Config';
|
||||
import {InvalidApiOriginError} from '@fluxer/errors/src/domains/core/InvalidApiOriginError';
|
||||
import type {Context, Next} from 'hono';
|
||||
|
||||
const LEGACY_APP_ORIGINS = ['https://web.fluxer.app', 'https://web.canary.fluxer.app'];
|
||||
|
||||
export async function BlockAppOriginMiddleware(ctx: Context, next: Next) {
|
||||
const origin = ctx.req.header('origin');
|
||||
if (origin === 'https://web.fluxer.app' || origin === 'https://web.canary.fluxer.app') {
|
||||
if (
|
||||
origin !== undefined &&
|
||||
(LEGACY_APP_ORIGINS.includes(origin) || Config.endpoints.webAppOrigins.includes(origin))
|
||||
) {
|
||||
throw new InvalidApiOriginError();
|
||||
}
|
||||
await next();
|
||||
|
||||
@@ -15,6 +15,7 @@ interface RequireClientIpOptions {
|
||||
|
||||
const defaultExemptPaths: Array<string> = [
|
||||
'/_health',
|
||||
'/internal',
|
||||
'/webhooks/livekit',
|
||||
'/test',
|
||||
'/connections/bluesky/client-metadata.json',
|
||||
|
||||
@@ -5,7 +5,7 @@ import {Logger} from '@app/api/Logger';
|
||||
import {hashAuthToken, recordAbuseSignal} from '@app/api/middleware/AbusiveIpAutoBanner';
|
||||
import type {User} from '@app/api/models/User';
|
||||
import type {HonoEnv} from '@app/api/types/HonoEnv';
|
||||
import {requireRequestClientIp} from '@app/api/utils/RequestClientIp';
|
||||
import {getRequestClientIp} from '@app/api/utils/RequestClientIp';
|
||||
import {stripApiPrefix} from '@app/api/utils/RequestPathUtils';
|
||||
import type {Context} from 'hono';
|
||||
import {createMiddleware} from 'hono/factory';
|
||||
@@ -60,7 +60,7 @@ function setUserInContext(ctx: Context<HonoEnv>, user: User, trackActivity: bool
|
||||
ctx.set('user', user);
|
||||
if (trackActivity) {
|
||||
const now = new Date();
|
||||
const ip = requireRequestClientIp(ctx);
|
||||
const ip = getRequestClientIp(ctx);
|
||||
const kvActivityTracker = ctx.get('kvActivityTracker');
|
||||
const userActivityBuffer = ctx.get('userActivityBuffer');
|
||||
userActivityBuffer.recordActivity(user.id, now, ip);
|
||||
@@ -77,7 +77,7 @@ export const UserMiddleware = createMiddleware<HonoEnv>(async (ctx, next) => {
|
||||
}
|
||||
const rawAuthHeader = ctx.req.header('Authorization');
|
||||
const parsed = parseAuthHeader(rawAuthHeader);
|
||||
const resolvedClientIp = requireRequestClientIp(ctx);
|
||||
const resolvedClientIp = getRequestClientIp(ctx);
|
||||
ctx.set('oauthBearerToken', undefined);
|
||||
ctx.set('oauthBearerApplicationId', undefined);
|
||||
ctx.set('oauthBearerAllowed', false);
|
||||
|
||||
@@ -22,6 +22,7 @@ function createHarness(path = 'http://localhost/v1/messages'): Harness {
|
||||
return ctx.text('ok');
|
||||
});
|
||||
app.get('/_health', (ctx) => ctx.text('OK'));
|
||||
app.get('/internal/rpc', (ctx) => ctx.text('OK'));
|
||||
app.onError(AppErrorHandler);
|
||||
return {
|
||||
request: async (headers) => app.request(path, {headers}),
|
||||
@@ -80,6 +81,12 @@ describe('RequireClientIpMiddleware', () => {
|
||||
expect(response.status).toBe(200);
|
||||
});
|
||||
|
||||
it('leaves internal service to service calls alone', async () => {
|
||||
const harness = createHarness('http://localhost/internal/rpc');
|
||||
const response = await harness.request({});
|
||||
expect(response.status).toBe(200);
|
||||
});
|
||||
|
||||
it('passes every request through in test mode', async () => {
|
||||
Config.dev.testModeEnabled = true;
|
||||
const harness = createHarness();
|
||||
|
||||
@@ -948,6 +948,111 @@
|
||||
"security": [{"botToken": []}, {"sessionToken": []}]
|
||||
}
|
||||
},
|
||||
"/auth/origin-handoff": {
|
||||
"post": {
|
||||
"operationId": "create_origin_handoff",
|
||||
"summary": "Create origin handoff",
|
||||
"tags": ["Auth"],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success",
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/OriginHandoffCreateResponse"}}}
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad Request - The request was malformed or contained invalid data",
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
|
||||
},
|
||||
"401": {
|
||||
"description": "Unauthorized - Authentication is required or the token is invalid",
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
|
||||
},
|
||||
"403": {
|
||||
"description": "Forbidden - You do not have permission to perform this action",
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
|
||||
},
|
||||
"429": {
|
||||
"description": "Too Many Requests - You are being rate limited",
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/ThrottledError"}}},
|
||||
"headers": {
|
||||
"Retry-After": {
|
||||
"description": "Number of seconds to wait before retrying (only on 429)",
|
||||
"schema": {"type": "integer"}
|
||||
},
|
||||
"X-RateLimit-Limit": {
|
||||
"description": "The number of requests that can be made in the current window",
|
||||
"schema": {"type": "integer"}
|
||||
},
|
||||
"X-RateLimit-Remaining": {
|
||||
"description": "The number of remaining requests that can be made",
|
||||
"schema": {"type": "integer"}
|
||||
},
|
||||
"X-RateLimit-Reset": {
|
||||
"description": "Unix timestamp when the rate limit resets",
|
||||
"schema": {"type": "integer"}
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal Server Error - An unexpected error occurred",
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
|
||||
}
|
||||
},
|
||||
"description": "Store encrypted client state for up to two minutes so another first-party web origin can redeem it once. The receiving origin must present the nonce whose SHA-256 digest is sent here.",
|
||||
"security": [{"sessionToken": []}],
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/OriginHandoffCreateRequest"}}}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/auth/origin-handoff/redeem": {
|
||||
"post": {
|
||||
"operationId": "redeem_origin_handoff",
|
||||
"summary": "Redeem origin handoff",
|
||||
"tags": ["Auth"],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success",
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/OriginHandoffRedeemResponse"}}}
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad Request - The request was malformed or contained invalid data",
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
|
||||
},
|
||||
"429": {
|
||||
"description": "Too Many Requests - You are being rate limited",
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/ThrottledError"}}},
|
||||
"headers": {
|
||||
"Retry-After": {
|
||||
"description": "Number of seconds to wait before retrying (only on 429)",
|
||||
"schema": {"type": "integer"}
|
||||
},
|
||||
"X-RateLimit-Limit": {
|
||||
"description": "The number of requests that can be made in the current window",
|
||||
"schema": {"type": "integer"}
|
||||
},
|
||||
"X-RateLimit-Remaining": {
|
||||
"description": "The number of remaining requests that can be made",
|
||||
"schema": {"type": "integer"}
|
||||
},
|
||||
"X-RateLimit-Reset": {
|
||||
"description": "Unix timestamp when the rate limit resets",
|
||||
"schema": {"type": "integer"}
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal Server Error - An unexpected error occurred",
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
|
||||
}
|
||||
},
|
||||
"description": "Return the encrypted client state stored by create origin handoff and delete it in the same step. A wrong nonce also consumes the handoff. On the official instance the request must come from a first-party web origin.",
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/OriginHandoffRedeemRequest"}}}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/auth/register": {
|
||||
"post": {
|
||||
"operationId": "register_account",
|
||||
@@ -22495,7 +22600,8 @@
|
||||
"required": ["p256dh", "auth"],
|
||||
"description": "Encryption keys for the push subscription"
|
||||
},
|
||||
"user_agent": {"description": "The user agent string identifying the client", "type": "string"}
|
||||
"user_agent": {"description": "The user agent string identifying the client", "type": "string"},
|
||||
"installed_app": {"description": "Whether the client runs in an installed web app window", "type": "boolean"}
|
||||
},
|
||||
"required": ["endpoint", "keys"]
|
||||
},
|
||||
@@ -22524,7 +22630,8 @@
|
||||
"required": ["p256dh", "auth"],
|
||||
"description": "Encryption keys for the new push subscription"
|
||||
},
|
||||
"user_agent": {"description": "The user agent string identifying the client", "type": "string"}
|
||||
"user_agent": {"description": "The user agent string identifying the client", "type": "string"},
|
||||
"installed_app": {"description": "Whether the client runs in an installed web app window", "type": "boolean"}
|
||||
},
|
||||
"required": ["old_endpoint", "endpoint", "keys"]
|
||||
},
|
||||
@@ -22735,13 +22842,14 @@
|
||||
"properties": {
|
||||
"platform": {
|
||||
"description": "The mobile push notification platform",
|
||||
"x-enumNames": ["ANDROID_FCM", "IOS_APNS", "ANDROID_UNIFIED_PUSH"],
|
||||
"x-enumNames": ["ANDROID_FCM", "IOS_APNS", "IOS_APNS_VOIP", "ANDROID_UNIFIED_PUSH"],
|
||||
"x-enumDescriptions": [
|
||||
"Firebase Cloud Messaging (Android)",
|
||||
"Apple Push Notification Service (iOS)",
|
||||
"Apple PushKit VoIP push, used only to ring an incoming call (iOS)",
|
||||
"UnifiedPush (Android without Google services)"
|
||||
],
|
||||
"enum": ["android_fcm", "ios_apns", "android_unified_push"],
|
||||
"enum": ["android_fcm", "ios_apns", "ios_apns_voip", "android_unified_push"],
|
||||
"type": "string"
|
||||
},
|
||||
"token": {
|
||||
@@ -22802,13 +22910,14 @@
|
||||
"properties": {
|
||||
"platform": {
|
||||
"description": "The mobile push notification platform",
|
||||
"x-enumNames": ["ANDROID_FCM", "IOS_APNS", "ANDROID_UNIFIED_PUSH"],
|
||||
"x-enumNames": ["ANDROID_FCM", "IOS_APNS", "IOS_APNS_VOIP", "ANDROID_UNIFIED_PUSH"],
|
||||
"x-enumDescriptions": [
|
||||
"Firebase Cloud Messaging (Android)",
|
||||
"Apple Push Notification Service (iOS)",
|
||||
"Apple PushKit VoIP push, used only to ring an incoming call (iOS)",
|
||||
"UnifiedPush (Android without Google services)"
|
||||
],
|
||||
"enum": ["android_fcm", "ios_apns", "android_unified_push"],
|
||||
"enum": ["android_fcm", "ios_apns", "ios_apns_voip", "android_unified_push"],
|
||||
"type": "string"
|
||||
},
|
||||
"token": {
|
||||
@@ -27189,7 +27298,7 @@
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"voice_noise_suppression": {"$ref": "#/components/schemas/VoiceNoiseSuppressionAssignmentResponse"},
|
||||
"screen_share_delivery": {"$ref": "#/components/schemas/ScreenShareDeliveryAssignmentResponse"}
|
||||
"domain_migration": {"$ref": "#/components/schemas/DomainMigrationAssignmentResponse"}
|
||||
},
|
||||
"additionalProperties": false
|
||||
}
|
||||
@@ -28402,6 +28511,56 @@
|
||||
{"$ref": "#/components/schemas/AuthRegistrationPendingApprovalResponse"}
|
||||
]
|
||||
},
|
||||
"OriginHandoffRedeemRequest": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"handoff_id": {
|
||||
"type": "string",
|
||||
"pattern": "^[A-Za-z0-9_-]{43}$",
|
||||
"description": "Identifier returned when the handoff was created"
|
||||
},
|
||||
"nonce": {
|
||||
"type": "string",
|
||||
"minLength": 16,
|
||||
"maxLength": 256,
|
||||
"pattern": "^[A-Za-z0-9_-]+$",
|
||||
"description": "Nonce whose SHA-256 digest was sent when the handoff was created"
|
||||
}
|
||||
},
|
||||
"required": ["handoff_id", "nonce"]
|
||||
},
|
||||
"OriginHandoffRedeemResponse": {
|
||||
"type": "object",
|
||||
"properties": {"payload": {"type": "string", "description": "Encrypted client state encoded as base64url"}},
|
||||
"required": ["payload"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"OriginHandoffCreateRequest": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"nonce_hash": {
|
||||
"type": "string",
|
||||
"pattern": "^[0-9a-f]{64}$",
|
||||
"description": "Lowercase hex SHA-256 digest of the nonce the receiving origin holds"
|
||||
},
|
||||
"payload": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 8388608,
|
||||
"pattern": "^[A-Za-z0-9_-]+$",
|
||||
"description": "Encrypted client state encoded as base64url"
|
||||
}
|
||||
},
|
||||
"required": ["nonce_hash", "payload"]
|
||||
},
|
||||
"OriginHandoffCreateResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"handoff_id": {"type": "string", "description": "Single-use identifier the receiving origin redeems"}
|
||||
},
|
||||
"required": ["handoff_id"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"MfaTicketRequest": {
|
||||
"type": "object",
|
||||
"properties": {"ticket": {"description": "The MFA ticket from the login response", "type": "string"}},
|
||||
@@ -28765,6 +28924,10 @@
|
||||
},
|
||||
"description": "Public application configuration for client-side features",
|
||||
"$ref": "#/components/schemas/InstanceAppPublicSchema"
|
||||
},
|
||||
"domain_migration": {
|
||||
"description": "Web domain migration switch and anonymous rollout, only acted on by official instance clients",
|
||||
"$ref": "#/components/schemas/DomainMigrationDiscoveryResponse"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
@@ -28783,6 +28946,31 @@
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"DomainMigrationDiscoveryResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"enabled": {"type": "boolean", "description": "Whether the domain migration is switched on"},
|
||||
"anonymous_rollout_basis_points": {
|
||||
"type": "integer",
|
||||
"minimum": 0,
|
||||
"maximum": 10000,
|
||||
"description": "Share of logged-out devices, in basis points, that move to the new domain"
|
||||
},
|
||||
"rollout_salt": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 64,
|
||||
"pattern": "^[\\x20-\\x7e]+$",
|
||||
"description": "Salt used to bucket devices and users"
|
||||
},
|
||||
"standalone_forwarding": {
|
||||
"type": "boolean",
|
||||
"description": "Whether installed desktop web apps forward to the new domain after moving their session"
|
||||
}
|
||||
},
|
||||
"required": ["enabled", "anonymous_rollout_basis_points", "rollout_salt", "standalone_forwarding"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"InstanceAppPublicSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -30693,7 +30881,7 @@
|
||||
"additionalProperties": false
|
||||
},
|
||||
"DonationCurrency": {"type": "string", "enum": ["usd", "eur", "brl", "inr", "pln", "try", "sek", "dkk", "nok"]},
|
||||
"ScreenShareDeliveryAssignmentResponse": {
|
||||
"DomainMigrationAssignmentResponse": {
|
||||
"type": "object",
|
||||
"properties": {"enabled": {"type": "boolean"}},
|
||||
"required": ["enabled"],
|
||||
|
||||
@@ -204,9 +204,7 @@ function buildApnsPayload(payload: Record<string, unknown>): Record<string, unkn
|
||||
if (badge !== undefined) {
|
||||
aps.badge = badge;
|
||||
}
|
||||
if (imageUrl) {
|
||||
aps['mutable-content'] = 1;
|
||||
}
|
||||
aps['mutable-content'] = 1;
|
||||
return {
|
||||
...data,
|
||||
title,
|
||||
|
||||
@@ -139,7 +139,7 @@ describe('ApnsPushService', () => {
|
||||
notification: {title: 'Alice', body: 'Hello', icon: 'https://cdn.example/avatar.png'},
|
||||
});
|
||||
expect(payload.image_url).toBeUndefined();
|
||||
expect(payload.aps).not.toHaveProperty('mutable-content');
|
||||
expect(payload.aps).toHaveProperty('mutable-content', 1);
|
||||
expect(payload.author_avatar_url).toBe('https://cdn.example/avatar.png');
|
||||
});
|
||||
it('imports the APNs signing key once per PEM and rejects a truncated one every time', async () => {
|
||||
|
||||
@@ -132,6 +132,14 @@ export const AuthRateLimitConfigs = {
|
||||
bucket: 'auth:handoff:cancel',
|
||||
config: {limit: 10, windowMs: ms('1 minute')},
|
||||
} as RouteRateLimitConfig,
|
||||
AUTH_ORIGIN_HANDOFF_CREATE: {
|
||||
bucket: 'auth:origin_handoff:create',
|
||||
config: {limit: 3, windowMs: ms('10 minutes')},
|
||||
} as RouteRateLimitConfig,
|
||||
AUTH_ORIGIN_HANDOFF_REDEEM: {
|
||||
bucket: 'auth:origin_handoff:redeem',
|
||||
config: {limit: 10, windowMs: ms('1 minute')},
|
||||
} as RouteRateLimitConfig,
|
||||
SUDO_WEBAUTHN_OPTIONS: {
|
||||
bucket: 'sudo:webauthn:options',
|
||||
config: {limit: 10, windowMs: ms('1 minute')},
|
||||
|
||||
@@ -1,6 +1,29 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {Config} from '@app/api/Config';
|
||||
import {PHONE_REQUIREMENT_FLAGS, SuspiciousActivityFlags} from '@fluxer/constants/src/UserConstants';
|
||||
|
||||
const EMAIL_ONLY_EQUIVALENTS: ReadonlyArray<readonly [number, number]> = [
|
||||
[SuspiciousActivityFlags.REQUIRE_VERIFIED_EMAIL_OR_VERIFIED_PHONE, SuspiciousActivityFlags.REQUIRE_VERIFIED_EMAIL],
|
||||
[SuspiciousActivityFlags.REQUIRE_VERIFIED_EMAIL_OR_REVERIFIED_PHONE, SuspiciousActivityFlags.REQUIRE_VERIFIED_EMAIL],
|
||||
[
|
||||
SuspiciousActivityFlags.REQUIRE_REVERIFIED_EMAIL_OR_VERIFIED_PHONE,
|
||||
SuspiciousActivityFlags.REQUIRE_REVERIFIED_EMAIL,
|
||||
],
|
||||
[
|
||||
SuspiciousActivityFlags.REQUIRE_REVERIFIED_EMAIL_OR_REVERIFIED_PHONE,
|
||||
SuspiciousActivityFlags.REQUIRE_REVERIFIED_EMAIL,
|
||||
],
|
||||
];
|
||||
|
||||
const PHONE_OFFERING_FLAGS = EMAIL_ONLY_EQUIVALENTS.reduce((mask, [either]) => mask | either, PHONE_REQUIREMENT_FLAGS);
|
||||
|
||||
function withoutPhoneOfferingFlags(flagBits: number): number {
|
||||
return EMAIL_ONLY_EQUIVALENTS.reduce(
|
||||
(next, [either, emailOnly]) => ((flagBits & either) !== 0 ? next | emailOnly : next),
|
||||
flagBits & ~PHONE_OFFERING_FLAGS,
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeCountryCode(countryCode: string | null | undefined): string | null {
|
||||
const trimmed = countryCode?.trim();
|
||||
@@ -17,6 +40,25 @@ export function countryRequiresInboundPhoneVerification(countryCode: string | nu
|
||||
return configuredCountrySet(Config.abusePolicy.inboundPhoneCountryCodes).has(normalized);
|
||||
}
|
||||
|
||||
export function phoneFlaggingAllowedForCountry(countryCode: string | null | undefined): boolean {
|
||||
const {enabled, exemptCountryCodes} = Config.abusePolicy.phoneFlagging;
|
||||
if (!enabled) return false;
|
||||
const normalized = normalizeCountryCode(countryCode);
|
||||
if (!normalized) return true;
|
||||
return !configuredCountrySet(exemptCountryCodes).has(normalized);
|
||||
}
|
||||
|
||||
export async function stripDisallowedPhoneFlags(
|
||||
flagBits: number,
|
||||
resolveCountryCode: () => Promise<string | null>,
|
||||
): Promise<number> {
|
||||
if ((flagBits & PHONE_OFFERING_FLAGS) === 0) return flagBits;
|
||||
const {enabled, exemptCountryCodes} = Config.abusePolicy.phoneFlagging;
|
||||
if (enabled && exemptCountryCodes.length === 0) return flagBits;
|
||||
if (enabled && phoneFlaggingAllowedForCountry(await resolveCountryCode())) return flagBits;
|
||||
return withoutPhoneOfferingFlags(flagBits);
|
||||
}
|
||||
|
||||
export function phoneRequiresInboundVerification(
|
||||
phone: string,
|
||||
prefixes: ReadonlyArray<string> = Config.abusePolicy.phoneVerification.inboundRequiredPrefixes,
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {Config} from '@app/api/Config';
|
||||
import {phoneFlaggingAllowedForCountry, stripDisallowedPhoneFlags} from '@app/api/risk/AbusePolicy';
|
||||
import {SuspiciousActivityFlags} from '@fluxer/constants/src/UserConstants';
|
||||
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
|
||||
|
||||
const PHONE_AND_EMAIL =
|
||||
SuspiciousActivityFlags.REQUIRE_VERIFIED_EMAIL |
|
||||
SuspiciousActivityFlags.REQUIRE_VERIFIED_PHONE |
|
||||
SuspiciousActivityFlags.REQUIRE_INBOUND_PHONE_VERIFICATION;
|
||||
|
||||
describe('phone flagging policy', () => {
|
||||
const original = {...Config.abusePolicy.phoneFlagging};
|
||||
|
||||
beforeEach(() => {
|
||||
Config.abusePolicy.phoneFlagging = {enabled: true, exemptCountryCodes: []};
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
Config.abusePolicy.phoneFlagging = original;
|
||||
});
|
||||
|
||||
it('keeps phone flags by default without resolving the country', async () => {
|
||||
const resolveCountryCode = vi.fn(async () => 'NG');
|
||||
expect(await stripDisallowedPhoneFlags(PHONE_AND_EMAIL, resolveCountryCode)).toBe(PHONE_AND_EMAIL);
|
||||
expect(resolveCountryCode).not.toHaveBeenCalled();
|
||||
expect(phoneFlaggingAllowedForCountry('NG')).toBe(true);
|
||||
});
|
||||
|
||||
it('strips only phone flags when disabled', async () => {
|
||||
Config.abusePolicy.phoneFlagging = {enabled: false, exemptCountryCodes: []};
|
||||
const resolveCountryCode = vi.fn(async () => 'NG');
|
||||
expect(await stripDisallowedPhoneFlags(PHONE_AND_EMAIL, resolveCountryCode)).toBe(
|
||||
SuspiciousActivityFlags.REQUIRE_VERIFIED_EMAIL,
|
||||
);
|
||||
expect(resolveCountryCode).not.toHaveBeenCalled();
|
||||
expect(phoneFlaggingAllowedForCountry('NG')).toBe(false);
|
||||
expect(phoneFlaggingAllowedForCountry(null)).toBe(false);
|
||||
});
|
||||
|
||||
it('strips phone flags for exempt countries only', async () => {
|
||||
Config.abusePolicy.phoneFlagging = {enabled: true, exemptCountryCodes: [' br', 'PT']};
|
||||
expect(await stripDisallowedPhoneFlags(PHONE_AND_EMAIL, async () => 'BR')).toBe(
|
||||
SuspiciousActivityFlags.REQUIRE_VERIFIED_EMAIL,
|
||||
);
|
||||
expect(await stripDisallowedPhoneFlags(PHONE_AND_EMAIL, async () => 'ng')).toBe(PHONE_AND_EMAIL);
|
||||
expect(await stripDisallowedPhoneFlags(PHONE_AND_EMAIL, async () => null)).toBe(PHONE_AND_EMAIL);
|
||||
expect(phoneFlaggingAllowedForCountry('pt')).toBe(false);
|
||||
expect(phoneFlaggingAllowedForCountry('NG')).toBe(true);
|
||||
});
|
||||
|
||||
it('replaces email or phone flags with their email only equivalent', async () => {
|
||||
Config.abusePolicy.phoneFlagging = {enabled: false, exemptCountryCodes: []};
|
||||
expect(
|
||||
await stripDisallowedPhoneFlags(
|
||||
SuspiciousActivityFlags.REQUIRE_VERIFIED_EMAIL_OR_VERIFIED_PHONE |
|
||||
SuspiciousActivityFlags.REQUIRE_VERIFIED_EMAIL_OR_REVERIFIED_PHONE,
|
||||
async () => null,
|
||||
),
|
||||
).toBe(SuspiciousActivityFlags.REQUIRE_VERIFIED_EMAIL);
|
||||
expect(
|
||||
await stripDisallowedPhoneFlags(
|
||||
SuspiciousActivityFlags.REQUIRE_REVERIFIED_EMAIL_OR_VERIFIED_PHONE |
|
||||
SuspiciousActivityFlags.REQUIRE_INBOUND_PHONE_VERIFICATION,
|
||||
async () => null,
|
||||
),
|
||||
).toBe(SuspiciousActivityFlags.REQUIRE_REVERIFIED_EMAIL);
|
||||
Config.abusePolicy.phoneFlagging = {enabled: true, exemptCountryCodes: ['BR']};
|
||||
expect(
|
||||
await stripDisallowedPhoneFlags(
|
||||
SuspiciousActivityFlags.REQUIRE_REVERIFIED_EMAIL_OR_REVERIFIED_PHONE,
|
||||
async () => 'BR',
|
||||
),
|
||||
).toBe(SuspiciousActivityFlags.REQUIRE_REVERIFIED_EMAIL);
|
||||
});
|
||||
|
||||
it('skips the country lookup when no phone flags are present', async () => {
|
||||
Config.abusePolicy.phoneFlagging = {enabled: true, exemptCountryCodes: ['BR']};
|
||||
const resolveCountryCode = vi.fn(async () => 'BR');
|
||||
expect(await stripDisallowedPhoneFlags(SuspiciousActivityFlags.REQUIRE_VERIFIED_EMAIL, resolveCountryCode)).toBe(
|
||||
SuspiciousActivityFlags.REQUIRE_VERIFIED_EMAIL,
|
||||
);
|
||||
expect(resolveCountryCode).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -10,7 +10,7 @@ import type {UserCacheService} from '@app/api/infrastructure/UserCacheService';
|
||||
import {Logger} from '@app/api/Logger';
|
||||
import type {RequestCache} from '@app/api/middleware/RequestCacheMiddleware';
|
||||
import type {User} from '@app/api/models/User';
|
||||
import {countryRequiresInboundPhoneVerification} from '@app/api/risk/AbusePolicy';
|
||||
import {countryRequiresInboundPhoneVerification, phoneFlaggingAllowedForCountry} from '@app/api/risk/AbusePolicy';
|
||||
import {
|
||||
createRpcTimingNode,
|
||||
RpcTimingRecorder,
|
||||
@@ -311,6 +311,17 @@ export class RpcSessionStartService {
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
if (
|
||||
!timeRpcStepSync(timingSteps, 'check_phone_flagging_allowed', () =>
|
||||
phoneFlaggingAllowedForCountry(geoipCountryIso),
|
||||
)
|
||||
) {
|
||||
Logger.info(
|
||||
{userId: user.id.toString(), countryIso: geoipCountryIso},
|
||||
'Skipping configured-country inbound phone requirement: phone flagging disabled for this country',
|
||||
);
|
||||
return null;
|
||||
}
|
||||
if (
|
||||
timeRpcStepSync(timingSteps, 'check_not_suspicious_flag', () => (user.flags & UserFlags.NOT_SUSPICIOUS) !== 0n)
|
||||
) {
|
||||
|
||||
@@ -52,7 +52,8 @@ function snowflakeSeconds(snowflake: string): number {
|
||||
|
||||
function buildSort(sortBy: string, sortOrder: 'asc' | 'desc' | undefined): Array<string> | undefined {
|
||||
if (sortBy === 'relevance') return undefined;
|
||||
return [`${sortBy}:${sortOrder ?? 'desc'}`, 'id:desc'];
|
||||
const direction = sortOrder ?? 'desc';
|
||||
return [`${sortBy}:${direction}`, `id:${direction}`];
|
||||
}
|
||||
|
||||
function buildTimestampSort(filters: MessageSearchFilters | AuditLogSearchFilters): Array<string> | undefined {
|
||||
|
||||
@@ -117,7 +117,7 @@ describe('MeilisearchMessageAdapter', () => {
|
||||
'(guildId = "guild-1") AND ((channelId = "channel-\\"quoted\\"" OR channelId = "channel-2")) AND (mentionedUserIds = "user-1")',
|
||||
limit: 10,
|
||||
offset: 20,
|
||||
sort: ['createdAt:asc', 'id:desc'],
|
||||
sort: ['createdAt:asc', 'id:asc'],
|
||||
attributesToSearchOn: ['content', 'embedContent'],
|
||||
showRankingScore: false,
|
||||
},
|
||||
|
||||
@@ -39,7 +39,6 @@ import type Stripe from 'stripe';
|
||||
|
||||
const PRODUCT_NAME = 'Fluxer';
|
||||
const PREMIUM_TIER_NAME = 'Plutonium';
|
||||
const TERMS_URL = 'https://fluxer.app/terms';
|
||||
export const EU_WITHDRAWAL_WAIVER_TEXT_VERSION = '2026-04-23';
|
||||
|
||||
type CheckoutSessionCreateParams = Stripe.Checkout.SessionCreateParams;
|
||||
@@ -226,7 +225,7 @@ export class StripeCheckoutService {
|
||||
message: getContentMessage('billing.eu_withdrawal_waiver_checkout', user.locale, {
|
||||
product_name: PRODUCT_NAME,
|
||||
premium_tier_name: PREMIUM_TIER_NAME,
|
||||
terms_url: TERMS_URL,
|
||||
terms_url: `${Config.endpoints.marketing}/terms`,
|
||||
}),
|
||||
},
|
||||
},
|
||||
|
||||
@@ -19,6 +19,7 @@ import {GuildMemberRepository} from '@app/api/guild/repositories/GuildMemberRepo
|
||||
import {GuildRepository} from '@app/api/guild/repositories/GuildRepository';
|
||||
import {GuildRoleRepository} from '@app/api/guild/repositories/GuildRoleRepository';
|
||||
import {
|
||||
type CallCaller,
|
||||
type CallData,
|
||||
type GatewayChannelMention,
|
||||
type GatewayGuildMemoryStats,
|
||||
@@ -925,6 +926,7 @@ export class NoopGatewayService extends IGatewayService {
|
||||
_region: string,
|
||||
_ringing: Array<string>,
|
||||
_recipients: Array<string>,
|
||||
_caller?: CallCaller,
|
||||
): Promise<CallData> {
|
||||
return {
|
||||
channel_id: _channelId.toString(),
|
||||
@@ -940,7 +942,7 @@ export class NoopGatewayService extends IGatewayService {
|
||||
return true;
|
||||
}
|
||||
|
||||
async ringCallRecipients(_channelId: ChannelID, _recipients: Array<string>): Promise<boolean> {
|
||||
async ringCallRecipients(_channelId: ChannelID, _recipients: Array<string>, _caller?: CallCaller): Promise<boolean> {
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {Config} from '@app/api/Config';
|
||||
import type {User} from '@app/api/models/User';
|
||||
import {setInjectedAccountPolicyEvaluator} from '@app/api/risk/AccountPolicyService';
|
||||
import {setCachedDeferredPhoneGateEnabled} from '@app/api/risk/DeferredPhoneGateCache';
|
||||
@@ -52,6 +53,20 @@ describe('deferred phone gate marker', () => {
|
||||
});
|
||||
expect(getRequiredActions(user)).toEqual(['REQUIRE_VERIFIED_PHONE']);
|
||||
});
|
||||
it('keeps a deferral suppressed when the gate reads off but phone flagging is disabled', () => {
|
||||
setCachedDeferredPhoneGateEnabled(false);
|
||||
const original = {...Config.abusePolicy.phoneFlagging};
|
||||
Config.abusePolicy.phoneFlagging = {enabled: false, exemptCountryCodes: []};
|
||||
try {
|
||||
const user = createUser({
|
||||
suspiciousActivityFlags: SuspiciousActivityFlags.REQUIRE_VERIFIED_PHONE | DEFERRED_PHONE_ON_COMMUNITY_JOIN,
|
||||
});
|
||||
expect(getRequiredActions(user)).toEqual([]);
|
||||
expect(getEffectiveSuspiciousFlags(user)).toBe(0);
|
||||
} finally {
|
||||
Config.abusePolicy.phoneFlagging = original;
|
||||
}
|
||||
});
|
||||
it('suppresses a deferred phone requirement so the account is not locked out', () => {
|
||||
const user = createUser({
|
||||
suspiciousActivityFlags: SuspiciousActivityFlags.REQUIRE_VERIFIED_PHONE | DEFERRED_PHONE_ON_COMMUNITY_JOIN,
|
||||
|
||||
@@ -134,7 +134,7 @@ function suppressDeferredPhoneFlags(rawFlags: number): number {
|
||||
if ((rawFlags & DEFERRED_PHONE_ON_COMMUNITY_JOIN) === 0) {
|
||||
return rawFlags;
|
||||
}
|
||||
if (getCachedDeferredPhoneGateEnabled() === false) {
|
||||
if (getCachedDeferredPhoneGateEnabled() === false && Config.abusePolicy.phoneFlagging.enabled) {
|
||||
return rawFlags & ~DEFERRED_PHONE_ON_COMMUNITY_JOIN;
|
||||
}
|
||||
return rawFlags & ~DEFERRABLE_PHONE_FLAGS;
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import * as AuthSession from '@app/api/auth/AuthSession';
|
||||
import {requireSudoMode} from '@app/api/auth/services/SudoVerificationService';
|
||||
import {createGuildID, createUserID} from '@app/api/BrandedTypes';
|
||||
import {Config} from '@app/api/Config';
|
||||
import {DefaultUserOnly, LoginRequired, LoginRequiredAllowSuspicious} from '@app/api/middleware/AuthMiddleware';
|
||||
import {requireOAuth2ScopeForBearer} from '@app/api/middleware/OAuth2ScopeMiddleware';
|
||||
import {RateLimitMiddleware} from '@app/api/middleware/RateLimitMiddleware';
|
||||
@@ -10,6 +11,7 @@ import {OpenAPI} from '@app/api/middleware/ResponseTypeMiddleware';
|
||||
import {SudoModeMiddleware} from '@app/api/middleware/SudoModeMiddleware';
|
||||
import {RateLimitConfigs} from '@app/api/RateLimitConfig';
|
||||
import type {HonoApp} from '@app/api/types/HonoEnv';
|
||||
import {classifyWebPushOrigin} from '@app/api/user/services/WebPushOriginReplacement';
|
||||
import {getCachedUserPartialResponse} from '@app/api/user/UserCacheHelpers';
|
||||
import {
|
||||
mapUserGuildSettingsToResponse,
|
||||
@@ -854,7 +856,7 @@ export function UserAccountController(app: HonoApp) {
|
||||
'Registers a new push notification subscription for the current user. Takes push endpoint and encryption keys from a Web Push API subscription. Returns subscription ID for future reference.',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const {endpoint, keys, user_agent} = ctx.req.valid('json');
|
||||
const {endpoint, keys, user_agent, installed_app} = ctx.req.valid('json');
|
||||
const authSession = ctx.get('authSession');
|
||||
const subscription = await ctx.get('userService').contentService.registerPushSubscription({
|
||||
userId: ctx.get('user').id,
|
||||
@@ -862,6 +864,8 @@ export function UserAccountController(app: HonoApp) {
|
||||
endpoint,
|
||||
keys,
|
||||
userAgent: user_agent,
|
||||
originKind: classifyWebPushOrigin(ctx.req.header('origin'), Config.instance.selfHosted),
|
||||
installedApp: installed_app,
|
||||
});
|
||||
return ctx.json({subscription_id: subscription.subscriptionId});
|
||||
},
|
||||
@@ -883,7 +887,7 @@ export function UserAccountController(app: HonoApp) {
|
||||
'Replaces an existing push subscription whose endpoint has been rotated by the browser (pushsubscriptionchange). Deletes the row keyed by the old endpoint and inserts a new one for the new endpoint.',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const {old_endpoint, endpoint, keys, user_agent} = ctx.req.valid('json');
|
||||
const {old_endpoint, endpoint, keys, user_agent, installed_app} = ctx.req.valid('json');
|
||||
const authSession = ctx.get('authSession');
|
||||
const subscription = await ctx.get('userService').contentService.rotatePushSubscription({
|
||||
userId: ctx.get('user').id,
|
||||
@@ -892,6 +896,8 @@ export function UserAccountController(app: HonoApp) {
|
||||
endpoint,
|
||||
keys,
|
||||
userAgent: user_agent,
|
||||
originKind: classifyWebPushOrigin(ctx.req.header('origin'), Config.instance.selfHosted),
|
||||
installedApp: installed_app,
|
||||
});
|
||||
return ctx.json({subscription_id: subscription.subscriptionId});
|
||||
},
|
||||
|
||||
@@ -12,6 +12,7 @@ import {Logger} from '@app/api/Logger';
|
||||
import type {RequestCache} from '@app/api/middleware/RequestCacheMiddleware';
|
||||
import type {AuthSession} from '@app/api/models/AuthSession';
|
||||
import type {User} from '@app/api/models/User';
|
||||
import {stripDisallowedPhoneFlags} from '@app/api/risk/AbusePolicy';
|
||||
import {createAccountPolicyContactContext, type IAccountPolicyEvaluator} from '@app/api/risk/AccountPolicyEvaluator';
|
||||
import type {IRegistrationEventsRepository} from '@app/api/risk/adapters/VelocityAdapter';
|
||||
import type {IRiskHistoryRepository} from '@app/api/risk/HistoricalOutcomeRepository';
|
||||
@@ -42,6 +43,7 @@ import {
|
||||
mapUserToPrivateResponse,
|
||||
mapUserToProfileResponse,
|
||||
} from '@app/api/user/UserMappers';
|
||||
import {lookupGeoip} from '@app/api/utils/IpUtils';
|
||||
import {DEFERRED_PHONE_ON_COMMUNITY_JOIN, imposePhoneRequirements} from '@fluxer/constants/src/UserConstants';
|
||||
import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes';
|
||||
import {getCurrentTimeZoneOffsetMinutes} from '@fluxer/date_utils/src/TimeZoneUtils';
|
||||
@@ -302,7 +304,11 @@ export class UserAccountRequestService {
|
||||
action: emailSetRecommendedAction,
|
||||
},
|
||||
});
|
||||
nextSuspiciousFlags = imposePhoneRequirements(nextSuspiciousFlags, policyDecision.flagBits);
|
||||
const policyFlagBits = await stripDisallowedPhoneFlags(
|
||||
policyDecision.flagBits,
|
||||
async () => (await lookupGeoip(request)).countryCode,
|
||||
);
|
||||
nextSuspiciousFlags = imposePhoneRequirements(nextSuspiciousFlags, policyFlagBits);
|
||||
if (nextSuspiciousFlags !== currentSuspiciousFlags) {
|
||||
user = await this.userRepository.patchUpsert(
|
||||
user.id,
|
||||
|
||||
@@ -20,12 +20,23 @@ import {resolveLimitSafe} from '@app/api/limits/LimitConfigUtils';
|
||||
import {createLimitMatchContext} from '@app/api/limits/LimitMatchContextBuilder';
|
||||
import type {RequestCache} from '@app/api/middleware/RequestCacheMiddleware';
|
||||
import type {Message} from '@app/api/models/Message';
|
||||
import type {PushSubscription} from '@app/api/models/PushSubscription';
|
||||
import {PushSubscription} from '@app/api/models/PushSubscription';
|
||||
import type {IUserAccountRepository} from '@app/api/user/repositories/IUserAccountRepository';
|
||||
import type {IUserContentRepository} from '@app/api/user/repositories/IUserContentRepository';
|
||||
import {BaseUserUpdatePropagator} from '@app/api/user/services/BaseUserUpdatePropagator';
|
||||
import {verifyHarvestDownloadToken} from '@app/api/user/services/HarvestDownloadToken';
|
||||
import {buildHarvestDownloadUrl} from '@app/api/user/services/HarvestDownloadUrl';
|
||||
import {
|
||||
findInstalledLegacyPushSubscriptionIds,
|
||||
findTargetPushSubscriptionIds,
|
||||
getPushOriginReplacement,
|
||||
getPushSessionPredecessor,
|
||||
markInstalledLegacyPushSubscription,
|
||||
markPushOriginReplaced,
|
||||
markTargetPushSubscription,
|
||||
sameUserAgentFamily,
|
||||
type WebPushOriginKind,
|
||||
} from '@app/api/user/services/WebPushOriginReplacement';
|
||||
import {UserHarvest} from '@app/api/user/UserHarvestModel';
|
||||
import {UserHarvestRepository} from '@app/api/user/UserHarvestRepository';
|
||||
import {serializeSelfMessageFilter} from '@app/api/worker/utils/SelfMessageFilterPayload';
|
||||
@@ -56,6 +67,7 @@ import type {
|
||||
import type {SavedMessageStatus} from '@fluxer/schema/src/domains/user/UserResponseSchemas';
|
||||
import {snowflakeToDate} from '@fluxer/snowflake/src/Snowflake';
|
||||
import {isPubliclyRoutableUrlShape} from '@pkgs/http_client/src/PublicInternetRequestUrlPolicy';
|
||||
import type {IKVProvider} from '@pkgs/kv_client/src/IKVProvider';
|
||||
import type {IWorkerService} from '@pkgs/worker/src/contracts/IWorkerService';
|
||||
import {ms} from 'itty-time';
|
||||
|
||||
@@ -119,6 +131,12 @@ function resolveMobileWebPushKeys(device: RegisterMobileDeviceRequest): {p256dh:
|
||||
'Web Push registrations require encryption_key and auth_secret',
|
||||
);
|
||||
}
|
||||
if (device.platform === 'android_unified_push' || device.platform === 'ios_apns_voip') {
|
||||
throw InputValidationError.create(
|
||||
'encryption_key',
|
||||
'Web Push registrations require encryption_key and auth_secret',
|
||||
);
|
||||
}
|
||||
if (isPushEndpointUrl(device.token)) {
|
||||
throw InputValidationError.create('token', 'Endpoint URL registrations require encryption_key and auth_secret');
|
||||
}
|
||||
@@ -135,7 +153,7 @@ function normalizeProviderEnvironment(
|
||||
environment: RegisterMobileDeviceRequest['provider_environment'],
|
||||
): string | null {
|
||||
if (environment) return environment;
|
||||
return platform === 'ios_apns' ? DEFAULT_APNS_PROVIDER_ENVIRONMENT : null;
|
||||
return platform === 'ios_apns' || platform === 'ios_apns_voip' ? DEFAULT_APNS_PROVIDER_ENVIRONMENT : null;
|
||||
}
|
||||
|
||||
const isUnreachableEntityError = (error: unknown): boolean =>
|
||||
@@ -153,6 +171,7 @@ export class UserContentService {
|
||||
private readonly gatewayService: IGatewayService;
|
||||
private readonly workerService: IWorkerService<WorkerTaskName>;
|
||||
private readonly snowflakeService: ISnowflakeService;
|
||||
private readonly kv: IKVProvider;
|
||||
|
||||
constructor(
|
||||
apiContext: ApiContext,
|
||||
@@ -162,11 +181,12 @@ export class UserContentService {
|
||||
private bulkMessageDeletionQueue: KVBulkMessageDeletionQueueService,
|
||||
private limitConfigService: LimitConfigService,
|
||||
) {
|
||||
const {users, gateway, worker, snowflake} = apiContext.services;
|
||||
const {users, gateway, worker, snowflake, kv} = apiContext.services;
|
||||
this.userRepository = users;
|
||||
this.gatewayService = gateway;
|
||||
this.workerService = worker;
|
||||
this.snowflakeService = snowflake;
|
||||
this.kv = kv;
|
||||
this.updatePropagator = new BaseUserUpdatePropagator({
|
||||
userCacheService,
|
||||
gatewayService: this.gatewayService,
|
||||
@@ -353,8 +373,10 @@ export class UserContentService {
|
||||
auth: string;
|
||||
};
|
||||
userAgent?: string;
|
||||
originKind?: WebPushOriginKind | null;
|
||||
installedApp?: boolean;
|
||||
}): Promise<PushSubscription> {
|
||||
const {userId, authSessionIdHash, endpoint, keys, userAgent} = params;
|
||||
const {userId, authSessionIdHash, endpoint, keys, userAgent, originKind, installedApp} = params;
|
||||
assertPublicPushEndpoint(endpoint, 'endpoint');
|
||||
const subscriptionId = createWebPushSubscriptionId(endpoint);
|
||||
const data: PushSubscriptionRow = {
|
||||
@@ -369,11 +391,76 @@ export class UserContentService {
|
||||
app_id: null,
|
||||
provider_environment: null,
|
||||
};
|
||||
const subscription = await this.userRepository.createPushSubscription(data);
|
||||
const subscription = await this.storeWebPushSubscription(data, originKind ?? null, installedApp === true);
|
||||
await this.gatewayService.invalidatePushSubscriptions({userId});
|
||||
return subscription;
|
||||
}
|
||||
|
||||
private async storeWebPushSubscription(
|
||||
data: PushSubscriptionRow,
|
||||
originKind: WebPushOriginKind | null,
|
||||
installedApp: boolean,
|
||||
): Promise<PushSubscription> {
|
||||
if (originKind === 'legacy' && (await this.isLegacyWebPushReplaced(data, installedApp))) {
|
||||
return new PushSubscription(data);
|
||||
}
|
||||
const subscription = await this.userRepository.createPushSubscription(data);
|
||||
if (originKind === 'legacy' && installedApp) {
|
||||
await this.bestEffortPushOriginWrite(() => markInstalledLegacyPushSubscription(this.kv, data.subscription_id));
|
||||
}
|
||||
if (originKind === 'target') {
|
||||
await this.bestEffortPushOriginWrite(() => this.replaceLegacyWebPushSubscriptions(data, installedApp));
|
||||
}
|
||||
return subscription;
|
||||
}
|
||||
|
||||
private async isLegacyWebPushReplaced(data: PushSubscriptionRow, installedApp: boolean): Promise<boolean> {
|
||||
const sessionIdHash = data.auth_session_id_hash;
|
||||
if (!sessionIdHash) return false;
|
||||
try {
|
||||
const replacement = await getPushOriginReplacement(this.kv, sessionIdHash);
|
||||
return replacement === 'installed' || (replacement === 'browser' && !installedApp);
|
||||
} catch (error) {
|
||||
Logger.warn({error}, 'Failed to read the web push origin replacement');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private async bestEffortPushOriginWrite(write: () => Promise<void>): Promise<void> {
|
||||
try {
|
||||
await write();
|
||||
} catch (error) {
|
||||
Logger.warn({error}, 'Failed to apply the web push origin replacement');
|
||||
}
|
||||
}
|
||||
|
||||
private async replaceLegacyWebPushSubscriptions(data: PushSubscriptionRow, installedApp: boolean): Promise<void> {
|
||||
await markTargetPushSubscription(this.kv, data.subscription_id);
|
||||
const sessionIdHash = data.auth_session_id_hash;
|
||||
if (!sessionIdHash) return;
|
||||
await markPushOriginReplaced(this.kv, sessionIdHash, installedApp ? 'installed' : 'browser');
|
||||
const predecessor = await getPushSessionPredecessor(this.kv, sessionIdHash);
|
||||
const candidates = (await this.userRepository.listPushSubscriptions(data.user_id)).filter(
|
||||
(subscription) =>
|
||||
subscription.platform === WEB_PUSH_PLATFORM &&
|
||||
subscription.endpoint !== data.endpoint &&
|
||||
(subscription.authSessionIdHash === sessionIdHash ||
|
||||
(predecessor !== null &&
|
||||
subscription.authSessionIdHash === predecessor &&
|
||||
sameUserAgentFamily(subscription.userAgent, data.user_agent))),
|
||||
);
|
||||
const candidateIds = candidates.map((subscription) => subscription.subscriptionId);
|
||||
const [targetSubscriptionIds, installedLegacySubscriptionIds] = await Promise.all([
|
||||
findTargetPushSubscriptionIds(this.kv, candidateIds),
|
||||
installedApp ? Promise.resolve(new Set<string>()) : findInstalledLegacyPushSubscriptionIds(this.kv, candidateIds),
|
||||
]);
|
||||
for (const subscription of candidates) {
|
||||
if (targetSubscriptionIds.has(subscription.subscriptionId)) continue;
|
||||
if (installedLegacySubscriptionIds.has(subscription.subscriptionId)) continue;
|
||||
await this.userRepository.deletePushSubscription(data.user_id, subscription.subscriptionId);
|
||||
}
|
||||
}
|
||||
|
||||
async listPushSubscriptions(userId: UserID): Promise<Array<PushSubscription>> {
|
||||
const subscriptions = await this.userRepository.listPushSubscriptions(userId);
|
||||
return subscriptions.filter((subscription) => subscription.platform === WEB_PUSH_PLATFORM);
|
||||
@@ -394,8 +481,10 @@ export class UserContentService {
|
||||
auth: string;
|
||||
};
|
||||
userAgent?: string;
|
||||
originKind?: WebPushOriginKind | null;
|
||||
installedApp?: boolean;
|
||||
}): Promise<PushSubscription> {
|
||||
const {userId, authSessionIdHash, oldEndpoint, endpoint, keys, userAgent} = params;
|
||||
const {userId, authSessionIdHash, oldEndpoint, endpoint, keys, userAgent, originKind, installedApp} = params;
|
||||
assertPublicPushEndpoint(endpoint, 'endpoint');
|
||||
const oldSubscriptionId = createWebPushSubscriptionId(oldEndpoint);
|
||||
const newSubscriptionId = createWebPushSubscriptionId(endpoint);
|
||||
@@ -414,7 +503,7 @@ export class UserContentService {
|
||||
app_id: null,
|
||||
provider_environment: null,
|
||||
};
|
||||
const subscription = await this.userRepository.createPushSubscription(data);
|
||||
const subscription = await this.storeWebPushSubscription(data, originKind ?? null, installedApp === true);
|
||||
await this.gatewayService.invalidatePushSubscriptions({userId});
|
||||
return subscription;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import type {IKVProvider} from '@pkgs/kv_client/src/IKVProvider';
|
||||
import {seconds} from 'itty-time';
|
||||
import {uint8ArrayToBase64} from 'uint8array-extras';
|
||||
|
||||
export type WebPushOriginKind = 'legacy' | 'target';
|
||||
|
||||
export type WebPushOriginReplacement = 'installed' | 'browser';
|
||||
|
||||
const WEB_PUSH_ORIGIN_KINDS: ReadonlyMap<string, WebPushOriginKind> = new Map([
|
||||
['https://web.fluxer.app', 'legacy'],
|
||||
['https://web.canary.fluxer.app', 'legacy'],
|
||||
['https://fluxer.com', 'target'],
|
||||
['https://canary.fluxer.com', 'target'],
|
||||
]);
|
||||
|
||||
const PUSH_ORIGIN_REPLACED_PREFIX = 'push_origin_replaced:';
|
||||
const PUSH_SESSION_PREDECESSOR_PREFIX = 'push_session_predecessor:';
|
||||
const PUSH_TARGET_SUBSCRIPTION_PREFIX = 'push_target_subscription:';
|
||||
const PUSH_INSTALLED_LEGACY_SUBSCRIPTION_PREFIX = 'push_installed_legacy_subscription:';
|
||||
const USER_AGENT_VERSION_PATTERN = /\d+(?:[._]\d+)*/g;
|
||||
|
||||
export const WEB_PUSH_ORIGIN_RECORD_TTL_SECONDS = seconds('400 days');
|
||||
|
||||
export function classifyWebPushOrigin(
|
||||
origin: string | null | undefined,
|
||||
selfHosted: boolean,
|
||||
): WebPushOriginKind | null {
|
||||
if (selfHosted || !origin) return null;
|
||||
return WEB_PUSH_ORIGIN_KINDS.get(origin) ?? null;
|
||||
}
|
||||
|
||||
export function encodePushSessionIdHash(sessionIdHash: Uint8Array): string {
|
||||
return uint8ArrayToBase64(sessionIdHash, {urlSafe: true});
|
||||
}
|
||||
|
||||
export function sameUserAgentFamily(a: string | null | undefined, b: string | null | undefined): boolean {
|
||||
if (!a || !b) return false;
|
||||
return a.replace(USER_AGENT_VERSION_PATTERN, '') === b.replace(USER_AGENT_VERSION_PATTERN, '');
|
||||
}
|
||||
|
||||
export async function recordPushSessionPredecessor(
|
||||
kv: IKVProvider,
|
||||
sessionIdHash: string,
|
||||
predecessorSessionIdHash: string,
|
||||
): Promise<void> {
|
||||
if (sessionIdHash === predecessorSessionIdHash) return;
|
||||
await kv.setex(
|
||||
`${PUSH_SESSION_PREDECESSOR_PREFIX}${sessionIdHash}`,
|
||||
WEB_PUSH_ORIGIN_RECORD_TTL_SECONDS,
|
||||
predecessorSessionIdHash,
|
||||
);
|
||||
}
|
||||
|
||||
export async function getPushSessionPredecessor(kv: IKVProvider, sessionIdHash: string): Promise<string | null> {
|
||||
return kv.get(`${PUSH_SESSION_PREDECESSOR_PREFIX}${sessionIdHash}`);
|
||||
}
|
||||
|
||||
export async function markPushOriginReplaced(
|
||||
kv: IKVProvider,
|
||||
sessionIdHash: string,
|
||||
replacement: WebPushOriginReplacement,
|
||||
): Promise<void> {
|
||||
const key = `${PUSH_ORIGIN_REPLACED_PREFIX}${sessionIdHash}`;
|
||||
if (replacement === 'browser' && (await kv.get(key)) === 'installed') return;
|
||||
await kv.setex(key, WEB_PUSH_ORIGIN_RECORD_TTL_SECONDS, replacement);
|
||||
}
|
||||
|
||||
export async function getPushOriginReplacement(
|
||||
kv: IKVProvider,
|
||||
sessionIdHash: string,
|
||||
): Promise<WebPushOriginReplacement | null> {
|
||||
const value = await kv.get(`${PUSH_ORIGIN_REPLACED_PREFIX}${sessionIdHash}`);
|
||||
if (value === null) return null;
|
||||
return value === 'browser' ? 'browser' : 'installed';
|
||||
}
|
||||
|
||||
async function markSubscription(kv: IKVProvider, prefix: string, subscriptionId: string): Promise<void> {
|
||||
await kv.setex(`${prefix}${subscriptionId}`, WEB_PUSH_ORIGIN_RECORD_TTL_SECONDS, '1');
|
||||
}
|
||||
|
||||
async function findMarkedSubscriptionIds(
|
||||
kv: IKVProvider,
|
||||
prefix: string,
|
||||
subscriptionIds: Array<string>,
|
||||
): Promise<Set<string>> {
|
||||
if (subscriptionIds.length === 0) return new Set();
|
||||
const markers = await kv.mget(...subscriptionIds.map((id) => `${prefix}${id}`));
|
||||
return new Set(subscriptionIds.filter((_, index) => markers[index] !== null));
|
||||
}
|
||||
|
||||
export async function markTargetPushSubscription(kv: IKVProvider, subscriptionId: string): Promise<void> {
|
||||
await markSubscription(kv, PUSH_TARGET_SUBSCRIPTION_PREFIX, subscriptionId);
|
||||
}
|
||||
|
||||
export async function findTargetPushSubscriptionIds(
|
||||
kv: IKVProvider,
|
||||
subscriptionIds: Array<string>,
|
||||
): Promise<Set<string>> {
|
||||
return findMarkedSubscriptionIds(kv, PUSH_TARGET_SUBSCRIPTION_PREFIX, subscriptionIds);
|
||||
}
|
||||
|
||||
export async function markInstalledLegacyPushSubscription(kv: IKVProvider, subscriptionId: string): Promise<void> {
|
||||
await markSubscription(kv, PUSH_INSTALLED_LEGACY_SUBSCRIPTION_PREFIX, subscriptionId);
|
||||
}
|
||||
|
||||
export async function findInstalledLegacyPushSubscriptionIds(
|
||||
kv: IKVProvider,
|
||||
subscriptionIds: Array<string>,
|
||||
): Promise<Set<string>> {
|
||||
return findMarkedSubscriptionIds(kv, PUSH_INSTALLED_LEGACY_SUBSCRIPTION_PREFIX, subscriptionIds);
|
||||
}
|
||||
@@ -0,0 +1,383 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {createAuthHarness, createTestAccount, loginAccount} from '@app/api/auth/tests/AuthTestUtils';
|
||||
import {getConfig} from '@app/api/Config';
|
||||
import type {ApiTestHarness} from '@app/api/test/ApiTestHarness';
|
||||
import {createBuilder, createBuilderWithoutAuth} from '@app/api/test/TestRequestBuilder';
|
||||
import {classifyWebPushOrigin, sameUserAgentFamily} from '@app/api/user/services/WebPushOriginReplacement';
|
||||
import {listPushSubscriptions} from '@app/api/user/tests/UserTestUtils';
|
||||
import {afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi} from 'vitest';
|
||||
|
||||
const LEGACY_ORIGIN = 'https://web.fluxer.app';
|
||||
const TARGET_ORIGIN = 'https://fluxer.com';
|
||||
const IPHONE_UA =
|
||||
'Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Mobile/15E148 Safari/604.1';
|
||||
const IPHONE_UPDATED_UA =
|
||||
'Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1 Mobile/15E148 Safari/604.1';
|
||||
const DESKTOP_CHROME_UA =
|
||||
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36';
|
||||
const ANDROID_CHROME_UA =
|
||||
'Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Mobile Safari/537.36';
|
||||
|
||||
interface SubscribeOptions {
|
||||
userAgent?: string;
|
||||
installedApp?: boolean;
|
||||
}
|
||||
|
||||
interface PushSubscribeResponse {
|
||||
subscription_id: string;
|
||||
}
|
||||
|
||||
interface HandoffInitiateResponse {
|
||||
code: string;
|
||||
poll_secret: string;
|
||||
}
|
||||
|
||||
interface HandoffStatusResponse {
|
||||
status: 'pending' | 'completed' | 'expired';
|
||||
token?: string;
|
||||
}
|
||||
|
||||
describe('classifyWebPushOrigin', () => {
|
||||
it.each([
|
||||
{origin: 'https://web.fluxer.app', kind: 'legacy'},
|
||||
{origin: 'https://web.canary.fluxer.app', kind: 'legacy'},
|
||||
{origin: 'https://fluxer.com', kind: 'target'},
|
||||
{origin: 'https://canary.fluxer.com', kind: 'target'},
|
||||
{origin: 'https://fluxer.app', kind: null},
|
||||
{origin: 'https://example.com', kind: null},
|
||||
{origin: undefined, kind: null},
|
||||
{origin: null, kind: null},
|
||||
])('classifies $origin as $kind on the official instance', ({origin, kind}) => {
|
||||
expect(classifyWebPushOrigin(origin, false)).toBe(kind);
|
||||
});
|
||||
|
||||
it('never classifies an origin on a self-hosted instance', () => {
|
||||
expect(classifyWebPushOrigin(LEGACY_ORIGIN, true)).toBeNull();
|
||||
expect(classifyWebPushOrigin(TARGET_ORIGIN, true)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('sameUserAgentFamily', () => {
|
||||
it('matches the same browser across version updates', () => {
|
||||
expect(sameUserAgentFamily(IPHONE_UA, IPHONE_UPDATED_UA)).toBe(true);
|
||||
});
|
||||
|
||||
it('tells devices and browsers apart', () => {
|
||||
expect(sameUserAgentFamily(DESKTOP_CHROME_UA, ANDROID_CHROME_UA)).toBe(false);
|
||||
expect(sameUserAgentFamily(IPHONE_UA, DESKTOP_CHROME_UA)).toBe(false);
|
||||
});
|
||||
|
||||
it('never matches a missing user agent', () => {
|
||||
expect(sameUserAgentFamily(null, null)).toBe(false);
|
||||
expect(sameUserAgentFamily(IPHONE_UA, undefined)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('web push origin replacement', () => {
|
||||
let harness: ApiTestHarness;
|
||||
beforeAll(async () => {
|
||||
harness = await createAuthHarness();
|
||||
});
|
||||
beforeEach(async () => {
|
||||
await harness.reset();
|
||||
});
|
||||
afterAll(async () => {
|
||||
await harness?.shutdown();
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
async function subscribeFrom(
|
||||
token: string,
|
||||
origin: string | null,
|
||||
endpoint: string,
|
||||
options: SubscribeOptions = {},
|
||||
): Promise<string> {
|
||||
const builder = createBuilder<PushSubscribeResponse>(harness, token).post('/users/@me/push/subscribe');
|
||||
if (origin) builder.header('Origin', origin);
|
||||
const response = await builder
|
||||
.body({
|
||||
endpoint,
|
||||
keys: {p256dh: 'test-p256dh-key', auth: 'test-auth-key'},
|
||||
user_agent: options.userAgent,
|
||||
installed_app: options.installedApp,
|
||||
})
|
||||
.execute();
|
||||
return response.subscription_id;
|
||||
}
|
||||
|
||||
async function rotateFrom(
|
||||
token: string,
|
||||
origin: string,
|
||||
oldEndpoint: string,
|
||||
endpoint: string,
|
||||
installedApp?: boolean,
|
||||
): Promise<string> {
|
||||
const response = await createBuilder<PushSubscribeResponse>(harness, token)
|
||||
.post('/users/@me/push/rotate')
|
||||
.header('Origin', origin)
|
||||
.body({
|
||||
old_endpoint: oldEndpoint,
|
||||
endpoint,
|
||||
keys: {p256dh: 'test-p256dh-key', auth: 'test-auth-key'},
|
||||
installed_app: installedApp,
|
||||
})
|
||||
.execute();
|
||||
return response.subscription_id;
|
||||
}
|
||||
|
||||
async function listSubscriptionIds(token: string): Promise<Array<string>> {
|
||||
const result = await listPushSubscriptions(harness, token);
|
||||
return result.subscriptions.map((subscription) => subscription.subscription_id).sort();
|
||||
}
|
||||
|
||||
async function pairNewSession(
|
||||
approverToken: string,
|
||||
approverUserId: string,
|
||||
approverOrigin: string,
|
||||
initiatorOrigin: string | null = TARGET_ORIGIN,
|
||||
) {
|
||||
const initiate = createBuilderWithoutAuth<HandoffInitiateResponse>(harness).post('/auth/handoff/initiate');
|
||||
if (initiatorOrigin) initiate.header('Origin', initiatorOrigin);
|
||||
const initiated = await initiate.body(null).execute();
|
||||
await createBuilderWithoutAuth(harness).get(`/auth/handoff/${initiated.code}/info`).execute();
|
||||
await createBuilderWithoutAuth(harness)
|
||||
.post('/auth/handoff/complete')
|
||||
.header('Origin', approverOrigin)
|
||||
.body({code: initiated.code, token: approverToken, user_id: approverUserId})
|
||||
.expect(204)
|
||||
.execute();
|
||||
const completed = await createBuilderWithoutAuth<HandoffStatusResponse>(harness)
|
||||
.post(`/auth/handoff/${initiated.code}/status`)
|
||||
.body({poll_secret: initiated.poll_secret})
|
||||
.execute();
|
||||
expect(completed.status).toBe('completed');
|
||||
return completed.token!;
|
||||
}
|
||||
|
||||
async function withSelfHosted(callback: () => Promise<void>): Promise<void> {
|
||||
const config = getConfig();
|
||||
const original = config.instance.selfHosted;
|
||||
try {
|
||||
config.instance.selfHosted = true;
|
||||
await callback();
|
||||
} finally {
|
||||
config.instance.selfHosted = original;
|
||||
}
|
||||
}
|
||||
|
||||
it('replaces the legacy subscription of the same session when the new origin subscribes', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
await subscribeFrom(account.token, LEGACY_ORIGIN, 'https://push.example.com/legacy');
|
||||
const target = await subscribeFrom(account.token, TARGET_ORIGIN, 'https://push.example.com/target');
|
||||
expect(await listSubscriptionIds(account.token)).toEqual([target]);
|
||||
});
|
||||
|
||||
it('turns a later legacy subscribe for the replaced session into a no-op', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const target = await subscribeFrom(account.token, TARGET_ORIGIN, 'https://push.example.com/target');
|
||||
const legacy = await subscribeFrom(account.token, LEGACY_ORIGIN, 'https://push.example.com/legacy');
|
||||
expect(legacy).toMatch(/^[a-f0-9]{32}$/);
|
||||
expect(legacy).not.toBe(target);
|
||||
expect(await listSubscriptionIds(account.token)).toEqual([target]);
|
||||
});
|
||||
|
||||
it('does not store a legacy rotation for a replaced session', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
await subscribeFrom(account.token, LEGACY_ORIGIN, 'https://push.example.com/legacy-old');
|
||||
const target = await subscribeFrom(account.token, TARGET_ORIGIN, 'https://push.example.com/target');
|
||||
await rotateFrom(
|
||||
account.token,
|
||||
LEGACY_ORIGIN,
|
||||
'https://push.example.com/legacy-old',
|
||||
'https://push.example.com/legacy-new',
|
||||
);
|
||||
expect(await listSubscriptionIds(account.token)).toEqual([target]);
|
||||
});
|
||||
|
||||
it('keeps legacy subscriptions working until the new origin subscribes', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const first = await subscribeFrom(account.token, LEGACY_ORIGIN, 'https://push.example.com/legacy-a');
|
||||
const second = await subscribeFrom(account.token, LEGACY_ORIGIN, 'https://push.example.com/legacy-b');
|
||||
expect(await listSubscriptionIds(account.token)).toEqual([first, second].sort());
|
||||
});
|
||||
|
||||
it('leaves subscriptions from other sessions alone', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const other = await loginAccount(harness, account);
|
||||
const otherLegacy = await subscribeFrom(other.token, LEGACY_ORIGIN, 'https://push.example.com/other-legacy');
|
||||
const target = await subscribeFrom(account.token, TARGET_ORIGIN, 'https://push.example.com/target');
|
||||
expect(await listSubscriptionIds(account.token)).toEqual([otherLegacy, target].sort());
|
||||
});
|
||||
|
||||
it('never removes another new-origin subscription of the same session', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const first = await subscribeFrom(account.token, TARGET_ORIGIN, 'https://push.example.com/target-a');
|
||||
const second = await subscribeFrom(account.token, 'https://canary.fluxer.com', 'https://push.example.com/target-b');
|
||||
expect(await listSubscriptionIds(account.token)).toEqual([first, second].sort());
|
||||
});
|
||||
|
||||
it('treats unclassified rows as legacy without ever skipping an unclassified subscribe', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const unknown = await subscribeFrom(account.token, null, 'https://push.example.com/no-origin');
|
||||
const target = await subscribeFrom(account.token, TARGET_ORIGIN, 'https://push.example.com/target');
|
||||
expect(await listSubscriptionIds(account.token)).toEqual([target]);
|
||||
const legacyAfter = await subscribeFrom(account.token, null, 'https://push.example.com/no-origin');
|
||||
expect(legacyAfter).toBe(unknown);
|
||||
expect(await listSubscriptionIds(account.token)).toEqual([unknown, target].sort());
|
||||
});
|
||||
|
||||
it('replaces the approving legacy session on the same device once a paired session subscribes', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const approver = await loginAccount(harness, account);
|
||||
const approverLegacy = 'https://push.example.com/approver-legacy';
|
||||
await subscribeFrom(approver.token, LEGACY_ORIGIN, approverLegacy, {userAgent: IPHONE_UA, installedApp: true});
|
||||
const pairedToken = await pairNewSession(approver.token, approver.userId, LEGACY_ORIGIN);
|
||||
const paired = await subscribeFrom(pairedToken, TARGET_ORIGIN, 'https://push.example.com/paired', {
|
||||
userAgent: IPHONE_UPDATED_UA,
|
||||
installedApp: true,
|
||||
});
|
||||
expect(await listSubscriptionIds(approver.token)).toEqual([paired]);
|
||||
});
|
||||
|
||||
it('never silences the approving session for good', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const approver = await loginAccount(harness, account);
|
||||
const approverLegacy = 'https://push.example.com/approver-legacy';
|
||||
await subscribeFrom(approver.token, LEGACY_ORIGIN, approverLegacy, {userAgent: IPHONE_UA});
|
||||
const pairedToken = await pairNewSession(approver.token, approver.userId, LEGACY_ORIGIN);
|
||||
const paired = await subscribeFrom(pairedToken, TARGET_ORIGIN, 'https://push.example.com/paired', {
|
||||
userAgent: IPHONE_UA,
|
||||
});
|
||||
const restored = await subscribeFrom(approver.token, LEGACY_ORIGIN, approverLegacy, {userAgent: IPHONE_UA});
|
||||
expect(await listSubscriptionIds(approver.token)).toEqual([paired, restored].sort());
|
||||
});
|
||||
|
||||
it('leaves the approving session alone when it runs on another device', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const approver = await loginAccount(harness, account);
|
||||
const approverLegacy = await subscribeFrom(approver.token, LEGACY_ORIGIN, 'https://push.example.com/desktop', {
|
||||
userAgent: DESKTOP_CHROME_UA,
|
||||
installedApp: true,
|
||||
});
|
||||
const pairedToken = await pairNewSession(approver.token, approver.userId, LEGACY_ORIGIN);
|
||||
const paired = await subscribeFrom(pairedToken, TARGET_ORIGIN, 'https://push.example.com/phone', {
|
||||
userAgent: ANDROID_CHROME_UA,
|
||||
installedApp: true,
|
||||
});
|
||||
expect(await listSubscriptionIds(approver.token)).toEqual([approverLegacy, paired].sort());
|
||||
const desktopAgain = await subscribeFrom(approver.token, LEGACY_ORIGIN, 'https://push.example.com/desktop', {
|
||||
userAgent: DESKTOP_CHROME_UA,
|
||||
installedApp: true,
|
||||
});
|
||||
expect(desktopAgain).toBe(approverLegacy);
|
||||
expect(await listSubscriptionIds(approver.token)).toEqual([approverLegacy, paired].sort());
|
||||
});
|
||||
|
||||
it.each([
|
||||
{label: 'the approval came from the new origin', approverOrigin: TARGET_ORIGIN, initiatorOrigin: TARGET_ORIGIN},
|
||||
{label: 'the new session did not start on the new origin', approverOrigin: LEGACY_ORIGIN, initiatorOrigin: null},
|
||||
{
|
||||
label: 'the new session started on the old origin',
|
||||
approverOrigin: LEGACY_ORIGIN,
|
||||
initiatorOrigin: LEGACY_ORIGIN,
|
||||
},
|
||||
])('does not link sessions when $label', async ({approverOrigin, initiatorOrigin}) => {
|
||||
const account = await createTestAccount(harness);
|
||||
const approver = await loginAccount(harness, account);
|
||||
const approverSubscription = await subscribeFrom(
|
||||
approver.token,
|
||||
LEGACY_ORIGIN,
|
||||
'https://push.example.com/approver-legacy',
|
||||
{userAgent: IPHONE_UA},
|
||||
);
|
||||
const pairedToken = await pairNewSession(approver.token, approver.userId, approverOrigin, initiatorOrigin);
|
||||
const paired = await subscribeFrom(pairedToken, TARGET_ORIGIN, 'https://push.example.com/paired', {
|
||||
userAgent: IPHONE_UA,
|
||||
});
|
||||
expect(await listSubscriptionIds(approver.token)).toEqual([approverSubscription, paired].sort());
|
||||
});
|
||||
|
||||
it('completes the approval when the predecessor link cannot be written', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const approver = await loginAccount(harness, account);
|
||||
const setex = harness.kvProvider.setex.bind(harness.kvProvider);
|
||||
vi.spyOn(harness.kvProvider, 'setex').mockImplementation(async (key, ttl, value) => {
|
||||
if (key.startsWith('push_session_predecessor:')) throw new Error('kv down');
|
||||
return setex(key, ttl, value);
|
||||
});
|
||||
const pairedToken = await pairNewSession(approver.token, approver.userId, LEGACY_ORIGIN);
|
||||
expect(pairedToken).toBeTruthy();
|
||||
});
|
||||
|
||||
it('stores a subscribe when the replacement marker cannot be read or written', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const get = harness.kvProvider.get.bind(harness.kvProvider);
|
||||
vi.spyOn(harness.kvProvider, 'get').mockImplementation(async (key) => {
|
||||
if (key.startsWith('push_origin_replaced:')) throw new Error('kv down');
|
||||
return get(key);
|
||||
});
|
||||
const target = await subscribeFrom(account.token, TARGET_ORIGIN, 'https://push.example.com/target');
|
||||
const legacy = await subscribeFrom(account.token, LEGACY_ORIGIN, 'https://push.example.com/legacy');
|
||||
expect(await listSubscriptionIds(account.token)).toEqual([legacy, target].sort());
|
||||
});
|
||||
|
||||
it('keeps an installed legacy app subscribed when only a browser tab moved', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const installed = await subscribeFrom(account.token, LEGACY_ORIGIN, 'https://push.example.com/legacy-app', {
|
||||
userAgent: DESKTOP_CHROME_UA,
|
||||
installedApp: true,
|
||||
});
|
||||
const target = await subscribeFrom(account.token, TARGET_ORIGIN, 'https://push.example.com/target-tab', {
|
||||
userAgent: DESKTOP_CHROME_UA,
|
||||
});
|
||||
expect(await listSubscriptionIds(account.token)).toEqual([installed, target].sort());
|
||||
const rotated = await rotateFrom(
|
||||
account.token,
|
||||
LEGACY_ORIGIN,
|
||||
'https://push.example.com/legacy-app',
|
||||
'https://push.example.com/legacy-app-2',
|
||||
true,
|
||||
);
|
||||
expect(await listSubscriptionIds(account.token)).toEqual([rotated, target].sort());
|
||||
await subscribeFrom(account.token, LEGACY_ORIGIN, 'https://push.example.com/legacy-tab', {
|
||||
userAgent: DESKTOP_CHROME_UA,
|
||||
});
|
||||
expect(await listSubscriptionIds(account.token)).toEqual([rotated, target].sort());
|
||||
});
|
||||
|
||||
it('replaces an installed legacy app once the new app is installed', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
await subscribeFrom(account.token, LEGACY_ORIGIN, 'https://push.example.com/legacy-app', {
|
||||
userAgent: DESKTOP_CHROME_UA,
|
||||
installedApp: true,
|
||||
});
|
||||
await subscribeFrom(account.token, TARGET_ORIGIN, 'https://push.example.com/target-tab', {
|
||||
userAgent: DESKTOP_CHROME_UA,
|
||||
});
|
||||
const targetApp = await subscribeFrom(account.token, TARGET_ORIGIN, 'https://push.example.com/target-app', {
|
||||
userAgent: DESKTOP_CHROME_UA,
|
||||
installedApp: true,
|
||||
});
|
||||
const ids = await listSubscriptionIds(account.token);
|
||||
expect(ids).toContain(targetApp);
|
||||
expect(ids).toHaveLength(2);
|
||||
await subscribeFrom(account.token, LEGACY_ORIGIN, 'https://push.example.com/legacy-app', {
|
||||
userAgent: DESKTOP_CHROME_UA,
|
||||
installedApp: true,
|
||||
});
|
||||
expect(await listSubscriptionIds(account.token)).toEqual(ids);
|
||||
});
|
||||
|
||||
it('does nothing new on a self-hosted instance', async () => {
|
||||
await withSelfHosted(async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const legacy = await subscribeFrom(account.token, LEGACY_ORIGIN, 'https://push.example.com/legacy');
|
||||
const target = await subscribeFrom(account.token, TARGET_ORIGIN, 'https://push.example.com/target');
|
||||
const legacyAgain = await subscribeFrom(account.token, LEGACY_ORIGIN, 'https://push.example.com/legacy-2');
|
||||
expect(await listSubscriptionIds(account.token)).toEqual([legacy, target, legacyAgain].sort());
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -292,6 +292,136 @@ describe('Push Subscription Lifecycle', () => {
|
||||
const mobileDevices = await listMobileDevices(harness, account.token);
|
||||
expect(mobileDevices.devices).toHaveLength(0);
|
||||
});
|
||||
test('VoIP registration stores the PushKit endpoint and encryption keys', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const endpoint = 'https://relay.example.com/apns-voip/device-1';
|
||||
const registered = await registerMobileDevice(harness, account.token, {
|
||||
platform: 'ios_apns_voip',
|
||||
token: endpoint,
|
||||
encryption_key: 'voip-p256dh-key',
|
||||
auth_secret: 'voip-auth-secret',
|
||||
app_id: 'stable',
|
||||
});
|
||||
const subscription = await findStoredSubscription(account.userId, registered.device_id);
|
||||
expect(subscription.platform).toBe('ios_apns_voip');
|
||||
expect(subscription.endpoint).toBe(endpoint);
|
||||
expect(subscription.p256dhKey).toBe('voip-p256dh-key');
|
||||
expect(subscription.authKey).toBe('voip-auth-secret');
|
||||
});
|
||||
test('VoIP registration defaults to the production provider environment', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const registered = await registerMobileDevice(harness, account.token, {
|
||||
platform: 'ios_apns_voip',
|
||||
token: 'https://relay.example.com/apns-voip/default-environment',
|
||||
encryption_key: 'voip-default-environment-p256dh-key',
|
||||
auth_secret: 'voip-default-environment-auth-secret',
|
||||
});
|
||||
const subscription = await findStoredSubscription(account.userId, registered.device_id);
|
||||
expect(subscription.providerEnvironment).toBe('production');
|
||||
});
|
||||
test('VoIP registration without encryption keys is rejected', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
await createBuilder(harness, account.token)
|
||||
.post('/users/@me/mobile-devices')
|
||||
.body({
|
||||
platform: 'ios_apns_voip',
|
||||
token: '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef',
|
||||
})
|
||||
.expect(HTTP_STATUS.BAD_REQUEST)
|
||||
.execute();
|
||||
});
|
||||
test('VoIP registration with only one encryption key is rejected', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
await createBuilder(harness, account.token)
|
||||
.post('/users/@me/mobile-devices')
|
||||
.body({
|
||||
platform: 'ios_apns_voip',
|
||||
token: 'https://relay.example.com/apns-voip/half-keys',
|
||||
encryption_key: 'voip-half-p256dh-key',
|
||||
})
|
||||
.expect(HTTP_STATUS.BAD_REQUEST)
|
||||
.execute();
|
||||
});
|
||||
test('VoIP and standard APNs registrations coexist as separate devices', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const standard = await registerMobileDevice(harness, account.token, {
|
||||
platform: 'ios_apns',
|
||||
token: 'https://relay.example.com/apns/paired-device',
|
||||
encryption_key: 'paired-apns-p256dh-key',
|
||||
auth_secret: 'paired-apns-auth-secret',
|
||||
app_id: 'stable',
|
||||
provider_environment: 'production',
|
||||
});
|
||||
const voip = await registerMobileDevice(harness, account.token, {
|
||||
platform: 'ios_apns_voip',
|
||||
token: 'https://relay.example.com/apns-voip/paired-device',
|
||||
encryption_key: 'paired-voip-p256dh-key',
|
||||
auth_secret: 'paired-voip-auth-secret',
|
||||
app_id: 'stable',
|
||||
provider_environment: 'production',
|
||||
});
|
||||
expect(voip.device_id).not.toBe(standard.device_id);
|
||||
const mobileDevices = await listMobileDevices(harness, account.token);
|
||||
const platforms = mobileDevices.devices.map((device) => device.platform).sort();
|
||||
expect(platforms).toEqual(['ios_apns', 'ios_apns_voip']);
|
||||
});
|
||||
test('platform alone separates device ids for one registration token', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const endpoint = 'https://relay.example.com/apns/shared-token';
|
||||
const standard = await registerMobileDevice(harness, account.token, {
|
||||
platform: 'ios_apns',
|
||||
token: endpoint,
|
||||
encryption_key: 'shared-p256dh-key',
|
||||
auth_secret: 'shared-auth-secret',
|
||||
app_id: 'stable',
|
||||
provider_environment: 'production',
|
||||
});
|
||||
const voip = await registerMobileDevice(harness, account.token, {
|
||||
platform: 'ios_apns_voip',
|
||||
token: endpoint,
|
||||
encryption_key: 'shared-p256dh-key',
|
||||
auth_secret: 'shared-auth-secret',
|
||||
app_id: 'stable',
|
||||
provider_environment: 'production',
|
||||
});
|
||||
expect(voip.device_id).not.toBe(standard.device_id);
|
||||
});
|
||||
test('unregister removes only the named VoIP registration', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const voipEndpoint = 'https://relay.example.com/apns-voip/removed-device';
|
||||
const standard = await registerMobileDevice(harness, account.token, {
|
||||
platform: 'ios_apns',
|
||||
token: 'https://relay.example.com/apns/kept-device',
|
||||
encryption_key: 'kept-p256dh-key',
|
||||
auth_secret: 'kept-auth-secret',
|
||||
app_id: 'stable',
|
||||
provider_environment: 'production',
|
||||
});
|
||||
await registerMobileDevice(harness, account.token, {
|
||||
platform: 'ios_apns_voip',
|
||||
token: voipEndpoint,
|
||||
encryption_key: 'removed-p256dh-key',
|
||||
auth_secret: 'removed-auth-secret',
|
||||
app_id: 'stable',
|
||||
provider_environment: 'production',
|
||||
});
|
||||
await unregisterMobileDevice(harness, account.token, {
|
||||
platform: 'ios_apns_voip',
|
||||
token: voipEndpoint,
|
||||
app_id: 'stable',
|
||||
provider_environment: 'production',
|
||||
});
|
||||
const mobileDevices = await listMobileDevices(harness, account.token);
|
||||
expect(mobileDevices.devices).toEqual([
|
||||
{
|
||||
device_id: standard.device_id,
|
||||
platform: 'ios_apns',
|
||||
app_id: 'stable',
|
||||
provider_environment: 'production',
|
||||
user_agent: null,
|
||||
},
|
||||
]);
|
||||
});
|
||||
test('mobile Web Push registrations stay out of the web push subscription list', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
await registerMobileDevice(harness, account.token, {
|
||||
|
||||
@@ -344,7 +344,7 @@ export async function registerMobileDevice(
|
||||
harness: ApiTestHarness,
|
||||
token: string,
|
||||
body: {
|
||||
platform: 'android_fcm' | 'ios_apns' | 'android_unified_push';
|
||||
platform: 'android_fcm' | 'ios_apns' | 'ios_apns_voip' | 'android_unified_push';
|
||||
token: string;
|
||||
user_agent?: string;
|
||||
app_id?: string;
|
||||
@@ -371,7 +371,7 @@ export async function unregisterMobileDevice(
|
||||
harness: ApiTestHarness,
|
||||
token: string,
|
||||
body: {
|
||||
platform: 'android_fcm' | 'ios_apns' | 'android_unified_push';
|
||||
platform: 'android_fcm' | 'ios_apns' | 'ios_apns_voip' | 'android_unified_push';
|
||||
token: string;
|
||||
app_id?: string;
|
||||
provider_environment?: 'production' | 'development';
|
||||
|
||||
@@ -10,6 +10,11 @@ function getInviteEndpointBase(): string {
|
||||
return `${url.hostname}${url.pathname.replace(/\/+$/, '')}`;
|
||||
}
|
||||
|
||||
function getWebAppHostsPattern(): string {
|
||||
const hostnames = new Set(Config.endpoints.webAppOrigins.map((origin) => new URL(origin).hostname));
|
||||
return [...hostnames].map((hostname) => RegexUtils.escapeRegex(hostname)).join('|');
|
||||
}
|
||||
|
||||
function getInvitePattern(): RegExp {
|
||||
if (!_invitePattern) {
|
||||
_invitePattern = new RegExp(
|
||||
@@ -18,7 +23,7 @@ function getInvitePattern(): RegExp {
|
||||
'(?:',
|
||||
`${RegexUtils.escapeRegex(getInviteEndpointBase())}(?:\\/#)?\\/(?!invite\\/)([a-zA-Z0-9\\-]{2,32})(?![a-zA-Z0-9\\-])`,
|
||||
'|',
|
||||
`${RegexUtils.escapeRegex(new URL(Config.endpoints.webApp).hostname)}(?:\\/#)?\\/invite\\/([a-zA-Z0-9\\-]{2,32})(?![a-zA-Z0-9\\-])`,
|
||||
`(?:${getWebAppHostsPattern()})(?:\\/#)?\\/invite\\/([a-zA-Z0-9\\-]{2,32})(?![a-zA-Z0-9\\-])`,
|
||||
')',
|
||||
].join(''),
|
||||
'gi',
|
||||
|
||||
@@ -8,7 +8,7 @@ import * as InviteUtils from '@app/api/utils/InviteUtils';
|
||||
import {URL_REGEX} from '@fluxer/constants/src/Core';
|
||||
import * as idna from 'idna-uts46-hx';
|
||||
|
||||
const CLIENT_ROUTE_PATH_PREFIXES = ['/channels/', '/theme/'];
|
||||
const CLIENT_ROUTE_PATH_PREFIXES = ['/channels/', '/theme/', '/invite/', '/gift/', '/oauth2/', '/users/'];
|
||||
|
||||
interface ExcludedLinkBase {
|
||||
hostname: string;
|
||||
@@ -19,12 +19,14 @@ function normalizeHostname(hostname: string | undefined) {
|
||||
return hostname?.trim().toLowerCase() || '';
|
||||
}
|
||||
|
||||
function getWebAppHostname() {
|
||||
try {
|
||||
return new URL(Config.endpoints.webApp).hostname;
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
function getWebAppHostnames(): Array<string> {
|
||||
return Config.endpoints.webAppOrigins.flatMap((origin) => {
|
||||
try {
|
||||
return [new URL(origin).hostname];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function endpointLinkBase(endpoint: string): ExcludedLinkBase | null {
|
||||
@@ -45,7 +47,7 @@ function getExcludedLinkBases(): Array<ExcludedLinkBase> {
|
||||
endpointLinkBase(Config.endpoints.invite),
|
||||
endpointLinkBase(Config.endpoints.gift),
|
||||
];
|
||||
for (const hostname of [getWebAppHostname(), Config.hosts.marketing]) {
|
||||
for (const hostname of [...getWebAppHostnames(), Config.hosts.marketing]) {
|
||||
for (const pathPrefix of CLIENT_ROUTE_PATH_PREFIXES) {
|
||||
bases.push({hostname: normalizeHostname(hostname), pathPrefix});
|
||||
}
|
||||
|
||||
@@ -52,7 +52,6 @@ export interface InternalRoomOptions {
|
||||
|
||||
singlePeerConnection: boolean;
|
||||
subscriberVideoCodecExclusions?: Array<VideoCodec>;
|
||||
screenShareDelivery?: boolean;
|
||||
h264HardwareProfiles?: ReadonlySet<string>;
|
||||
dataStream?: RoomDataStreamOptions;
|
||||
}
|
||||
|
||||
@@ -73,66 +73,34 @@ describe('applyVideoStartBitrate', () => {
|
||||
}
|
||||
|
||||
it('adds a start bitrate to a non-SVC codec section', () => {
|
||||
for (const screenShareDelivery of [false, true]) {
|
||||
const media = videoMedia('camera-track', [
|
||||
{payload: 96, config: 'level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=42e01f'},
|
||||
]);
|
||||
expect(applyVideoStartBitrate(media, 'camera-track', 'H264', 1000, false, screenShareDelivery)).toBe(96);
|
||||
expect(media.fmtp[0]?.config).toBe(
|
||||
'level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=42e01f;x-google-start-bitrate=900',
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('caps camera start bitrates but not screen share start bitrates while screen share delivery is off', () => {
|
||||
const camera = videoMedia('camera-track', [{payload: 96, config: 'profile-level-id=42e01f'}]);
|
||||
applyVideoStartBitrate(camera, 'camera-track', 'H264', 3000);
|
||||
expect(camera.fmtp[0]?.config).toBe('profile-level-id=42e01f;x-google-start-bitrate=1000');
|
||||
|
||||
const screen = videoMedia('screen-track', [{payload: 96, config: 'profile-level-id=42e01f'}]);
|
||||
applyVideoStartBitrate(screen, 'screen-track', 'H264', 6000, true);
|
||||
expect(screen.fmtp[0]?.config).toBe('profile-level-id=42e01f;x-google-start-bitrate=5400');
|
||||
const media = videoMedia('camera-track', [
|
||||
{payload: 96, config: 'level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=42e01f'},
|
||||
]);
|
||||
expect(applyVideoStartBitrate(media, 'camera-track', 'H264', 1000, false)).toBe(96);
|
||||
expect(media.fmtp[0]?.config).toBe(
|
||||
'level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=42e01f;x-google-start-bitrate=900',
|
||||
);
|
||||
});
|
||||
|
||||
it('caps camera and screen share start bitrates at their own ceilings', () => {
|
||||
const camera = videoMedia('camera-track', [{payload: 96, config: 'profile-level-id=42e01f'}]);
|
||||
applyVideoStartBitrate(camera, 'camera-track', 'H264', 3000, false, true);
|
||||
applyVideoStartBitrate(camera, 'camera-track', 'H264', 3000, false);
|
||||
expect(camera.fmtp[0]?.config).toBe('profile-level-id=42e01f;x-google-start-bitrate=1000');
|
||||
|
||||
const screen = videoMedia('screen-track', [{payload: 96, config: 'profile-level-id=42e01f'}]);
|
||||
applyVideoStartBitrate(screen, 'screen-track', 'H264', 6000, true, true);
|
||||
applyVideoStartBitrate(screen, 'screen-track', 'H264', 6000, true);
|
||||
expect(screen.fmtp[0]?.config).toBe('profile-level-id=42e01f;x-google-start-bitrate=1500');
|
||||
});
|
||||
|
||||
it('leaves a small screen share start bitrate on the floor while screen share delivery is off', () => {
|
||||
const screen = videoMedia('screen-track', [{payload: 96, config: 'profile-level-id=42e01f'}]);
|
||||
applyVideoStartBitrate(screen, 'screen-track', 'H264', 300, true);
|
||||
expect(screen.fmtp[0]?.config).toBe('profile-level-id=42e01f;x-google-start-bitrate=270');
|
||||
});
|
||||
|
||||
it('keeps a screen share start bitrate above the frame dropper cliff', () => {
|
||||
const screen = videoMedia('screen-track', [{payload: 96, config: 'profile-level-id=42e01f'}]);
|
||||
applyVideoStartBitrate(screen, 'screen-track', 'H264', 300, true, true);
|
||||
applyVideoStartBitrate(screen, 'screen-track', 'H264', 300, true);
|
||||
expect(screen.fmtp[0]?.config).toBe('profile-level-id=42e01f;x-google-start-bitrate=600');
|
||||
});
|
||||
|
||||
it('stamps only the lead payload type while screen share delivery is off', () => {
|
||||
const media = multiPayloadScreenMedia();
|
||||
expect(applyVideoStartBitrate(media, 'screen-track', 'H264', 6000, true)).toBe(116);
|
||||
expect(media.fmtp.find((fmtp) => fmtp.payload === 116)?.config).toBe(
|
||||
'level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=4d001f;x-google-start-bitrate=5400',
|
||||
);
|
||||
expect(media.fmtp.find((fmtp) => fmtp.payload === 102)?.config).toBe(
|
||||
'level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=42001f',
|
||||
);
|
||||
expect(media.fmtp.find((fmtp) => fmtp.payload === 108)?.config).toBe(
|
||||
'level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=42e01f',
|
||||
);
|
||||
});
|
||||
|
||||
it('stamps every payload type the codec is offered under, not only the lead one', () => {
|
||||
const media = multiPayloadScreenMedia();
|
||||
expect(applyVideoStartBitrate(media, 'screen-track', 'H264', 6000, true, true)).toBe(116);
|
||||
expect(applyVideoStartBitrate(media, 'screen-track', 'H264', 6000, true)).toBe(116);
|
||||
for (const fmtp of media.fmtp) {
|
||||
expect(fmtp.config).toContain('x-google-start-bitrate=1500');
|
||||
}
|
||||
@@ -140,47 +108,35 @@ describe('applyVideoStartBitrate', () => {
|
||||
});
|
||||
|
||||
it('only touches the fmtp line for the matching payload', () => {
|
||||
for (const screenShareDelivery of [false, true]) {
|
||||
const media = videoMedia(
|
||||
'screen-track',
|
||||
[
|
||||
{payload: 96, config: 'profile-level-id=42e01f'},
|
||||
{payload: 98, config: 'profile-id=0'},
|
||||
],
|
||||
[
|
||||
{payload: 96, codec: 'H264'},
|
||||
{payload: 98, codec: 'VP9'},
|
||||
],
|
||||
);
|
||||
applyVideoStartBitrate(media, 'screen-track', 'VP9', 1500, true, screenShareDelivery);
|
||||
expect(media.fmtp[0]?.config).toBe('profile-level-id=42e01f');
|
||||
expect(media.fmtp[1]?.config).toBe('profile-id=0;x-google-start-bitrate=1350');
|
||||
}
|
||||
});
|
||||
|
||||
it('never appends a second start bitrate while screen share delivery is off', () => {
|
||||
const media = videoMedia('camera-track', [
|
||||
{payload: 96, config: 'profile-level-id=42e01f;x-google-start-bitrate=900'},
|
||||
]);
|
||||
applyVideoStartBitrate(media, 'camera-track', 'H264', 2000);
|
||||
expect(media.fmtp[0]?.config).toBe('profile-level-id=42e01f;x-google-start-bitrate=900');
|
||||
const media = videoMedia(
|
||||
'screen-track',
|
||||
[
|
||||
{payload: 96, config: 'profile-level-id=42e01f'},
|
||||
{payload: 98, config: 'profile-id=0'},
|
||||
],
|
||||
[
|
||||
{payload: 96, codec: 'H264'},
|
||||
{payload: 98, codec: 'VP9'},
|
||||
],
|
||||
);
|
||||
applyVideoStartBitrate(media, 'screen-track', 'VP9', 1500, true);
|
||||
expect(media.fmtp[0]?.config).toBe('profile-level-id=42e01f');
|
||||
expect(media.fmtp[1]?.config).toBe('profile-id=0;x-google-start-bitrate=1350');
|
||||
});
|
||||
|
||||
it('replaces a start bitrate an earlier offer wrote instead of keeping it', () => {
|
||||
const media = videoMedia('camera-track', [
|
||||
{payload: 96, config: 'profile-level-id=42e01f;x-google-start-bitrate=900'},
|
||||
]);
|
||||
applyVideoStartBitrate(media, 'camera-track', 'H264', 2000, false, true);
|
||||
applyVideoStartBitrate(media, 'camera-track', 'H264', 2000, false);
|
||||
expect(media.fmtp[0]?.config).toBe('profile-level-id=42e01f;x-google-start-bitrate=1000');
|
||||
});
|
||||
|
||||
it('leaves other tracks and missing codecs alone', () => {
|
||||
for (const screenShareDelivery of [false, true]) {
|
||||
const media = videoMedia('camera-track', [{payload: 96, config: 'profile-level-id=42e01f'}]);
|
||||
expect(applyVideoStartBitrate(media, 'other-track', 'H264', 2000, false, screenShareDelivery)).toBeUndefined();
|
||||
expect(applyVideoStartBitrate(media, 'camera-track', 'AV1', 2000, false, screenShareDelivery)).toBe(0);
|
||||
expect(media.fmtp[0]?.config).toBe('profile-level-id=42e01f');
|
||||
}
|
||||
const media = videoMedia('camera-track', [{payload: 96, config: 'profile-level-id=42e01f'}]);
|
||||
expect(applyVideoStartBitrate(media, 'other-track', 'H264', 2000, false)).toBeUndefined();
|
||||
expect(applyVideoStartBitrate(media, 'camera-track', 'AV1', 2000, false)).toBe(0);
|
||||
expect(media.fmtp[0]?.config).toBe('profile-level-id=42e01f');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -49,7 +49,6 @@ export function applyVideoStartBitrate(
|
||||
codec: string,
|
||||
maxbr: number,
|
||||
isScreenShare = false,
|
||||
screenShareDelivery = false,
|
||||
): number | undefined {
|
||||
if (!media.msid?.includes(cid)) {
|
||||
return undefined;
|
||||
@@ -65,25 +64,10 @@ export function applyVideoStartBitrate(
|
||||
const calculatedStartBitrate = Math.round(maxbr * startBitrateMultiplier);
|
||||
let startBitrate = Math.min(calculatedStartBitrate, maxStartBitrateKbps);
|
||||
if (isScreenShare) {
|
||||
startBitrate = screenShareDelivery
|
||||
? Math.max(minScreenShareStartBitrateKbps, Math.min(calculatedStartBitrate, maxScreenShareStartBitrateKbps))
|
||||
: calculatedStartBitrate;
|
||||
}
|
||||
|
||||
if (!screenShareDelivery) {
|
||||
const codecPayload = codecPayloads[0];
|
||||
const fmtp = media.fmtp.find((entry) => entry.payload === codecPayload);
|
||||
if (fmtp) {
|
||||
if (!fmtp.config.includes(startBitrateParameter)) {
|
||||
fmtp.config += `;${startBitrateParameter}=${startBitrate}`;
|
||||
}
|
||||
} else {
|
||||
media.fmtp.push({
|
||||
payload: codecPayload,
|
||||
config: `${startBitrateParameter}=${startBitrate}`,
|
||||
});
|
||||
}
|
||||
return codecPayload;
|
||||
startBitrate = Math.max(
|
||||
minScreenShareStartBitrateKbps,
|
||||
Math.min(calculatedStartBitrate, maxScreenShareStartBitrateKbps),
|
||||
);
|
||||
}
|
||||
|
||||
for (const payload of codecPayloads) {
|
||||
@@ -143,8 +127,6 @@ export default class PCTransport extends (EventEmitter as new () => TypedEmitter
|
||||
|
||||
excludedVideoDecoderMimeTypes: Set<string> = new Set();
|
||||
|
||||
private screenShareDelivery: boolean;
|
||||
|
||||
onOffer?: (offer: RTCSessionDescriptionInit, offerId: number) => void;
|
||||
|
||||
onIceCandidate?: (candidate: RTCIceCandidate) => void;
|
||||
@@ -161,10 +143,9 @@ export default class PCTransport extends (EventEmitter as new () => TypedEmitter
|
||||
|
||||
onTrack?: (ev: RTCTrackEvent) => void;
|
||||
|
||||
constructor(config?: RTCConfiguration, loggerOptions: LoggerOptions = {}, screenShareDelivery: boolean = false) {
|
||||
constructor(config?: RTCConfiguration, loggerOptions: LoggerOptions = {}) {
|
||||
super();
|
||||
this.loggerOptions = loggerOptions;
|
||||
this.screenShareDelivery = screenShareDelivery;
|
||||
this.log = getLogger(loggerOptions.loggerName ?? LoggerNames.PCTransport, () => this.logContext);
|
||||
this.iceLog = getLogger(LoggerNames.ICE, () => this.logContext);
|
||||
this.config = config;
|
||||
@@ -412,7 +393,6 @@ export default class PCTransport extends (EventEmitter as new () => TypedEmitter
|
||||
trackbr.codec,
|
||||
trackbr.maxbr,
|
||||
trackbr.isScreenShare,
|
||||
this.screenShareDelivery,
|
||||
);
|
||||
if (codecPayload === undefined) {
|
||||
return false;
|
||||
@@ -471,10 +451,7 @@ export default class PCTransport extends (EventEmitter as new () => TypedEmitter
|
||||
for (const transceiver of this.getTransceivers()) {
|
||||
if (transceiver.receiver.track?.kind !== 'video') continue;
|
||||
if ((transceiver as {stopped?: boolean}).stopped) continue;
|
||||
const receives = this.screenShareDelivery
|
||||
? transceiver.direction === 'recvonly'
|
||||
: transceiver.direction === 'recvonly' || transceiver.direction === 'sendrecv';
|
||||
if (!receives) continue;
|
||||
if (transceiver.direction !== 'recvonly') continue;
|
||||
if (typeof transceiver.setCodecPreferences !== 'function') continue;
|
||||
try {
|
||||
transceiver.setCodecPreferences(allowed);
|
||||
|
||||
@@ -98,7 +98,6 @@ export class PCTransportManager {
|
||||
loggerOptions: LoggerOptions,
|
||||
rtcConfig?: RTCConfiguration,
|
||||
subscriberVideoCodecExclusions?: Array<VideoCodec>,
|
||||
screenShareDelivery: boolean = false,
|
||||
) {
|
||||
this.loggerOptions = loggerOptions;
|
||||
this.log = getLogger(loggerOptions.loggerName ?? LoggerNames.PCManager, () => this.logContext);
|
||||
@@ -106,10 +105,10 @@ export class PCTransportManager {
|
||||
|
||||
this.isPublisherConnectionRequired = mode !== 'subscriber-primary';
|
||||
this.isSubscriberConnectionRequired = mode === 'subscriber-primary';
|
||||
this.publisher = new PCTransport(rtcConfig, loggerOptions, screenShareDelivery);
|
||||
this.publisher = new PCTransport(rtcConfig, loggerOptions);
|
||||
this._mode = mode;
|
||||
if (mode !== 'publisher-only') {
|
||||
this.subscriber = new PCTransport(rtcConfig, loggerOptions, screenShareDelivery);
|
||||
this.subscriber = new PCTransport(rtcConfig, loggerOptions);
|
||||
this.subscriber.onConnectionStateChange = this.updateState;
|
||||
this.subscriber.onIceConnectionStateChange = this.updateState;
|
||||
this.subscriber.onSignalingStatechange = this.updateState;
|
||||
@@ -127,7 +126,7 @@ export class PCTransportManager {
|
||||
};
|
||||
}
|
||||
|
||||
const receivingTransport = screenShareDelivery ? (this.subscriber ?? this.publisher) : this.subscriber;
|
||||
const receivingTransport = this.subscriber ?? this.publisher;
|
||||
if (receivingTransport) {
|
||||
for (const codec of subscriberVideoCodecExclusions ?? []) {
|
||||
receivingTransport.excludedVideoDecoderMimeTypes.add(`video/${codec}`);
|
||||
|
||||
@@ -25,41 +25,20 @@ describe('selectPublisherCodecPreferences', () => {
|
||||
const mainLine = 'level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=4d001f';
|
||||
const highLine = 'level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=64001f';
|
||||
|
||||
it('keeps Main and Baseline ahead of Constrained Baseline while screen share delivery is off', () => {
|
||||
const constrainedBaseline = codec('video/H264', constrainedBaselineLine);
|
||||
const baseline = codec('video/H264', baselineLine);
|
||||
const highProfile = codec('video/H264', highLine);
|
||||
const rtx = codec('video/rtx');
|
||||
const preferences = selectPublisherCodecPreferences('h264', [constrainedBaseline, rtx, baseline, highProfile]);
|
||||
expect(preferences).toEqual([highProfile, baseline, constrainedBaseline, rtx]);
|
||||
});
|
||||
|
||||
it('offers High first, then the one profile this server always registers, then the ones it registers nowhere', () => {
|
||||
const constrainedBaseline = codec('video/H264', constrainedBaselineLine);
|
||||
const baseline = codec('video/H264', baselineLine);
|
||||
const highProfile = codec('video/H264', highLine);
|
||||
const rtx = codec('video/rtx');
|
||||
const preferences = selectPublisherCodecPreferences(
|
||||
'h264',
|
||||
[constrainedBaseline, rtx, baseline, highProfile],
|
||||
true,
|
||||
);
|
||||
const preferences = selectPublisherCodecPreferences('h264', [constrainedBaseline, rtx, baseline, highProfile]);
|
||||
expect(preferences).toEqual([highProfile, constrainedBaseline, baseline, rtx]);
|
||||
});
|
||||
|
||||
it('leads with Main and then Baseline while screen share delivery is off', () => {
|
||||
const constrainedBaseline = codec('video/H264', constrainedBaselineLine);
|
||||
const mainProfile = codec('video/H264', mainLine);
|
||||
const baseline = codec('video/H264', baselineLine);
|
||||
const preferences = selectPublisherCodecPreferences('h264', [mainProfile, baseline, constrainedBaseline]);
|
||||
expect(preferences).toEqual([mainProfile, baseline, constrainedBaseline]);
|
||||
});
|
||||
|
||||
it('never leads with Main or Baseline, which this server registers nowhere and deletes from the answer', () => {
|
||||
const constrainedBaseline = codec('video/H264', constrainedBaselineLine);
|
||||
const mainProfile = codec('video/H264', mainLine);
|
||||
const baseline = codec('video/H264', baselineLine);
|
||||
const preferences = selectPublisherCodecPreferences('h264', [mainProfile, baseline, constrainedBaseline], true);
|
||||
const preferences = selectPublisherCodecPreferences('h264', [mainProfile, baseline, constrainedBaseline]);
|
||||
expect(preferences).toEqual([constrainedBaseline, mainProfile, baseline]);
|
||||
});
|
||||
|
||||
@@ -75,21 +54,8 @@ describe('selectPublisherCodecPreferences', () => {
|
||||
];
|
||||
}
|
||||
|
||||
it('sorts the capabilities Chromium reports by the old table while screen share delivery is off', () => {
|
||||
const preferences = selectPublisherCodecPreferences('h264', chromiumCapabilities());
|
||||
expect(preferences.map((entry) => entry.sdpFmtpLine)).toEqual([
|
||||
'level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=640034',
|
||||
'level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=4d001f',
|
||||
'level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=42001f',
|
||||
'level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=42e01f',
|
||||
'level-asymmetry-allowed=1;packetization-mode=0;profile-level-id=4d001f',
|
||||
'level-asymmetry-allowed=1;packetization-mode=0;profile-level-id=42001f',
|
||||
'level-asymmetry-allowed=1;packetization-mode=0;profile-level-id=42e01f',
|
||||
]);
|
||||
});
|
||||
|
||||
it('offers High first out of the capabilities Chromium reports, so the only hardware profile this server registers wins', () => {
|
||||
const preferences = selectPublisherCodecPreferences('h264', chromiumCapabilities(), true);
|
||||
const preferences = selectPublisherCodecPreferences('h264', chromiumCapabilities());
|
||||
expect(preferences.map((entry) => entry.sdpFmtpLine)).toEqual([
|
||||
'level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=640034',
|
||||
'level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=42e01f',
|
||||
@@ -116,7 +82,7 @@ describe('selectPublisherCodecPreferences', () => {
|
||||
return {constrainedBaseline, mainProfile, highProfileLevel31, highProfileLevel51, constrainedHigh};
|
||||
}
|
||||
|
||||
it('ranks High, Constrained High, Main and Baseline above Constrained Baseline while screen share delivery is off', () => {
|
||||
it('ranks High and Constrained High above Constrained Baseline, whatever level each one reports', () => {
|
||||
const {constrainedBaseline, mainProfile, highProfileLevel31, highProfileLevel51, constrainedHigh} = levelSpread();
|
||||
const preferences = selectPublisherCodecPreferences('h264', [
|
||||
mainProfile,
|
||||
@@ -125,22 +91,6 @@ describe('selectPublisherCodecPreferences', () => {
|
||||
constrainedHigh,
|
||||
constrainedBaseline,
|
||||
]);
|
||||
expect(preferences).toEqual([
|
||||
highProfileLevel31,
|
||||
highProfileLevel51,
|
||||
constrainedHigh,
|
||||
mainProfile,
|
||||
constrainedBaseline,
|
||||
]);
|
||||
});
|
||||
|
||||
it('ranks High and Constrained High above Constrained Baseline, whatever level each one reports', () => {
|
||||
const {constrainedBaseline, mainProfile, highProfileLevel31, highProfileLevel51, constrainedHigh} = levelSpread();
|
||||
const preferences = selectPublisherCodecPreferences(
|
||||
'h264',
|
||||
[mainProfile, highProfileLevel31, highProfileLevel51, constrainedHigh, constrainedBaseline],
|
||||
true,
|
||||
);
|
||||
expect(preferences).toEqual([
|
||||
highProfileLevel31,
|
||||
highProfileLevel51,
|
||||
@@ -150,7 +100,7 @@ describe('selectPublisherCodecPreferences', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('ranks packetization-mode=1 above packetization-mode=0 in both arms, which no hardware encoder takes', () => {
|
||||
it('ranks packetization-mode=1 above packetization-mode=0, which no hardware encoder takes', () => {
|
||||
const constrainedBaselineMode0 = codec(
|
||||
'video/H264',
|
||||
'level-asymmetry-allowed=1;packetization-mode=0;profile-level-id=42e01f',
|
||||
@@ -167,7 +117,6 @@ describe('selectPublisherCodecPreferences', () => {
|
||||
const capabilities = [highProfileMode0, constrainedBaselineMode0, constrainedBaselineMode1, highProfileMode1];
|
||||
const expected = [highProfileMode1, constrainedBaselineMode1, highProfileMode0, constrainedBaselineMode0];
|
||||
expect(selectPublisherCodecPreferences('h264', capabilities)).toEqual(expected);
|
||||
expect(selectPublisherCodecPreferences('h264', capabilities, true)).toEqual(expected);
|
||||
});
|
||||
|
||||
it('puts the chosen codec first and keeps every other codec in browser capability order', () => {
|
||||
@@ -175,7 +124,6 @@ describe('selectPublisherCodecPreferences', () => {
|
||||
const vp8 = codec('video/VP8');
|
||||
const rtx = codec('video/rtx');
|
||||
expect(selectPublisherCodecPreferences('vp9', [vp8, rtx, vp9])).toEqual([vp9, vp8, rtx]);
|
||||
expect(selectPublisherCodecPreferences('vp9', [vp8, rtx, vp9], true)).toEqual([vp9, vp8, rtx]);
|
||||
});
|
||||
|
||||
it('keeps the other codecs so a later publication on the same connection can negotiate them', () => {
|
||||
@@ -192,7 +140,7 @@ describe('selectPublisherCodecPreferences', () => {
|
||||
const constrainedBaseline = codec('video/H264', constrainedBaselineLine);
|
||||
const capabilities = [vp8, highProfile, constrainedBaseline];
|
||||
expect(selectPublisherCodecPreferences('vp8', capabilities)).toEqual([vp8, highProfile, constrainedBaseline]);
|
||||
expect(selectPublisherCodecPreferences('vp8', capabilities, true, new Set(['42e0']))).toEqual([
|
||||
expect(selectPublisherCodecPreferences('vp8', capabilities, new Set(['42e0']))).toEqual([
|
||||
vp8,
|
||||
constrainedBaseline,
|
||||
highProfile,
|
||||
@@ -203,25 +151,15 @@ describe('selectPublisherCodecPreferences', () => {
|
||||
expect(selectPublisherCodecPreferences('av1', [codec('video/VP8'), codec('video/rtx')])).toEqual([]);
|
||||
});
|
||||
|
||||
it('ignores the profiles this host measured while screen share delivery is off', () => {
|
||||
const constrainedBaseline = codec('video/H264', constrainedBaselineLine);
|
||||
const highProfile = codec('video/H264', highLine);
|
||||
const capabilities = [highProfile, constrainedBaseline];
|
||||
expect(selectPublisherCodecPreferences('h264', capabilities, false, new Set(['42e0']))).toEqual([
|
||||
highProfile,
|
||||
constrainedBaseline,
|
||||
]);
|
||||
});
|
||||
|
||||
it('only lets a profile this host encodes in hardware outrank Constrained Baseline', () => {
|
||||
const constrainedBaseline = codec('video/H264', constrainedBaselineLine);
|
||||
const highProfile = codec('video/H264', highLine);
|
||||
const capabilities = [highProfile, constrainedBaseline];
|
||||
expect(selectPublisherCodecPreferences('h264', capabilities, true, new Set(['42e0']))).toEqual([
|
||||
expect(selectPublisherCodecPreferences('h264', capabilities, new Set(['42e0']))).toEqual([
|
||||
constrainedBaseline,
|
||||
highProfile,
|
||||
]);
|
||||
expect(selectPublisherCodecPreferences('h264', capabilities, true, new Set(['6400', '42e0']))).toEqual([
|
||||
expect(selectPublisherCodecPreferences('h264', capabilities, new Set(['6400', '42e0']))).toEqual([
|
||||
highProfile,
|
||||
constrainedBaseline,
|
||||
]);
|
||||
@@ -231,8 +169,8 @@ describe('selectPublisherCodecPreferences', () => {
|
||||
const constrainedBaseline = codec('video/H264', constrainedBaselineLine);
|
||||
const highProfile = codec('video/H264', highLine);
|
||||
const capabilities = [constrainedBaseline, highProfile];
|
||||
expect(selectPublisherCodecPreferences('h264', capabilities, true)).toEqual([highProfile, constrainedBaseline]);
|
||||
expect(selectPublisherCodecPreferences('h264', capabilities, true, new Set())).toEqual([
|
||||
expect(selectPublisherCodecPreferences('h264', capabilities)).toEqual([highProfile, constrainedBaseline]);
|
||||
expect(selectPublisherCodecPreferences('h264', capabilities, new Set())).toEqual([
|
||||
highProfile,
|
||||
constrainedBaseline,
|
||||
]);
|
||||
@@ -245,7 +183,7 @@ describe('selectPublisherCodecPreferences', () => {
|
||||
'level-asymmetry-allowed=1;packetization-mode=0;profile-level-id=64001f',
|
||||
);
|
||||
expect(
|
||||
selectPublisherCodecPreferences('h264', [highProfileMode0, constrainedBaselineMode1], true, new Set(['6400'])),
|
||||
selectPublisherCodecPreferences('h264', [highProfileMode0, constrainedBaselineMode1], new Set(['6400'])),
|
||||
).toEqual([constrainedBaselineMode1, highProfileMode0]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -123,13 +123,6 @@ const videoCodecMimeTypes: Record<VideoCodec, Array<string>> = {
|
||||
vp8: ['video/vp8'],
|
||||
};
|
||||
const h264ProfileRanks = new Map([
|
||||
['6400', 0],
|
||||
['640c', 1],
|
||||
['4d00', 2],
|
||||
['4200', 3],
|
||||
['42e0', 4],
|
||||
]);
|
||||
const h264DeliveryProfileRanks = new Map([
|
||||
['6400', 0],
|
||||
['640c', 1],
|
||||
['42e0', 2],
|
||||
@@ -139,8 +132,7 @@ const h264DeliveryProfileRanks = new Map([
|
||||
const h264UnrankedProfileScore = 5;
|
||||
const h264MissingProfileScore = 6;
|
||||
const h264NonHardwareProfilePenalty = 8;
|
||||
const h264PacketizationMode0Score = 10;
|
||||
const h264DeliveryPacketizationMode0Score = 20;
|
||||
const h264PacketizationMode0Score = 20;
|
||||
type RtpCodecCapability = RTCRtpCapabilities['codecs'][number] & {sdpFmtpLine?: string};
|
||||
|
||||
enum PCState {
|
||||
@@ -523,7 +515,6 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit
|
||||
this.loggerOptions,
|
||||
rtcConfig,
|
||||
this.options.subscriberVideoCodecExclusions,
|
||||
this.options.screenShareDelivery ?? false,
|
||||
);
|
||||
} else {
|
||||
this.participantSid = joinResponse.participant?.sid;
|
||||
@@ -537,7 +528,6 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit
|
||||
this.loggerOptions,
|
||||
rtcConfig,
|
||||
this.options.subscriberVideoCodecExclusions,
|
||||
this.options.screenShareDelivery ?? false,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1062,12 +1052,7 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit
|
||||
if (typeof RTCRtpSender === 'undefined' || typeof RTCRtpSender.getCapabilities !== 'function') return;
|
||||
const capabilities = RTCRtpSender.getCapabilities('video');
|
||||
if (!capabilities) return;
|
||||
const preferences = selectPublisherCodecPreferences(
|
||||
codec,
|
||||
capabilities.codecs,
|
||||
this.options.screenShareDelivery ?? false,
|
||||
this.options.h264HardwareProfiles,
|
||||
);
|
||||
const preferences = selectPublisherCodecPreferences(codec, capabilities.codecs, this.options.h264HardwareProfiles);
|
||||
if (preferences.length === 0) {
|
||||
this.log.warn('sender cannot encode the requested codec, leaving the browser order in place', {
|
||||
...this.logContext,
|
||||
@@ -1843,33 +1828,27 @@ function getFmtpParameter(sdpFmtpLine: string | undefined, key: string): string
|
||||
|
||||
function getH264PublisherCodecScore(
|
||||
codec: RtpCodecCapability,
|
||||
screenShareDelivery: boolean,
|
||||
hardwareProfiles: ReadonlySet<string> | undefined,
|
||||
): number {
|
||||
const profileLevelId = getFmtpParameter(codec.sdpFmtpLine, 'profile-level-id');
|
||||
const packetizationMode = getFmtpParameter(codec.sdpFmtpLine, 'packetization-mode');
|
||||
const mode0Score = screenShareDelivery ? h264DeliveryPacketizationMode0Score : h264PacketizationMode0Score;
|
||||
const packetizationScore = packetizationMode === '1' ? 0 : mode0Score;
|
||||
const packetizationScore = packetizationMode === '1' ? 0 : h264PacketizationMode0Score;
|
||||
if (!profileLevelId) return packetizationScore + h264MissingProfileScore;
|
||||
const profile = profileLevelId.slice(0, 4);
|
||||
if (!screenShareDelivery) {
|
||||
return packetizationScore + (h264ProfileRanks.get(profile) ?? h264UnrankedProfileScore);
|
||||
}
|
||||
const isSoftwareOnly = hardwareProfiles !== undefined && hardwareProfiles.size > 0 && !hardwareProfiles.has(profile);
|
||||
const hardwareScore = isSoftwareOnly ? h264NonHardwareProfilePenalty : 0;
|
||||
return packetizationScore + hardwareScore + (h264DeliveryProfileRanks.get(profile) ?? h264UnrankedProfileScore);
|
||||
return packetizationScore + hardwareScore + (h264ProfileRanks.get(profile) ?? h264UnrankedProfileScore);
|
||||
}
|
||||
|
||||
function preferHardwareH264Codecs(
|
||||
codecs: ReadonlyArray<RtpCodecCapability>,
|
||||
screenShareDelivery: boolean,
|
||||
hardwareProfiles: ReadonlySet<string> | undefined,
|
||||
): Array<RtpCodecCapability> {
|
||||
return codecs
|
||||
.map((codec, index) => ({
|
||||
codec,
|
||||
index,
|
||||
score: getH264PublisherCodecScore(codec, screenShareDelivery, hardwareProfiles),
|
||||
score: getH264PublisherCodecScore(codec, hardwareProfiles),
|
||||
}))
|
||||
.sort((a, b) => a.score - b.score || a.index - b.index)
|
||||
.map((entry) => entry.codec);
|
||||
@@ -1878,17 +1857,15 @@ function preferHardwareH264Codecs(
|
||||
export function selectPublisherCodecPreferences(
|
||||
codec: VideoCodec,
|
||||
codecs: ReadonlyArray<RtpCodecCapability>,
|
||||
screenShareDelivery: boolean = false,
|
||||
h264HardwareProfiles?: ReadonlySet<string>,
|
||||
): Array<RtpCodecCapability> {
|
||||
const mimeTypes = new Set(videoCodecMimeTypes[codec]);
|
||||
const selected = codecs.filter((entry) => mimeTypes.has(entry.mimeType.toLowerCase()));
|
||||
if (selected.length === 0) return [];
|
||||
const preferred =
|
||||
codec === 'h264' ? preferHardwareH264Codecs(selected, screenShareDelivery, h264HardwareProfiles) : selected;
|
||||
const preferred = codec === 'h264' ? preferHardwareH264Codecs(selected, h264HardwareProfiles) : selected;
|
||||
const isH264 = (entry: RtpCodecCapability): boolean => entry.mimeType.toLowerCase() === 'video/h264';
|
||||
const remaining = codecs.filter((entry) => !mimeTypes.has(entry.mimeType.toLowerCase()));
|
||||
const rankedH264 = preferHardwareH264Codecs(remaining.filter(isH264), screenShareDelivery, h264HardwareProfiles);
|
||||
const rankedH264 = preferHardwareH264Codecs(remaining.filter(isH264), h264HardwareProfiles);
|
||||
let nextH264 = 0;
|
||||
const rest = remaining.map((entry) => (isH264(entry) ? rankedH264[nextH264++] : entry));
|
||||
return [...preferred, ...rest];
|
||||
|
||||
@@ -63,7 +63,6 @@ import {
|
||||
publishDefaults,
|
||||
roomConnectOptionDefaults,
|
||||
roomOptionDefaults,
|
||||
screenShareDeliveryPublishDefaults,
|
||||
videoDefaults,
|
||||
} from './defaults.ts';
|
||||
import {ConnectionError, ConnectionErrorReason, UnexpectedConnectionState, UnsupportedServer} from './errors.ts';
|
||||
@@ -228,7 +227,7 @@ class Room extends (EventEmitter as new () => TypedEmitter<RoomEventCallbacks>)
|
||||
...options?.videoCaptureDefaults,
|
||||
};
|
||||
this.options.publishDefaults = {
|
||||
...(this.options.screenShareDelivery ? screenShareDeliveryPublishDefaults : publishDefaults),
|
||||
...publishDefaults,
|
||||
...options?.publishDefaults,
|
||||
};
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ import {AudioPresets, BackupCodecPolicy, ScreenSharePresets, VideoPresets} from
|
||||
|
||||
export const defaultVideoCodec = 'h264';
|
||||
|
||||
export const screenShareDeliveryPublishDefaults: TrackPublishDefaults = {
|
||||
export const publishDefaults: TrackPublishDefaults = {
|
||||
audioPreset: AudioPresets.music,
|
||||
dtx: false,
|
||||
red: true,
|
||||
@@ -21,11 +21,6 @@ export const screenShareDeliveryPublishDefaults: TrackPublishDefaults = {
|
||||
preConnectBuffer: false,
|
||||
} as const;
|
||||
|
||||
export const publishDefaults: TrackPublishDefaults = {
|
||||
...screenShareDeliveryPublishDefaults,
|
||||
degradationPreference: 'maintain-resolution',
|
||||
};
|
||||
|
||||
export const audioDefaults: AudioCaptureOptions = {
|
||||
deviceId: {ideal: 'default'},
|
||||
autoGainControl: true,
|
||||
|
||||
@@ -814,7 +814,6 @@ export default class LocalParticipant extends Participant {
|
||||
...this.roomOptions.publishDefaults,
|
||||
...options,
|
||||
};
|
||||
track.screenShareDelivery = this.roomOptions.screenShareDelivery ?? false;
|
||||
const isStereoInput =
|
||||
('channelCount' in track.mediaStreamTrack.getSettings() &&
|
||||
track.mediaStreamTrack.getSettings().channelCount === 2) ||
|
||||
@@ -1788,7 +1787,7 @@ export default class LocalParticipant extends Participant {
|
||||
return;
|
||||
}
|
||||
let subscribedCodecs = update.subscribedCodecs;
|
||||
if (this.roomOptions.screenShareDelivery && hasSingleRidlessEncoding(pub.videoTrack)) {
|
||||
if (hasSingleRidlessEncoding(pub.videoTrack)) {
|
||||
subscribedCodecs = subscribedCodecs.filter((codec) => codec.qualities.some((quality) => quality.enabled));
|
||||
if (subscribedCodecs.length === 0) {
|
||||
return;
|
||||
|
||||
@@ -41,8 +41,6 @@ export default abstract class LocalTrack<TrackKind extends Track.Kind = Track.Ki
|
||||
|
||||
codec?: VideoCodec;
|
||||
|
||||
screenShareDelivery: boolean = false;
|
||||
|
||||
get constraints() {
|
||||
return this._constraints;
|
||||
}
|
||||
@@ -563,7 +561,7 @@ export default abstract class LocalTrack<TrackKind extends Track.Kind = Track.Ki
|
||||
);
|
||||
|
||||
private debouncedTrackMuteHandler = debounce(async () => {
|
||||
if (this.screenShareDelivery && this.source === Track.Source.ScreenShare) {
|
||||
if (this.source === Track.Source.ScreenShare) {
|
||||
this.log.debug('screen share capture went idle, keeping upstream published', this.logContext);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -433,6 +433,7 @@ export default () => {
|
||||
staticFilesPlugin({
|
||||
staticCdnEndpoint: normalizedStaticCdnEndpoint,
|
||||
fontsDir: path.join(MONOREPO_ROOT, 'packages', 'fonts'),
|
||||
wasmCratesDir: path.join(ROOT_DIR, 'rust'),
|
||||
}),
|
||||
new DefinePlugin({
|
||||
__FLUXER_PRECACHE_MANIFEST__: JSON.stringify([]),
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
BSD 3-Clause License
|
||||
|
||||
Copyright (c) 2026, Alexandre Bury
|
||||
|
||||
Redistribution and use in source and binary forms, with or without
|
||||
modification, are permitted provided that the following conditions are met:
|
||||
|
||||
1. Redistributions of source code must retain the above copyright notice, this
|
||||
list of conditions and the following disclaimer.
|
||||
|
||||
2. Redistributions in binary form must reproduce the above copyright notice,
|
||||
this list of conditions and the following disclaimer in the documentation
|
||||
and/or other materials provided with the distribution.
|
||||
|
||||
3. Neither the name of the copyright holder nor the names of its
|
||||
contributors may be used to endorse or promote products derived from
|
||||
this software without specific prior written permission.
|
||||
|
||||
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
||||
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
||||
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
||||
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
|
||||
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
|
||||
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
|
||||
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
|
||||
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
|
||||
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
||||
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||
@@ -0,0 +1,32 @@
|
||||
The auto-generated bindings are under the 3-clause BSD license:
|
||||
|
||||
BSD License
|
||||
|
||||
For Zstandard software
|
||||
|
||||
Copyright (c) Meta Platforms, Inc. and affiliates. All rights reserved.
|
||||
|
||||
Redistribution and use in source and binary forms, with or without modification,
|
||||
are permitted provided that the following conditions are met:
|
||||
|
||||
* Redistributions of source code must retain the above copyright notice, this
|
||||
list of conditions and the following disclaimer.
|
||||
|
||||
* Redistributions in binary form must reproduce the above copyright notice,
|
||||
this list of conditions and the following disclaimer in the documentation
|
||||
and/or other materials provided with the distribution.
|
||||
|
||||
* Neither the name Facebook, nor Meta, nor the names of its contributors may
|
||||
be used to endorse or promote products derived from this software without
|
||||
specific prior written permission.
|
||||
|
||||
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
|
||||
ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
|
||||
WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
||||
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR
|
||||
ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
|
||||
(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
|
||||
LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
|
||||
ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
|
||||
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
|
||||
SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||
@@ -0,0 +1,30 @@
|
||||
BSD License
|
||||
|
||||
For Zstandard software
|
||||
|
||||
Copyright (c) Meta Platforms, Inc. and affiliates. All rights reserved.
|
||||
|
||||
Redistribution and use in source and binary forms, with or without modification,
|
||||
are permitted provided that the following conditions are met:
|
||||
|
||||
* Redistributions of source code must retain the above copyright notice, this
|
||||
list of conditions and the following disclaimer.
|
||||
|
||||
* Redistributions in binary form must reproduce the above copyright notice,
|
||||
this list of conditions and the following disclaimer in the documentation
|
||||
and/or other materials provided with the distribution.
|
||||
|
||||
* Neither the name Facebook, nor Meta, nor the names of its contributors may
|
||||
be used to endorse or promote products derived from this software without
|
||||
specific prior written permission.
|
||||
|
||||
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
|
||||
ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
|
||||
WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
||||
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR
|
||||
ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
|
||||
(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
|
||||
LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
|
||||
ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
|
||||
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
|
||||
SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||
@@ -0,0 +1,12 @@
|
||||
# libfluxcore licenses
|
||||
|
||||
`libfluxcore_bg.wasm` is built from this crate and bundled into the app. It
|
||||
contains third-party code that keeps its upstream license.
|
||||
|
||||
| Component | Source | License |
|
||||
| --- | --- | --- |
|
||||
| Zstandard 1.5.7 | Vendored by the `zstd-sys` 2.1.0 crate | BSD-3-Clause, see `LICENSE-ZSTD.txt` (Zstandard is dual licensed, Fluxer uses it under BSD-3-Clause) |
|
||||
| `zstd-sys` 2.1.0 | Rust bindings, build script and WebAssembly libc shim | BSD-3-Clause, see `LICENSE-ZSTD-SYS.txt` |
|
||||
| `zstd` 0.14.0 | Rust wrapper | BSD-3-Clause, see `LICENSE-ZSTD-RS.txt` |
|
||||
|
||||
No Fluxer license notice grants rights to third-party trademarks or brand names.
|
||||
@@ -16,6 +16,22 @@ pub fn is_animated_image_bytes(input: &[u8]) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
pub fn sniff_image_format_bytes(input: &[u8]) -> u8 {
|
||||
if is_png(input) {
|
||||
1
|
||||
} else if is_gif(input) {
|
||||
2
|
||||
} else if is_webp(input) {
|
||||
3
|
||||
} else if is_avif_file(input) {
|
||||
4
|
||||
} else if input.starts_with(&[0xff, 0xd8, 0xff]) {
|
||||
5
|
||||
} else {
|
||||
0
|
||||
}
|
||||
}
|
||||
|
||||
fn is_gif(input: &[u8]) -> bool {
|
||||
input.starts_with(b"GIF89a") || input.starts_with(b"GIF87a")
|
||||
}
|
||||
@@ -35,7 +51,16 @@ fn is_avif_file(input: &[u8]) -> bool {
|
||||
}
|
||||
|
||||
fn has_avif_anim(input: &[u8]) -> bool {
|
||||
is_avif_file(input) && &input[8..12] == b"avis"
|
||||
if !is_avif_file(input) {
|
||||
return false;
|
||||
}
|
||||
if &input[8..12] == b"avis" {
|
||||
return true;
|
||||
}
|
||||
let box_end = read_u32_be(input, 0).map_or(0, |size| (size as usize).min(input.len()));
|
||||
input
|
||||
.get(16..box_end)
|
||||
.is_some_and(|brands| brands.as_chunks::<4>().0.contains(b"avis"))
|
||||
}
|
||||
|
||||
fn has_apng_actl(input: &[u8]) -> bool {
|
||||
@@ -243,13 +268,53 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn detects_avif_sequence_brand() {
|
||||
let avif = b"\x00\x00\x00\x18ftypavif\x00\x00\x00\x00avis";
|
||||
assert!(!is_animated_image_bytes(avif));
|
||||
let still = b"\x00\x00\x00\x18ftypavif\x00\x00\x00\x00mif1miaf";
|
||||
assert!(!is_animated_image_bytes(still));
|
||||
|
||||
let avis = b"\x00\x00\x00\x18ftypavis\x00\x00\x00\x00avif";
|
||||
assert!(is_animated_image_bytes(avis));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detects_avis_among_compatible_brands() {
|
||||
let compat = b"\x00\x00\x00\x1cftypavif\x00\x00\x00\x00mif1avismsf1";
|
||||
assert!(is_animated_image_bytes(compat));
|
||||
|
||||
let outside_ftyp = b"\x00\x00\x00\x14ftypavif\x00\x00\x00\x00mif1avis";
|
||||
assert!(!is_animated_image_bytes(outside_ftyp));
|
||||
|
||||
let truncated = b"\x00\x00\x00\x40ftypavif\x00\x00\x00\x00mif1av";
|
||||
assert!(!is_animated_image_bytes(truncated));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sniffs_image_formats_from_magic_bytes() {
|
||||
assert_eq!(sniff_image_format_bytes(b""), 0);
|
||||
assert_eq!(sniff_image_format_bytes(b"<svg xmlns"), 0);
|
||||
assert_eq!(
|
||||
sniff_image_format_bytes(b"\x89PNG\r\n\x1a\n\0\0\0\rIHDR"),
|
||||
1
|
||||
);
|
||||
assert_eq!(sniff_image_format_bytes(b"GIF89a\x01\x00"), 2);
|
||||
assert_eq!(sniff_image_format_bytes(b"GIF87a\x01\x00"), 2);
|
||||
assert_eq!(sniff_image_format_bytes(b"RIFF\x04\0\0\0WEBPVP8 "), 3);
|
||||
assert_eq!(sniff_image_format_bytes(b"RIFF\x04\0\0\0WAVEfmt "), 0);
|
||||
assert_eq!(
|
||||
sniff_image_format_bytes(b"\x00\x00\x00\x18ftypavif\x00\x00\x00\x00"),
|
||||
4
|
||||
);
|
||||
assert_eq!(
|
||||
sniff_image_format_bytes(b"\x00\x00\x00\x18ftypavis\x00\x00\x00\x00"),
|
||||
4
|
||||
);
|
||||
assert_eq!(
|
||||
sniff_image_format_bytes(b"\x00\x00\x00\x18ftypheic\x00\x00\x00\x00"),
|
||||
0
|
||||
);
|
||||
assert_eq!(sniff_image_format_bytes(b"\xff\xd8\xff\xe0\x00\x10JFIF"), 5);
|
||||
assert_eq!(sniff_image_format_bytes(b"\xff\xd8"), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_truncated_chunks_without_panicking() {
|
||||
assert!(!is_animated_image_bytes(
|
||||
|
||||
@@ -5,8 +5,8 @@ mod rgba;
|
||||
mod zstd_frame;
|
||||
mod zstd_stream;
|
||||
|
||||
use formats::is_animated_image_bytes;
|
||||
use rgba::{TransformRequest, crop_rotate_rgba_alloc};
|
||||
use formats::{is_animated_image_bytes, sniff_image_format_bytes};
|
||||
use rgba::{TransformRequest, crop_rotate_rgba_alloc, crop_rotate_rgba_into};
|
||||
use wasm_bindgen::prelude::*;
|
||||
|
||||
#[wasm_bindgen]
|
||||
@@ -40,6 +40,39 @@ pub fn crop_rotate_rgba_raw(
|
||||
.map_err(|error| JsValue::from_str(error.message()))
|
||||
}
|
||||
|
||||
#[wasm_bindgen]
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn crop_rotate_rgba_into_buffer(
|
||||
input: &[u8],
|
||||
output: &mut [u8],
|
||||
src_width: u32,
|
||||
src_height: u32,
|
||||
x: u32,
|
||||
y: u32,
|
||||
width: u32,
|
||||
height: u32,
|
||||
rotation_deg: u32,
|
||||
output_width: u32,
|
||||
output_height: u32,
|
||||
) -> Result<(), JsValue> {
|
||||
crop_rotate_rgba_into(
|
||||
input,
|
||||
output,
|
||||
TransformRequest {
|
||||
src_width,
|
||||
src_height,
|
||||
x,
|
||||
y,
|
||||
width,
|
||||
height,
|
||||
rotation_deg,
|
||||
resize_width: output_width,
|
||||
resize_height: output_height,
|
||||
},
|
||||
)
|
||||
.map_err(|error| JsValue::from_str(error.message()))
|
||||
}
|
||||
|
||||
#[wasm_bindgen]
|
||||
pub fn decompress_zstd_frame(input: &[u8]) -> Result<Vec<u8>, JsValue> {
|
||||
zstd_frame::decompress(input).map_err(zstd_error_to_js)
|
||||
@@ -106,6 +139,11 @@ pub fn is_animated_image(input: &[u8]) -> bool {
|
||||
is_animated_image_bytes(input)
|
||||
}
|
||||
|
||||
#[wasm_bindgen]
|
||||
pub fn sniff_image_format(input: &[u8]) -> u8 {
|
||||
sniff_image_format_bytes(input)
|
||||
}
|
||||
|
||||
fn optional_dimension_to_abi(value: Option<u32>) -> u32 {
|
||||
value.filter(|dimension| *dimension > 0).unwrap_or(u32::MAX)
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ pub struct TransformRequest {
|
||||
pub enum TransformError {
|
||||
InvalidDimensions,
|
||||
InvalidRgbaLength,
|
||||
InvalidOutputLength,
|
||||
EmptyCrop,
|
||||
EmptyTarget,
|
||||
ImageTooLarge,
|
||||
@@ -33,6 +34,7 @@ impl TransformError {
|
||||
match self {
|
||||
Self::InvalidDimensions => "invalid RGBA dimensions",
|
||||
Self::InvalidRgbaLength => "RGBA input length does not match dimensions",
|
||||
Self::InvalidOutputLength => "RGBA output length does not match target dimensions",
|
||||
Self::EmptyCrop => "Crop area is empty",
|
||||
Self::EmptyTarget => "Target dimensions are empty",
|
||||
Self::ImageTooLarge => "Image is too large to crop",
|
||||
@@ -75,12 +77,7 @@ pub fn crop_rotate_rgba_alloc(
|
||||
input: &[u8],
|
||||
request: TransformRequest,
|
||||
) -> Result<Vec<u8>, TransformError> {
|
||||
let geometry = output_geometry(request)?;
|
||||
let expected_len = rgba_byte_len(request.src_width, request.src_height, 0)?;
|
||||
if input.len() != expected_len {
|
||||
return Err(TransformError::InvalidRgbaLength);
|
||||
}
|
||||
|
||||
let geometry = checked_geometry(input, request)?;
|
||||
let output_len = rgba_byte_len(
|
||||
geometry.target_width,
|
||||
geometry.target_height,
|
||||
@@ -89,8 +86,45 @@ pub fn crop_rotate_rgba_alloc(
|
||||
let mut output = try_zeroed_vec(output_len)?;
|
||||
write_u32_le(&mut output, 0, geometry.target_width);
|
||||
write_u32_le(&mut output, 4, geometry.target_height);
|
||||
write_transformed(
|
||||
input,
|
||||
&mut output[RGBA_RESULT_HEADER_BYTES..],
|
||||
request,
|
||||
geometry,
|
||||
);
|
||||
Ok(output)
|
||||
}
|
||||
|
||||
let dst = &mut output[RGBA_RESULT_HEADER_BYTES..];
|
||||
pub fn crop_rotate_rgba_into(
|
||||
input: &[u8],
|
||||
output: &mut [u8],
|
||||
request: TransformRequest,
|
||||
) -> Result<(), TransformError> {
|
||||
let geometry = checked_geometry(input, request)?;
|
||||
if output.len() != rgba_byte_len(geometry.target_width, geometry.target_height, 0)? {
|
||||
return Err(TransformError::InvalidOutputLength);
|
||||
}
|
||||
write_transformed(input, output, request, geometry);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn checked_geometry(
|
||||
input: &[u8],
|
||||
request: TransformRequest,
|
||||
) -> Result<OutputGeometry, TransformError> {
|
||||
let geometry = output_geometry(request)?;
|
||||
if input.len() != rgba_byte_len(request.src_width, request.src_height, 0)? {
|
||||
return Err(TransformError::InvalidRgbaLength);
|
||||
}
|
||||
Ok(geometry)
|
||||
}
|
||||
|
||||
fn write_transformed(
|
||||
input: &[u8],
|
||||
dst: &mut [u8],
|
||||
request: TransformRequest,
|
||||
geometry: OutputGeometry,
|
||||
) {
|
||||
if geometry.target_width == geometry.base_width
|
||||
&& geometry.target_height == geometry.base_height
|
||||
{
|
||||
@@ -98,8 +132,6 @@ pub fn crop_rotate_rgba_alloc(
|
||||
} else {
|
||||
copy_rotated_with_nearest_resize(input, dst, request, geometry);
|
||||
}
|
||||
|
||||
Ok(output)
|
||||
}
|
||||
|
||||
fn output_geometry(request: TransformRequest) -> Result<OutputGeometry, TransformError> {
|
||||
@@ -463,6 +495,30 @@ mod tests {
|
||||
assert!(crop_rotate_rgba_alloc(&rgba(&[1, 2, 3, 4]), empty_target).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn into_writes_the_same_pixels_as_alloc() {
|
||||
let input = rgba(&[1, 2, 3, 4, 5, 6]);
|
||||
for rotation_deg in [0, 90, 180, 270] {
|
||||
let mut transform = request(2, 3);
|
||||
transform.rotation_deg = rotation_deg;
|
||||
transform.resize_width = 5;
|
||||
transform.resize_height = 4;
|
||||
let expected = crop_rotate_rgba_alloc(&input, transform).unwrap();
|
||||
let mut output = vec![0xaa; 5 * 4 * RGBA_BYTES_PER_PIXEL];
|
||||
crop_rotate_rgba_into(&input, &mut output, transform).unwrap();
|
||||
assert_eq!(output, payload(&expected));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn into_rejects_an_output_of_the_wrong_length() {
|
||||
let mut output = vec![0; 3 * RGBA_BYTES_PER_PIXEL];
|
||||
assert_eq!(
|
||||
crop_rotate_rgba_into(&rgba(&[1, 2, 3, 4]), &mut output, request(2, 2)).unwrap_err(),
|
||||
TransformError::InvalidOutputLength
|
||||
);
|
||||
}
|
||||
|
||||
proptest! {
|
||||
#[test]
|
||||
fn identity_transform_preserves_pixels(width in 1u32..16, height in 1u32..16) {
|
||||
|
||||
Generated
+160
@@ -0,0 +1,160 @@
|
||||
# This file is automatically @generated by Cargo.
|
||||
# It is not intended for manual editing.
|
||||
version = 4
|
||||
|
||||
[[package]]
|
||||
name = "bumpalo"
|
||||
version = "3.20.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
|
||||
|
||||
[[package]]
|
||||
name = "cc"
|
||||
version = "1.4.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "54413ede23c2daf518f35156dfde027feb2374004d63bd497f983c8db9c0e313"
|
||||
dependencies = [
|
||||
"find-msvc-tools",
|
||||
"shlex",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cfg-if"
|
||||
version = "1.0.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600"
|
||||
|
||||
[[package]]
|
||||
name = "find-msvc-tools"
|
||||
version = "0.1.13"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ef25905e51abafe4dcea6c15fec58c57b601cdbd0ee53d22ea1d3016c587d39b"
|
||||
|
||||
[[package]]
|
||||
name = "glob"
|
||||
version = "0.3.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b"
|
||||
|
||||
[[package]]
|
||||
name = "libfluxwebp"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"libwebp-sys",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "libwebp-sys"
|
||||
version = "0.14.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6b3a87b44e34d17161e4f17d92a463d596cb13825dcd1758ed18fd3a721e189c"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"glob",
|
||||
"pkg-config",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "once_cell"
|
||||
version = "1.21.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
|
||||
|
||||
[[package]]
|
||||
name = "pkg-config"
|
||||
version = "0.3.34"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548"
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro2"
|
||||
version = "1.0.107"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
|
||||
dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quote"
|
||||
version = "1.0.47"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustversion"
|
||||
version = "1.0.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
|
||||
|
||||
[[package]]
|
||||
name = "shlex"
|
||||
version = "2.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "3.0.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "unicode-ident"
|
||||
version = "1.0.26"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954"
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen"
|
||||
version = "0.2.128"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "aecb87a33d3b0c5e3b7aa46336eaf486cffafbd281b195e4c8b80d50df2351bf"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"once_cell",
|
||||
"rustversion",
|
||||
"wasm-bindgen-macro",
|
||||
"wasm-bindgen-shared",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-macro"
|
||||
version = "0.2.128"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a690d511e3c1a8b3a55e33511e3c2c00c78415cd23650f32b808627f5696b9ed"
|
||||
dependencies = [
|
||||
"quote",
|
||||
"wasm-bindgen-macro-support",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-macro-support"
|
||||
version = "0.2.128"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "411e4887f0071ef2d2164a9d5fdf2d20efbef78fccd3a78b0c10a1dc5295e48a"
|
||||
dependencies = [
|
||||
"bumpalo",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
"wasm-bindgen-shared",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-shared"
|
||||
version = "0.2.128"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "81941cd78d0c92026c33e5e01312845a4cb1e9af3407f9134b100dd03144103e"
|
||||
dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
@@ -0,0 +1,22 @@
|
||||
[package]
|
||||
name = "libfluxwebp"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
license = "AGPL-3.0-or-later"
|
||||
publish = false
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib"]
|
||||
|
||||
[dependencies]
|
||||
wasm-bindgen = "=0.2.128"
|
||||
libwebp-sys = {version = "=0.14.4", default-features = false}
|
||||
|
||||
[profile.release]
|
||||
codegen-units = 1
|
||||
lto = true
|
||||
opt-level = "z"
|
||||
panic = "abort"
|
||||
strip = true
|
||||
|
||||
[workspace]
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) the libwebp-sys authors (XianYou, Kornel Lesiński and contributors)
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,30 @@
|
||||
Copyright (c) 2010, Google Inc. All rights reserved.
|
||||
|
||||
Redistribution and use in source and binary forms, with or without
|
||||
modification, are permitted provided that the following conditions are
|
||||
met:
|
||||
|
||||
* Redistributions of source code must retain the above copyright
|
||||
notice, this list of conditions and the following disclaimer.
|
||||
|
||||
* Redistributions in binary form must reproduce the above copyright
|
||||
notice, this list of conditions and the following disclaimer in
|
||||
the documentation and/or other materials provided with the
|
||||
distribution.
|
||||
|
||||
* Neither the name of Google nor the names of its contributors may
|
||||
be used to endorse or promote products derived from this software
|
||||
without specific prior written permission.
|
||||
|
||||
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
|
||||
"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
|
||||
LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
|
||||
A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
|
||||
HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
|
||||
SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
|
||||
LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
|
||||
DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
|
||||
THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
|
||||
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
||||
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
# libfluxwebp licenses
|
||||
|
||||
`libfluxwebp_bg.wasm` and `libfluxwebp_simd_bg.wasm` are built from this crate
|
||||
and bundled into the app. They contain third-party code that keeps its upstream
|
||||
license.
|
||||
|
||||
| Component | Source | License |
|
||||
| --- | --- | --- |
|
||||
| libwebp 1.6.0 | Vendored by the `libwebp-sys` 0.14.4 crate | BSD-3-Clause, see `LICENSE-LIBWEBP.txt`, with the additional patent grant in `PATENTS-LIBWEBP.txt` |
|
||||
| `libwebp-sys` 0.14.4 | Rust bindings and build script | MIT, see `LICENSE-LIBWEBP-SYS.txt` |
|
||||
| `simd/xmmintrin.h`, `simd/emmintrin.h` | Emscripten 4.0.15 SSE compatibility headers | MIT or University of Illinois/NCSA, see `simd/LICENSE` |
|
||||
|
||||
The SIMD build compiles libwebp's SSE2 code paths through the Emscripten
|
||||
headers, unmodified. The headers in `shim/` and `src/shim.rs` are Fluxer code.
|
||||
|
||||
No Fluxer license notice grants rights to third-party trademarks or brand names.
|
||||
@@ -0,0 +1,23 @@
|
||||
Additional IP Rights Grant (Patents)
|
||||
------------------------------------
|
||||
|
||||
"These implementations" means the copyrightable works that implement the WebM
|
||||
codecs distributed by Google as part of the WebM Project.
|
||||
|
||||
Google hereby grants to you a perpetual, worldwide, non-exclusive, no-charge,
|
||||
royalty-free, irrevocable (except as stated in this section) patent license to
|
||||
make, have made, use, offer to sell, sell, import, transfer, and otherwise
|
||||
run, modify and propagate the contents of these implementations of WebM, where
|
||||
such license applies only to those patent claims, both currently owned by
|
||||
Google and acquired in the future, licensable by Google that are necessarily
|
||||
infringed by these implementations of WebM. This grant does not include claims
|
||||
that would be infringed only as a consequence of further modification of these
|
||||
implementations. If you or your agent or exclusive licensee institute or order
|
||||
or agree to the institution of patent litigation or any other patent
|
||||
enforcement activity against any entity (including a cross-claim or
|
||||
counterclaim in a lawsuit) alleging that any of these implementations of WebM
|
||||
or any code incorporated within any of these implementations of WebM
|
||||
constitute direct or contributory patent infringement, or inducement of
|
||||
patent infringement, then any patent rights granted to you under this License
|
||||
for these implementations of WebM shall terminate as of the date such
|
||||
litigation is filed.
|
||||
@@ -0,0 +1,6 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
#ifndef FLUXWEBP_ASSERT_H
|
||||
#define FLUXWEBP_ASSERT_H
|
||||
#define assert(expr) ((void)0)
|
||||
#endif
|
||||
@@ -0,0 +1,6 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
#ifndef FLUXWEBP_INTTYPES_H
|
||||
#define FLUXWEBP_INTTYPES_H
|
||||
#include <stdint.h>
|
||||
#endif
|
||||
@@ -0,0 +1,33 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
#ifndef FLUXWEBP_MATH_H
|
||||
#define FLUXWEBP_MATH_H
|
||||
double fluxwebp_shim_pow(double x, double y);
|
||||
double fluxwebp_shim_log(double x);
|
||||
float fluxwebp_shim_expf(float x);
|
||||
double fluxwebp_shim_log10(double x);
|
||||
float fluxwebp_shim_logf(float x);
|
||||
double fluxwebp_shim_round(double x);
|
||||
#define pow(x, y) fluxwebp_shim_pow(x, y)
|
||||
#define log(x) fluxwebp_shim_log(x)
|
||||
#define expf(x) fluxwebp_shim_expf(x)
|
||||
#define log10(x) fluxwebp_shim_log10(x)
|
||||
#define logf(x) fluxwebp_shim_logf(x)
|
||||
#define round(x) fluxwebp_shim_round(x)
|
||||
#define fabs(x) __builtin_fabs(x)
|
||||
#define floor(x) __builtin_floor(x)
|
||||
#define ceil(x) __builtin_ceil(x)
|
||||
#define sqrt(x) __builtin_sqrt(x)
|
||||
#define sqrtf(x) __builtin_sqrtf(x)
|
||||
#define rint(x) __builtin_rint(x)
|
||||
#define rintf(x) __builtin_rintf(x)
|
||||
#define fabsf(x) __builtin_fabsf(x)
|
||||
#define floorf(x) __builtin_floorf(x)
|
||||
#define ceilf(x) __builtin_ceilf(x)
|
||||
#define isnan(x) __builtin_isnan(x)
|
||||
#define isinf(x) __builtin_isinf(x)
|
||||
#define lrint(x) ((long)__builtin_rint(x))
|
||||
#define llrint(x) ((long long)__builtin_rint(x))
|
||||
#define lrintf(x) ((long)__builtin_rintf(x))
|
||||
#define llrintf(x) ((long long)__builtin_rintf(x))
|
||||
#endif
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user