mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-08 03:32:27 +09:00
Compare commits
28
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
98cceae59d | ||
|
|
3e32414849 | ||
|
|
7707b9531c | ||
|
|
86745e01e9 | ||
|
|
098830a95a | ||
|
|
cf83f66911 | ||
|
|
c577b97f35 | ||
|
|
8cc485cf81 | ||
|
|
6c36d934f7 | ||
|
|
63e3be5750 | ||
|
|
cdecda7f78 | ||
|
|
4ad2858773 | ||
|
|
fda41bb57a | ||
|
|
ce08f82a92 | ||
|
|
ef067f36c6 | ||
|
|
fc2b6b5299 | ||
|
|
55846b24ea | ||
|
|
20a15ac11d | ||
|
|
667ac7da8e | ||
|
|
1b81c14c48 | ||
|
|
ceec183d38 | ||
|
|
69ddc07ebb | ||
|
|
2f008b8653 | ||
|
|
3d38d3f694 | ||
|
|
ad86a04e67 | ||
|
|
600c15e17d | ||
|
|
08c9fe9886 | ||
|
|
43924e3ac5 |
@@ -36,8 +36,7 @@ body:
|
||||
label: Build information
|
||||
description: >-
|
||||
Open User Settings, scroll to the bottom of the left sidebar, and select
|
||||
the build information. Fluxer copies it to the clipboard. On mobile,
|
||||
select the build information at the bottom of the settings list.
|
||||
the build information. Fluxer copies it to the clipboard.
|
||||
validations:
|
||||
required: true
|
||||
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
# yaml-language-server: $schema=https://www.schemastore.org/github-issue-config.json
|
||||
blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name: Mobile client bugs
|
||||
url: https://github.com/fluxerapp/flutter_client#bug-reporting
|
||||
about: Read the reporting instructions for the Fluxer mobile client.
|
||||
- name: Account and billing support
|
||||
url: https://fluxer.app/help
|
||||
about: Find account help and support contact details.
|
||||
- name: Feature proposals
|
||||
url: https://github.com/orgs/fluxerapp/discussions
|
||||
about: Propose a feature in a discussion.
|
||||
- name: Security vulnerabilities
|
||||
url: https://github.com/fluxerapp/fluxer/security/advisories/new
|
||||
about: Submit a private vulnerability report.
|
||||
- name: Translations
|
||||
url: https://weblate.fluxer.tools
|
||||
about: Improve an existing locale or start a new one.
|
||||
|
||||
@@ -314,8 +314,8 @@ jobs:
|
||||
path: |
|
||||
~/.cache/rebar3
|
||||
fluxer_gateway/_build
|
||||
!fluxer_gateway/_build/default/lib/fluxer_gateway
|
||||
!fluxer_gateway/_build/test/lib/fluxer_gateway
|
||||
!fluxer_gateway/_build/default/lib/fluxer_gateway/**
|
||||
!fluxer_gateway/_build/test/lib/fluxer_gateway/**
|
||||
key: >-
|
||||
rebar3-${{ runner.os }}-otp28-rebar3.24.0-${{ hashFiles('fluxer_gateway/rebar.lock',
|
||||
'fluxer_gateway/rebar.config') }}
|
||||
@@ -345,8 +345,8 @@ jobs:
|
||||
path: |
|
||||
~/.cache/rebar3
|
||||
fluxer_gateway/_build
|
||||
!fluxer_gateway/_build/default/lib/fluxer_gateway
|
||||
!fluxer_gateway/_build/test/lib/fluxer_gateway
|
||||
!fluxer_gateway/_build/default/lib/fluxer_gateway/**
|
||||
!fluxer_gateway/_build/test/lib/fluxer_gateway/**
|
||||
key: >-
|
||||
rebar3-${{ runner.os }}-otp28-rebar3.24.0-${{ hashFiles('fluxer_gateway/rebar.lock',
|
||||
'fluxer_gateway/rebar.config') }}
|
||||
@@ -426,6 +426,28 @@ jobs:
|
||||
'packages/markdown_parser/rust/Cargo.toml', 'packages/markdown_parser/rust/.cargo/config.toml',
|
||||
'packages/markdown_parser/rust/src/**') }}
|
||||
|
||||
lint:
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271
|
||||
|
||||
- name: Install Node.js
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e
|
||||
with:
|
||||
node-version: '24'
|
||||
cache: 'pnpm'
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Check formatting and lint
|
||||
run: pnpm exec biome ci .
|
||||
|
||||
i18n:
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 25
|
||||
|
||||
@@ -3,12 +3,20 @@
|
||||
# A name absent from this file is one Compose does not forward, and it reaches a
|
||||
# service only through a Compose override file that adds it to that service's
|
||||
# environment. packages/config/src/__tests__/DeployEnvCoverage.test.ts fails when
|
||||
# a Compose edit forgets the matching line here.
|
||||
# a Compose edit forgets the matching line here. Compose expands this file from
|
||||
# top to bottom, so a line written with ${...} has to sit below every name it
|
||||
# reads.
|
||||
|
||||
FLUXER_DOMAIN=chat.example.com
|
||||
FLUXER_PUBLIC_SCHEME=https
|
||||
FLUXER_PUBLIC_PORT=443
|
||||
|
||||
# The three lines above are the address browsers use, and every endpoint the
|
||||
# services advertise carries the port from FLUXER_PUBLIC_PORT. They do not move
|
||||
# what the host publishes. FLUXER_HTTP_PORT and FLUXER_HTTPS_PORT further down
|
||||
# do that, and a non-default port needs the matching one set as well. Both
|
||||
# complete recipes are written out beside them.
|
||||
|
||||
# How browsers reach this instance.
|
||||
#
|
||||
# Default: Fluxer binds 80 and 443 and gets its own Let's Encrypt certificate.
|
||||
@@ -33,50 +41,71 @@ FLUXER_PUBLIC_PORT=443
|
||||
# address if it reaches Fluxer from a public IP.
|
||||
#FLUXER_EDGE_TRUSTED_PROXIES=private_ranges
|
||||
|
||||
# The public origin browsers use, without a trailing slash. Derived from the three
|
||||
# values above and correct for the usual https-on-443 setup, so leave it alone
|
||||
# unless you serve Fluxer on a non-default port, where the port must appear here.
|
||||
# The origin browsers see, without a trailing slash. Leave it unset and each
|
||||
# service builds one from the three values at the top of this file. Set it and it
|
||||
# wins: every service reads the host, the scheme and the port out of it and
|
||||
# ignores those three names. Use it when browsers reach the instance on a host
|
||||
# FLUXER_DOMAIN does not name. It has to be a bare origin, a scheme and a host
|
||||
# and an optional port and nothing after them, or the services refuse to start.
|
||||
# It does not move the edge listener or the published ports either, so set the
|
||||
# publish below to the port written here.
|
||||
#FLUXER_PUBLIC_ORIGIN=https://chat.example.com
|
||||
|
||||
# Overrides the address Fluxer's edge listens on. Honoured in the default mode
|
||||
# only: docker-compose.proxy.yml sets the literal :8080 and Compose lets the last
|
||||
# file win, so a value here is discarded under the proxy overlay with no warning.
|
||||
# Set it only for an unusual default-mode layout, such as serving several
|
||||
# hostnames or binding a non-default TLS port.
|
||||
#FLUXER_EDGE_SITE_ADDRESS=chat.example.com
|
||||
# Overrides the address the edge listens on inside its container. Compose builds
|
||||
# it from FLUXER_PUBLIC_SCHEME and FLUXER_DOMAIN with no port, and the edge keeps
|
||||
# its container ports at 80 and 443 whatever the public port is. Caddy matches a
|
||||
# site by host and ignores the port in the Host header, so a request arriving on
|
||||
# a non-default published port still lands on this site. Put a port in this value
|
||||
# only if you also publish that same container port below, or nothing will be
|
||||
# listening where the publish points. Honoured in the default mode only:
|
||||
# docker-compose.proxy.yml sets the literal :8080 and tunnel.compose.yml the
|
||||
# literal :80, and Compose lets the last file win, so a value here is discarded
|
||||
# under either overlay with no warning. Set it for an unusual default-mode
|
||||
# layout, such as serving several hostnames. Write the scheme into it: a bare
|
||||
# hostname means automatic HTTPS on 443 whatever FLUXER_PUBLIC_SCHEME says.
|
||||
#FLUXER_EDGE_SITE_ADDRESS=https://chat.example.com
|
||||
|
||||
# The old name for the value above. It is read only when
|
||||
# FLUXER_EDGE_SITE_ADDRESS is unset, so an existing .env keeps the listener
|
||||
# it already had. Rename it to FLUXER_EDGE_SITE_ADDRESS at your convenience.
|
||||
#FLUXER_CADDY_SITE_ADDRESS=
|
||||
|
||||
# FLUXER_PUBLIC_ORIGIN is the origin browsers see. It must carry the port
|
||||
# whenever FLUXER_PUBLIC_PORT is not the default for its scheme, because an
|
||||
# origin written with a default port never matches a browser Origin header.
|
||||
# Serving on any other port means setting all three, plus the published port
|
||||
# below, and pointing FLUXER_EDGE_SITE_ADDRESS at the same scheme and host.
|
||||
# Compose expands this file from top to bottom, so FLUXER_PUBLIC_ORIGIN has to
|
||||
# stay below the two values it reads. Above them it silently expands to a bare
|
||||
# host with a trailing colon.
|
||||
#FLUXER_PUBLIC_SCHEME=http
|
||||
#FLUXER_PUBLIC_PORT=19080
|
||||
#FLUXER_PUBLIC_ORIGIN=${FLUXER_PUBLIC_SCHEME}://${FLUXER_DOMAIN}:${FLUXER_PUBLIC_PORT}
|
||||
#FLUXER_HTTP_PORT=19080
|
||||
|
||||
# Ports Caddy publishes on the host. Caddy still listens on 80 and 443 inside
|
||||
# the container, so change only these when something else already owns the
|
||||
# standard ports or another proxy sits in front. Both take an optional bind
|
||||
# address in front of the port, and 127.0.0.1 keeps the publish off every
|
||||
# public interface. FLUXER_HTTPS_PORT moves the TCP and the UDP publish
|
||||
# together, because HTTP/3 needs both on the same port.
|
||||
# Host side of the edge's publishes, and the only two names that decide which
|
||||
# host ports Fluxer binds. The container side is fixed. Container 80 carries the
|
||||
# HTTP to HTTPS redirect and the Let's Encrypt HTTP challenge under an https
|
||||
# scheme, and the site itself under an http one. Container 443 carries the TLS
|
||||
# site. FLUXER_HTTPS_PORT moves the TCP and the UDP publish together, because
|
||||
# HTTP/3 needs both on the same port. Both take an optional bind address in front
|
||||
# of the port, and 127.0.0.1 keeps the publish off every public interface. Give
|
||||
# them different host ports: the same host port on both is two publishes of one
|
||||
# port and the edge refuses to start.
|
||||
#FLUXER_HTTP_PORT=80
|
||||
#FLUXER_HTTPS_PORT=443
|
||||
#FLUXER_HTTP_PORT=127.0.0.1:80
|
||||
#FLUXER_HTTPS_PORT=127.0.0.1:443
|
||||
|
||||
# HTTPS on 8443, complete. Host 80 stays published and still answers the ACME
|
||||
# challenge. Let's Encrypt only ever connects to the public 80 or 443, so the
|
||||
# certificate is issued if a router in front forwards public 80 to this host and
|
||||
# is not issued otherwise. Serve your own certificate from the Caddyfile when it
|
||||
# cannot.
|
||||
#FLUXER_PUBLIC_PORT=8443
|
||||
#FLUXER_HTTPS_PORT=8443
|
||||
|
||||
# Plain HTTP on 19080, complete. The port 80 publish moves to 19080, so nothing
|
||||
# binds host 80. Under an http scheme nothing listens on container 443, so the
|
||||
# last line parks that publish on loopback for a host that wants 443 for
|
||||
# something else. Drop it and 443 is published and idle, which is what earlier
|
||||
# releases did.
|
||||
#FLUXER_PUBLIC_SCHEME=http
|
||||
#FLUXER_PUBLIC_PORT=19080
|
||||
#FLUXER_HTTP_PORT=19080
|
||||
#FLUXER_HTTPS_PORT=127.0.0.1:443
|
||||
|
||||
# A tunnel or another proxy in front of the stack needs no HTTPS publish at all.
|
||||
# tunnel.compose.yml ships beside this file and replaces Caddy's published ports
|
||||
# with a single loopback HTTP publish, so nothing binds 443. FLUXER_HTTP_PORT
|
||||
# with a single loopback HTTP publish, so nothing binds 443, and points the edge
|
||||
# at plain HTTP on that publish so it stops redirecting to https. FLUXER_HTTP_PORT
|
||||
# still moves that one publish. Set the line below and plain docker compose
|
||||
# commands pick the file up, or add it to your own -f flags if you pass any. The
|
||||
# file uses the !override tag, which needs Compose 2.24.4 or newer.
|
||||
@@ -100,6 +129,12 @@ FLUXER_MEDIA_PROXY_UPLOAD_RELAY_SECRET_BASE64=CHANGE_ME
|
||||
FLUXER_ADMIN_SECRET_KEY_BASE=CHANGE_ME
|
||||
FLUXER_ADMIN_OAUTH_CLIENT_SECRET=CHANGE_ME
|
||||
|
||||
# The token every service sends to NATS. The bundled NATS runs without
|
||||
# authentication, so this stays empty unless a Compose override points the stack
|
||||
# at an external NATS that requires a token. Compose forwards the name to every
|
||||
# container that connects.
|
||||
#FLUXER_NATS_AUTH_TOKEN=
|
||||
|
||||
FLUXER_VAPID_PUBLIC_KEY=CHANGE_ME
|
||||
FLUXER_VAPID_PRIVATE_KEY=CHANGE_ME
|
||||
|
||||
@@ -147,9 +182,11 @@ FLUXER_VAPID_PRIVATE_KEY=CHANGE_ME
|
||||
LIVEKIT_API_KEY=fluxer
|
||||
LIVEKIT_API_SECRET=CHANGE_ME
|
||||
|
||||
# The URL browsers use for voice signalling. Derived from FLUXER_PUBLIC_SCHEME,
|
||||
# FLUXER_DOMAIN and FLUXER_PUBLIC_PORT as wss://host[:port]/livekit when empty.
|
||||
# Set it only when LiveKit is served from another host.
|
||||
# The URL browsers use for voice signalling. Compose builds it from
|
||||
# FLUXER_PUBLIC_ORIGIN, or from FLUXER_PUBLIC_SCHEME, FLUXER_DOMAIN and
|
||||
# FLUXER_PUBLIC_PORT, as that origin followed by /livekit. The client rewrites a
|
||||
# leading http to ws itself. Set it only when LiveKit is served from another
|
||||
# host.
|
||||
#FLUXER_LIVEKIT_URL=
|
||||
|
||||
# Media ports. LiveKit advertises these in ICE candidates, so the host must
|
||||
|
||||
@@ -18,6 +18,7 @@ x-fluxer-env: &fluxer-env
|
||||
FLUXER_BASE_DOMAIN: ${FLUXER_DOMAIN:?set FLUXER_DOMAIN in .env}
|
||||
FLUXER_PUBLIC_SCHEME: ${FLUXER_PUBLIC_SCHEME:-https}
|
||||
FLUXER_PUBLIC_PORT: ${FLUXER_PUBLIC_PORT:-443}
|
||||
FLUXER_PUBLIC_ORIGIN: ${FLUXER_PUBLIC_ORIGIN:-}
|
||||
FLUXER_TRUST_CLIENT_IP_HEADER: "true"
|
||||
FLUXER_CLIENT_IP_HEADER_NAME: x-forwarded-for
|
||||
FLUXER_API_HEADERS_TIMEOUT_MS: ${FLUXER_API_HEADERS_TIMEOUT_MS:-30000}
|
||||
@@ -56,7 +57,7 @@ x-fluxer-env: &fluxer-env
|
||||
FLUXER_LIVEKIT_INTERNAL_URL: http://livekit:7880
|
||||
FLUXER_LIVEKIT_WEBHOOK_URL: http://api:8080/webhooks/livekit
|
||||
FLUXER_LIVEKIT_DEFAULT_REGION: '{"id":"default","name":"Default","emoji":"🌍","latitude":0,"longitude":0}'
|
||||
FLUXER_LIVEKIT_URL: ${FLUXER_LIVEKIT_URL:-${FLUXER_PUBLIC_ORIGIN:-${FLUXER_PUBLIC_SCHEME:-https}://${FLUXER_DOMAIN}}/livekit}
|
||||
FLUXER_LIVEKIT_URL: ${FLUXER_LIVEKIT_URL:-${FLUXER_PUBLIC_ORIGIN:-${FLUXER_PUBLIC_SCHEME:-https}://${FLUXER_DOMAIN}:${FLUXER_PUBLIC_PORT:-443}}/livekit}
|
||||
|
||||
FLUXER_KLIPY_API_KEY: ${FLUXER_KLIPY_API_KEY:-}
|
||||
|
||||
@@ -132,7 +133,7 @@ services:
|
||||
- "${FLUXER_HTTPS_PORT:-443}:443"
|
||||
- "${FLUXER_HTTPS_PORT:-443}:443/udp"
|
||||
environment:
|
||||
FLUXER_EDGE_SITE_ADDRESS: ${FLUXER_EDGE_SITE_ADDRESS:-${FLUXER_CADDY_SITE_ADDRESS:-${FLUXER_DOMAIN:?set FLUXER_DOMAIN in .env}}}
|
||||
FLUXER_EDGE_SITE_ADDRESS: ${FLUXER_EDGE_SITE_ADDRESS:-${FLUXER_CADDY_SITE_ADDRESS:-${FLUXER_PUBLIC_SCHEME:-https}://${FLUXER_DOMAIN:?set FLUXER_DOMAIN in .env}}}
|
||||
FLUXER_EDGE_TRUSTED_PROXIES: ${FLUXER_EDGE_TRUSTED_PROXIES:-private_ranges}
|
||||
volumes:
|
||||
- ./Caddyfile:/etc/caddy/Caddyfile:ro
|
||||
@@ -494,6 +495,10 @@ services:
|
||||
environment:
|
||||
FLUXER_APP_PROXY_HOST: 0.0.0.0
|
||||
FLUXER_APP_PROXY_PORT: "8080"
|
||||
FLUXER_BASE_DOMAIN: ${FLUXER_DOMAIN:?set FLUXER_DOMAIN in .env}
|
||||
FLUXER_PUBLIC_SCHEME: ${FLUXER_PUBLIC_SCHEME:-https}
|
||||
FLUXER_PUBLIC_PORT: ${FLUXER_PUBLIC_PORT:-443}
|
||||
FLUXER_PUBLIC_ORIGIN: ${FLUXER_PUBLIC_ORIGIN:-}
|
||||
DISCOVERY_UPSTREAM_URL: http://edge:8088/.well-known/fluxer
|
||||
PUBLIC_BOOTSTRAP_API_ENDPOINT: /api
|
||||
PUBLIC_BOOTSTRAP_API_PUBLIC_ENDPOINT: ${FLUXER_PUBLIC_ORIGIN:-${FLUXER_PUBLIC_SCHEME:-https}://${FLUXER_DOMAIN}}/api
|
||||
|
||||
@@ -2,3 +2,5 @@ services:
|
||||
edge:
|
||||
ports: !override
|
||||
- "${FLUXER_HTTP_PORT:-127.0.0.1:80}:80"
|
||||
environment:
|
||||
FLUXER_EDGE_SITE_ADDRESS: ":80"
|
||||
|
||||
@@ -12287,14 +12287,7 @@
|
||||
"max_concurrent_guild_starts": {"type": "integer", "minimum": 1, "maximum": 10000, "format": "int32"},
|
||||
"gateway_dispatch_relay_shards": {"type": "integer", "minimum": 1, "maximum": 10000, "format": "int32"},
|
||||
"gateway_dispatch_relay_max_queue": {"type": "integer", "minimum": 0, "maximum": 1000000, "format": "int32"},
|
||||
"voice_e2ee_scope": {"enum": ["guild_feature_only", "platform_wide"], "type": "string"},
|
||||
"voice_reconciliation_v3_percentage": {"type": "number", "minimum": 0, "maximum": 100},
|
||||
"voice_reconciliation_v3_interval_ms": {
|
||||
"type": "integer",
|
||||
"minimum": 500,
|
||||
"maximum": 60000,
|
||||
"format": "int32"
|
||||
}
|
||||
"voice_e2ee_scope": {"enum": ["guild_feature_only", "platform_wide"], "type": "string"}
|
||||
}
|
||||
},
|
||||
"InstanceConfigUpdateRequest": {
|
||||
@@ -12534,14 +12527,7 @@
|
||||
"max_concurrent_guild_starts": {"type": "integer", "minimum": 1, "maximum": 10000, "format": "int32"},
|
||||
"gateway_dispatch_relay_shards": {"type": "integer", "minimum": 1, "maximum": 10000, "format": "int32"},
|
||||
"gateway_dispatch_relay_max_queue": {"type": "integer", "minimum": 0, "maximum": 1000000, "format": "int32"},
|
||||
"voice_e2ee_scope": {"enum": ["guild_feature_only", "platform_wide"], "type": "string"},
|
||||
"voice_reconciliation_v3_percentage": {"type": "number", "minimum": 0, "maximum": 100},
|
||||
"voice_reconciliation_v3_interval_ms": {
|
||||
"type": "integer",
|
||||
"minimum": 500,
|
||||
"maximum": 60000,
|
||||
"format": "int32"
|
||||
}
|
||||
"voice_e2ee_scope": {"enum": ["guild_feature_only", "platform_wide"], "type": "string"}
|
||||
}
|
||||
},
|
||||
"BrandingAssetUploadRequest": {
|
||||
|
||||
@@ -129,6 +129,11 @@ impl AdminConfig {
|
||||
pub fn secure_cookies(&self) -> bool {
|
||||
self.admin_endpoint.starts_with("https://")
|
||||
}
|
||||
|
||||
pub fn admin_origin(&self) -> Option<String> {
|
||||
let origin = url::Url::parse(&self.admin_endpoint).ok()?.origin();
|
||||
origin.is_tuple().then(|| origin.ascii_serialization())
|
||||
}
|
||||
}
|
||||
|
||||
impl RuntimeEnv {
|
||||
@@ -202,6 +207,7 @@ mod tests {
|
||||
unsafe { env::remove_var(name) };
|
||||
}
|
||||
unsafe { env::remove_var("FLUXER_PUBLIC_PORT") };
|
||||
unsafe { env::remove_var("FLUXER_PUBLIC_ORIGIN") };
|
||||
unsafe { env::set_var("FLUXER_ADMIN_SECRET_KEY_BASE", "test-secret") };
|
||||
for (name, value) in vars {
|
||||
unsafe { env::set_var(name, value) };
|
||||
@@ -350,7 +356,7 @@ mod tests {
|
||||
("FLUXER_BASE_DOMAIN", "fluxer.example"),
|
||||
("FLUXER_PUBLIC_PORT", "19080"),
|
||||
("FLUXER_ADMIN_ENDPOINT", "http://fluxer.example/admin"),
|
||||
("FLUXER_APP_ENDPOINT", "http://fluxer.example:19080"),
|
||||
("FLUXER_APP_ENDPOINT", "http://fluxer.example"),
|
||||
("FLUXER_MEDIA_ENDPOINT", "http://fluxer.example/media"),
|
||||
("FLUXER_STATIC_CDN_ENDPOINT", "https://cdn.example.net"),
|
||||
(
|
||||
|
||||
@@ -27,7 +27,6 @@ pub async fn csrf_protection(
|
||||
) -> Response {
|
||||
let config = state.config();
|
||||
let secret = config.secret_key_base.clone();
|
||||
let admin_endpoint = config.admin_endpoint.clone();
|
||||
let secure_cookies = config.secure_cookies();
|
||||
|
||||
let user_id = request
|
||||
@@ -50,7 +49,7 @@ pub async fn csrf_protection(
|
||||
.iter()
|
||||
.any(|suffix| path.ends_with(suffix));
|
||||
if !is_ignored {
|
||||
if !is_same_site_request(&request, &admin_endpoint) {
|
||||
if !is_same_site_request(&request, config.admin_origin().as_deref()) {
|
||||
return StatusCode::FORBIDDEN.into_response();
|
||||
}
|
||||
let header_token = extract_csrf_header(&request);
|
||||
@@ -167,7 +166,7 @@ async fn extract_csrf_from_form_body(
|
||||
Ok((request, token))
|
||||
}
|
||||
|
||||
fn is_same_site_request(request: &Request, admin_endpoint: &str) -> bool {
|
||||
fn is_same_site_request(request: &Request, admin_origin: Option<&str>) -> bool {
|
||||
if let Some(site) = request
|
||||
.headers()
|
||||
.get("sec-fetch-site")
|
||||
@@ -180,7 +179,7 @@ fn is_same_site_request(request: &Request, admin_endpoint: &str) -> bool {
|
||||
.get(header::ORIGIN)
|
||||
.and_then(|value| value.to_str().ok())
|
||||
{
|
||||
Some(origin) => origin == admin_endpoint,
|
||||
Some(origin) => admin_origin.is_some_and(|expected| origin == expected),
|
||||
None => true,
|
||||
}
|
||||
}
|
||||
@@ -275,6 +274,98 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
async fn action_status(admin_endpoint: &str, origin: &str) -> StatusCode {
|
||||
let state = state_with_admin_endpoint(admin_endpoint);
|
||||
let app = Router::new()
|
||||
.route("/", get(|| async { "ok" }).post(|| async { "ok" }))
|
||||
.layer(from_fn_with_state(state, csrf_protection));
|
||||
let issued = app
|
||||
.clone()
|
||||
.oneshot(Request::builder().uri("/").body(Body::empty()).unwrap())
|
||||
.await
|
||||
.expect("router responds");
|
||||
let cookie = issued
|
||||
.headers()
|
||||
.get_all(header::SET_COOKIE)
|
||||
.iter()
|
||||
.filter_map(|value| value.to_str().ok())
|
||||
.filter_map(|value| value.split(';').next())
|
||||
.find(|pair| pair.contains("csrf_token=") && !pair.ends_with('='))
|
||||
.expect("a csrf cookie is issued")
|
||||
.to_owned();
|
||||
let token = cookie.split_once('=').expect("a cookie value").1.to_owned();
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method(Method::POST)
|
||||
.uri("/")
|
||||
.header(header::COOKIE, cookie.as_str())
|
||||
.header(header::ORIGIN, origin)
|
||||
.header(CSRF_HEADER_NAME, token.as_str())
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.expect("router responds");
|
||||
response.status()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_matching_origin_passes_the_same_site_check() {
|
||||
let status = action_status(
|
||||
"https://admin.example.test/admin",
|
||||
"https://admin.example.test",
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_matching_origin_on_a_non_default_port_passes_the_same_site_check() {
|
||||
let status = action_status(
|
||||
"https://admin.example.test:19080/admin",
|
||||
"https://admin.example.test:19080",
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_foreign_origin_fails_the_same_site_check() {
|
||||
let status = action_status(
|
||||
"https://admin.example.test:19080/admin",
|
||||
"https://evil.example.test:19080",
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::FORBIDDEN);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn another_port_on_the_admin_host_fails_the_same_site_check() {
|
||||
let status = action_status(
|
||||
"https://admin.example.test:19080/admin",
|
||||
"https://admin.example.test",
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::FORBIDDEN);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn an_unparseable_admin_endpoint_fails_closed() {
|
||||
let status = action_status("not-an-endpoint", "https://admin.example.test").await;
|
||||
assert_eq!(status, StatusCode::FORBIDDEN);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn an_explicit_default_port_matches_a_portless_origin() {
|
||||
let status = action_status(
|
||||
"https://admin.example.test:443/admin",
|
||||
"https://admin.example.test",
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oauth2_callback_is_exempt() {
|
||||
let exempt = IGNORED_PATH_SUFFIXES
|
||||
|
||||
@@ -18,6 +18,7 @@ use tower::ServiceExt;
|
||||
const SECRET_KEY: &str = "legacy-csrf-cookie-test-secret";
|
||||
const ADMIN_ORIGIN: &str = "https://admin.example.test";
|
||||
const LEGACY_HEX_TOKEN: &str = "8f14e45fceea167a5a36dedd4bea25438f14e45fceea167a5a36dedd4bea2543";
|
||||
const CREATED_KEY_SECRET: &str = "fa_1900000000000000001_OneTimeSecretForTests";
|
||||
|
||||
struct TestApp {
|
||||
router: Router,
|
||||
@@ -128,6 +129,10 @@ async fn load_page(app: &TestApp, cookie: &str) -> (String, String) {
|
||||
let body = to_bytes(response.into_body(), usize::MAX).await.unwrap();
|
||||
let text = String::from_utf8(body.to_vec()).unwrap();
|
||||
assert_eq!(status, StatusCode::OK, "{text}");
|
||||
assert!(
|
||||
text.contains("AdminUser"),
|
||||
"the page did not render the admin the mock API returns"
|
||||
);
|
||||
let cookie_token = host_csrf_cookie(&headers)
|
||||
.unwrap_or_else(|| panic!("no __Host-csrf_token in Set-Cookie: {headers:?}"));
|
||||
let page_token = form_csrf_value(&text).expect("no _csrf hidden input rendered");
|
||||
@@ -166,7 +171,16 @@ async fn submit_action(app: &TestApp, cookie: &str, form_token: &str) -> StatusC
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
response.status()
|
||||
let status = response.status();
|
||||
let body = to_bytes(response.into_body(), usize::MAX).await.unwrap();
|
||||
let text = String::from_utf8(body.to_vec()).unwrap();
|
||||
if status == StatusCode::OK {
|
||||
assert!(
|
||||
text.contains(CREATED_KEY_SECRET),
|
||||
"the action did not render the key the mock API creates"
|
||||
);
|
||||
}
|
||||
status
|
||||
}
|
||||
|
||||
fn host_csrf_cookie(headers: &HeaderMap) -> Option<String> {
|
||||
@@ -207,11 +221,11 @@ async fn spawn_mock_api() -> String {
|
||||
|
||||
async fn mock_api(method: Method, uri: Uri) -> Response {
|
||||
match (method, uri.path()) {
|
||||
(Method::GET, "/admin/users/me") => Json(json!({ "user": admin_user() })).into_response(),
|
||||
(Method::GET, "/admin/users/@me") => Json(json!({ "user": admin_user() })).into_response(),
|
||||
(Method::GET, "/admin/api-keys") => Json(json!([])).into_response(),
|
||||
(Method::POST, "/admin/api-keys") => Json(json!({
|
||||
"key_id": "1900000000000000001",
|
||||
"key": "fa_1900000000000000001_OneTimeSecretForTests",
|
||||
"key": CREATED_KEY_SECRET,
|
||||
"name": "Legacy Cookie Key",
|
||||
"created_at": "2026-07-10T15:00:00.000Z",
|
||||
"expires_at": null,
|
||||
|
||||
@@ -30,8 +30,8 @@ import {phraseBlocklistCache} from '../../middleware/PhraseBlocklistCache';
|
||||
import {profileSubstringBlocklistCache} from '../../middleware/ProfileSubstringBlocklistCache';
|
||||
import {urlBlocklistCache} from '../../middleware/UrlBlocklistCache';
|
||||
import {
|
||||
getIpBanBlastRadiusVerdict,
|
||||
getSuspiciousIpSkipReason,
|
||||
hasHighCgnatBlastRadiusRisk,
|
||||
isSingleIpBanCandidate,
|
||||
} from '../../risk/IpBanCgnatGuard';
|
||||
import {isIpBanExempt} from '../../risk/IpBanExemptions';
|
||||
@@ -292,7 +292,7 @@ export class AdminBanManagementService {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
const highRisk = await hasHighCgnatBlastRadiusRisk(ip, this.deps.ipInfoService, {
|
||||
const {cgnat: highRisk} = await getIpBanBlastRadiusVerdict(ip, this.deps.ipInfoService, {
|
||||
source: 'admin.ip_ban',
|
||||
reason: 'pre_write_cgnat_guard',
|
||||
});
|
||||
|
||||
@@ -209,7 +209,7 @@ export async function forgotPassword(ctx: ApiContext, {data, request}: ForgotPas
|
||||
}
|
||||
const hasValidDns = await emailDnsValidation.hasValidDnsRecords(data.email);
|
||||
if (!hasValidDns) {
|
||||
throw InputValidationError.fromCode('email', ValidationErrorCodes.INVALID_EMAIL_ADDRESS);
|
||||
throw InputValidationError.fromCode('email', ValidationErrorCodes.EMAIL_DOMAIN_CANNOT_RECEIVE_MAIL);
|
||||
}
|
||||
const user = await users.findByEmail(data.email);
|
||||
if (!user) {
|
||||
|
||||
@@ -187,7 +187,7 @@ export async function register(
|
||||
contactDomain = normalizePolicyContactDomain(extractEmailDomain(rawEmail));
|
||||
const hasValidDns = await emailDnsValidation.hasValidDnsRecords(rawEmail);
|
||||
if (!hasValidDns) {
|
||||
throw InputValidationError.fromCode('email', ValidationErrorCodes.INVALID_EMAIL_ADDRESS);
|
||||
throw InputValidationError.fromCode('email', ValidationErrorCodes.EMAIL_DOMAIN_CANNOT_RECEIVE_MAIL);
|
||||
}
|
||||
contactDomainBlocked = accountPolicyEvaluator.isBlockedRegistrationEmailDomain(contactDomain);
|
||||
if (contactDomainBlocked) {
|
||||
|
||||
@@ -40,7 +40,7 @@ export class DonationCheckoutService {
|
||||
}
|
||||
const hasValidDns = await this.emailDnsValidationService.hasValidDnsRecords(params.email);
|
||||
if (!hasValidDns) {
|
||||
throw InputValidationError.fromCode('email', ValidationErrorCodes.INVALID_EMAIL_ADDRESS);
|
||||
throw InputValidationError.fromCode('email', ValidationErrorCodes.EMAIL_DOMAIN_CANNOT_RECEIVE_MAIL);
|
||||
}
|
||||
const isRecurring = params.interval !== null;
|
||||
const existingDonor = await this.donationRepository.findDonorByEmail(params.email);
|
||||
|
||||
@@ -24,7 +24,7 @@ export class DonationMagicLinkService {
|
||||
async sendMagicLink(email: string): Promise<void> {
|
||||
const hasValidDns = await this.emailDnsValidationService.hasValidDnsRecords(email);
|
||||
if (!hasValidDns) {
|
||||
throw InputValidationError.fromCode('email', ValidationErrorCodes.INVALID_EMAIL_ADDRESS);
|
||||
throw InputValidationError.fromCode('email', ValidationErrorCodes.EMAIL_DOMAIN_CANNOT_RECEIVE_MAIL);
|
||||
}
|
||||
const donor = await this.donationRepository.findDonorByEmail(email);
|
||||
if (!donor) {
|
||||
|
||||
@@ -401,7 +401,7 @@ export class GuildDiscoveryService extends IGuildDiscoveryService {
|
||||
const language =
|
||||
params.primaryLanguage && isValidDiscoveryLanguage(params.primaryLanguage) ? params.primaryLanguage : undefined;
|
||||
const tag = params.tag && params.tag.trim().length > 0 ? normalizeDiscoveryTag(params.tag) : undefined;
|
||||
const sortBy = params.sortBy === 'member_count' ? 'memberCount' : 'relevance';
|
||||
const sortBy = params.sortBy === 'relevance' ? 'relevance' : 'memberCount';
|
||||
const filters: GuildSearchFilters = {
|
||||
isDiscoverable: true,
|
||||
discoveryCategory: params.categoryId,
|
||||
@@ -444,7 +444,6 @@ export class GuildDiscoveryService extends IGuildDiscoveryService {
|
||||
for (const guild of guilds) {
|
||||
const counts = freshCounts.get(BigInt(guild.id) as GuildID);
|
||||
if (counts) {
|
||||
guild.member_count = counts.memberCount;
|
||||
guild.online_count = counts.onlineCount;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ import type {UserCacheService} from '../../infrastructure/UserCacheService';
|
||||
import {Logger} from '../../Logger';
|
||||
import type {RequestCache} from '../../middleware/RequestCacheMiddleware';
|
||||
import type {GuildBan} from '../../models/GuildBan';
|
||||
import {hasHighCgnatBlastRadiusRisk, isSingleIpBanCandidate} from '../../risk/IpBanCgnatGuard';
|
||||
import {getIpBanBlastRadiusVerdict, isSingleIpBanCandidate} from '../../risk/IpBanCgnatGuard';
|
||||
import {isIpBanExempt} from '../../risk/IpBanExemptions';
|
||||
import type {IUserRepository} from '../../user/IUserRepository';
|
||||
import type {WorkerTaskName} from '../../worker/WorkerLaneConfig';
|
||||
@@ -237,19 +237,20 @@ export class GuildModerationService {
|
||||
return true;
|
||||
}
|
||||
try {
|
||||
const highRisk = await hasHighCgnatBlastRadiusRisk(userIp, this.ipInfoService, {
|
||||
const {cgnat, sharedAccess} = await getIpBanBlastRadiusVerdict(userIp, this.ipInfoService, {
|
||||
source: 'guild.ip_ban',
|
||||
reason: 'join_cgnat_guard',
|
||||
});
|
||||
const highRisk = cgnat || sharedAccess;
|
||||
if (highRisk) {
|
||||
Logger.warn(
|
||||
{userIp, bannedIp},
|
||||
'Skipping guild IP ban match because IPInfo indicates high CGNAT blast-radius risk',
|
||||
'Skipping guild IP ban match because IPInfo indicates high shared-network blast-radius risk',
|
||||
);
|
||||
}
|
||||
return !highRisk;
|
||||
} catch (error) {
|
||||
Logger.warn({error, userIp, bannedIp}, 'IPInfo CGNAT guard failed while checking guild IP ban');
|
||||
Logger.warn({error, userIp, bannedIp}, 'IPInfo blast-radius guard failed while checking guild IP ban');
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,23 +68,10 @@ export class GuildSearchService {
|
||||
const includeNsfwRequested = searchParams.include_nsfw ?? false;
|
||||
const canUserAccessNsfw =
|
||||
guildIsAgeRestricted || includeNsfwRequested ? await this.getCanUserAccessNsfw(userId) : false;
|
||||
if (guildIsAgeRestricted) {
|
||||
if (!canUserAccessNsfw) {
|
||||
throw new NsfwContentRequiresAgeVerificationError();
|
||||
}
|
||||
if (!includeNsfwRequested) {
|
||||
const hitsPerPage = searchParams.hits_per_page ?? 25;
|
||||
const page = searchParams.page ?? 1;
|
||||
return {
|
||||
channels: [],
|
||||
messages: [],
|
||||
total: 0,
|
||||
hits_per_page: hitsPerPage,
|
||||
page,
|
||||
};
|
||||
}
|
||||
if (guildIsAgeRestricted && !canUserAccessNsfw) {
|
||||
throw new NsfwContentRequiresAgeVerificationError();
|
||||
}
|
||||
const canIncludeNsfw = includeNsfwRequested && canUserAccessNsfw;
|
||||
const canIncludeNsfw = canUserAccessNsfw && (includeNsfwRequested || guildIsAgeRestricted);
|
||||
const guildNsfw = guildData?.nsfw ?? false;
|
||||
const channels = await this.channelRepository.listChannels(channelIds);
|
||||
const channelMap = new Map<string, Channel>();
|
||||
|
||||
@@ -14,7 +14,7 @@ import type {GuildID, RoleID, UserID} from '../../../BrandedTypes';
|
||||
import {guildIdToRoleId} from '../../../BrandedTypes';
|
||||
import {Logger} from '../../../Logger';
|
||||
import type {GuildMember} from '../../../models/GuildMember';
|
||||
import {hasHighCgnatBlastRadiusRisk, isSingleIpBanCandidate} from '../../../risk/IpBanCgnatGuard';
|
||||
import {getIpBanBlastRadiusVerdict, isSingleIpBanCandidate} from '../../../risk/IpBanCgnatGuard';
|
||||
import {isIpBanExempt} from '../../../risk/IpBanExemptions';
|
||||
import type {IUserRepository} from '../../../user/IUserRepository';
|
||||
import type {IGuildRepositoryAggregate} from '../../repositories/IGuildRepositoryAggregate';
|
||||
@@ -119,14 +119,15 @@ export class GuildMemberValidationService {
|
||||
return true;
|
||||
}
|
||||
try {
|
||||
const highRisk = await hasHighCgnatBlastRadiusRisk(userIp, this.ipInfoService, {
|
||||
const {cgnat, sharedAccess} = await getIpBanBlastRadiusVerdict(userIp, this.ipInfoService, {
|
||||
source: 'guild.member_ip_ban',
|
||||
reason: 'join_cgnat_guard',
|
||||
});
|
||||
const highRisk = cgnat || sharedAccess;
|
||||
if (highRisk) {
|
||||
Logger.warn(
|
||||
{userIp, bannedIp},
|
||||
'Skipping guild member IP ban match because IPInfo indicates high CGNAT blast-radius risk',
|
||||
'Skipping guild member IP ban match because IPInfo indicates high shared-network blast-radius risk',
|
||||
);
|
||||
}
|
||||
return !highRisk;
|
||||
|
||||
@@ -8,13 +8,21 @@ import type {
|
||||
DiscoveryCategoryResponse,
|
||||
DiscoveryGuildListResponse,
|
||||
} from '@fluxer/schema/src/domains/guild/GuildDiscoverySchemas';
|
||||
import type {WorkerTaskHelpers} from '@pkgs/worker/src/contracts/WorkerTask';
|
||||
import {afterEach, beforeEach, describe, expect, test} from 'vitest';
|
||||
import {createTestAccount, setUserACLs} from '../../auth/tests/AuthTestUtils';
|
||||
import type {GuildID} from '../../BrandedTypes';
|
||||
import {createTestBotAccount} from '../../bot/tests/BotTestUtils';
|
||||
import {setInjectedGatewayService} from '../../middleware/ServiceRegistry';
|
||||
import {getGuildRepository} from '../../middleware/ServiceSingletons';
|
||||
import {banUser} from '../../moderation/tests/ModerationTestUtils';
|
||||
import {type ApiTestHarness, createApiTestHarness} from '../../test/ApiTestHarness';
|
||||
import {NoopLogger} from '../../test/mocks/NoopLogger';
|
||||
import {NoopGatewayService} from '../../test/NoopGatewayService';
|
||||
import {HTTP_STATUS, TEST_IDS} from '../../test/TestConstants';
|
||||
import {createBuilder, createBuilderWithoutAuth} from '../../test/TestRequestBuilder';
|
||||
import syncDiscoveryIndex from '../../worker/tasks/SyncDiscoveryIndex';
|
||||
import {clearWorkerDependencies, setWorkerDependenciesForTest} from '../../worker/WorkerContext';
|
||||
import {createGuild, getUserGuilds} from './GuildTestUtils';
|
||||
|
||||
async function setGuildMemberCount(harness: ApiTestHarness, guildId: string, memberCount: number): Promise<void> {
|
||||
@@ -24,6 +32,30 @@ async function setGuildMemberCount(harness: ApiTestHarness, guildId: string, mem
|
||||
.execute();
|
||||
}
|
||||
|
||||
interface LiveGuildCounts {
|
||||
memberCount: number;
|
||||
onlineCount: number;
|
||||
}
|
||||
|
||||
const WORKER_HELPERS = {logger: new NoopLogger()} as unknown as WorkerTaskHelpers;
|
||||
|
||||
class LiveCountsGatewayService extends NoopGatewayService {
|
||||
constructor(private readonly liveCounts: Map<string, LiveGuildCounts>) {
|
||||
super();
|
||||
}
|
||||
|
||||
override async getDiscoveryGuildCounts(guildIds: Array<GuildID>): Promise<Map<GuildID, LiveGuildCounts>> {
|
||||
const counts = new Map<GuildID, LiveGuildCounts>();
|
||||
for (const guildId of guildIds) {
|
||||
const live = this.liveCounts.get(guildId.toString());
|
||||
if (live) {
|
||||
counts.set(guildId, live);
|
||||
}
|
||||
}
|
||||
return counts;
|
||||
}
|
||||
}
|
||||
|
||||
async function applyAndApprove(
|
||||
harness: ApiTestHarness,
|
||||
ownerToken: string,
|
||||
@@ -44,12 +76,39 @@ async function applyAndApprove(
|
||||
.execute();
|
||||
}
|
||||
|
||||
async function createApprovedDiscoveryGuild(
|
||||
harness: ApiTestHarness,
|
||||
adminToken: string,
|
||||
name: string,
|
||||
memberCount: number,
|
||||
): Promise<string> {
|
||||
const owner = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, owner.token, name);
|
||||
await setGuildMemberCount(harness, guild.id, memberCount);
|
||||
await applyAndApprove(
|
||||
harness,
|
||||
owner.token,
|
||||
adminToken,
|
||||
guild.id,
|
||||
`${name} welcomes everyone`,
|
||||
DiscoveryCategories.GAMING,
|
||||
);
|
||||
return guild.id;
|
||||
}
|
||||
|
||||
function expectNonIncreasing(counts: Array<number>): void {
|
||||
for (let index = 1; index < counts.length; index++) {
|
||||
expect(counts[index]).toBeLessThanOrEqual(counts[index - 1]);
|
||||
}
|
||||
}
|
||||
|
||||
describe('Discovery Search and Join', () => {
|
||||
let harness: ApiTestHarness;
|
||||
beforeEach(async () => {
|
||||
harness = await createApiTestHarness({search: 'enabled'});
|
||||
});
|
||||
afterEach(async () => {
|
||||
clearWorkerDependencies();
|
||||
await harness?.shutdown();
|
||||
});
|
||||
describe('categories', () => {
|
||||
@@ -257,6 +316,82 @@ describe('Discovery Search and Join', () => {
|
||||
.execute();
|
||||
expect(results.guilds.length).toBeLessThanOrEqual(2);
|
||||
});
|
||||
test('should order results by the member count it reports back', async () => {
|
||||
const liveCounts = new Map<string, LiveGuildCounts>();
|
||||
setInjectedGatewayService(new LiveCountsGatewayService(liveCounts));
|
||||
const admin = await createTestAccount(harness);
|
||||
await setUserACLs(harness, admin, ['admin:authenticate', 'discovery:review']);
|
||||
const guildIds: Array<string> = [];
|
||||
for (const memberCount of [50, 40, 30, 20, 10]) {
|
||||
guildIds.push(
|
||||
await createApprovedDiscoveryGuild(harness, admin.token, `Ordered Guild ${memberCount}`, memberCount),
|
||||
);
|
||||
}
|
||||
liveCounts.set(guildIds[0], {memberCount: 5, onlineCount: 3});
|
||||
liveCounts.set(guildIds[4], {memberCount: 500, onlineCount: 7});
|
||||
const searcher = await createTestAccount(harness);
|
||||
const results = await createBuilder<DiscoveryGuildListResponse>(harness, searcher.token)
|
||||
.get('/discovery/guilds?sort_by=member_count&limit=48')
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
expect(results.guilds.map((guild) => guild.id)).toEqual(guildIds);
|
||||
expectNonIncreasing(results.guilds.map((guild) => guild.member_count));
|
||||
expect(results.guilds[0].online_count).toBe(3);
|
||||
expect(results.guilds[4].online_count).toBe(7);
|
||||
});
|
||||
test('should rank by member count when the client omits sort_by', async () => {
|
||||
const admin = await createTestAccount(harness);
|
||||
await setUserACLs(harness, admin, ['admin:authenticate', 'discovery:review']);
|
||||
const guildsByCount = new Map<number, string>();
|
||||
for (const memberCount of [30, 10, 20]) {
|
||||
guildsByCount.set(
|
||||
memberCount,
|
||||
await createApprovedDiscoveryGuild(harness, admin.token, `Unsorted Guild ${memberCount}`, memberCount),
|
||||
);
|
||||
}
|
||||
const searcher = await createTestAccount(harness);
|
||||
const results = await createBuilder<DiscoveryGuildListResponse>(harness, searcher.token)
|
||||
.get('/discovery/guilds?limit=48')
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
expect(results.guilds.map((guild) => guild.id)).toEqual([
|
||||
guildsByCount.get(30),
|
||||
guildsByCount.get(20),
|
||||
guildsByCount.get(10),
|
||||
]);
|
||||
expectNonIncreasing(results.guilds.map((guild) => guild.member_count));
|
||||
});
|
||||
test('should not repeat guilds across pages when the discovery index is resynced', async () => {
|
||||
const liveCounts = new Map<string, LiveGuildCounts>();
|
||||
const gatewayService = new LiveCountsGatewayService(liveCounts);
|
||||
setInjectedGatewayService(gatewayService);
|
||||
const admin = await createTestAccount(harness);
|
||||
await setUserACLs(harness, admin, ['admin:authenticate', 'discovery:review']);
|
||||
const guildIds: Array<string> = [];
|
||||
for (const [index, memberCount] of [60, 50, 40, 40, 30, 30].entries()) {
|
||||
guildIds.push(await createApprovedDiscoveryGuild(harness, admin.token, `Paged Guild ${index}`, memberCount));
|
||||
}
|
||||
const searcher = await createTestAccount(harness);
|
||||
const firstPage = await createBuilder<DiscoveryGuildListResponse>(harness, searcher.token)
|
||||
.get('/discovery/guilds?sort_by=member_count&limit=2&offset=0')
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
expect(firstPage.guilds.map((guild) => guild.id)).toEqual([guildIds[0], guildIds[1]]);
|
||||
liveCounts.set(guildIds[0], {memberCount: 5, onlineCount: 0});
|
||||
setWorkerDependenciesForTest({guildRepository: getGuildRepository(), gatewayService});
|
||||
await syncDiscoveryIndex({}, WORKER_HELPERS);
|
||||
const secondPage = await createBuilder<DiscoveryGuildListResponse>(harness, searcher.token)
|
||||
.get('/discovery/guilds?sort_by=member_count&limit=2&offset=2')
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
const thirdPage = await createBuilder<DiscoveryGuildListResponse>(harness, searcher.token)
|
||||
.get('/discovery/guilds?sort_by=member_count&limit=2&offset=4')
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
const paged = [...firstPage.guilds, ...secondPage.guilds, ...thirdPage.guilds].map((guild) => guild.id);
|
||||
expect(new Set(paged).size).toBe(paged.length);
|
||||
expect([...paged].sort()).toEqual([...guildIds].sort());
|
||||
});
|
||||
test('should require login to search', async () => {
|
||||
await createBuilderWithoutAuth(harness).get('/discovery/guilds').expect(HTTP_STATUS.UNAUTHORIZED).execute();
|
||||
});
|
||||
|
||||
@@ -27,25 +27,51 @@ interface EmailDnsValidationServiceOptions {
|
||||
enforceInTestMode?: boolean;
|
||||
positiveTtlMs?: number;
|
||||
negativeTtlMs?: number;
|
||||
lookupTimeoutMs?: number;
|
||||
maxCachedDomains?: number;
|
||||
isEmailEnabled?: () => Promise<boolean>;
|
||||
}
|
||||
|
||||
type DnsResolutionResult = 'valid' | 'invalid' | 'fallback' | 'transient_error';
|
||||
type DomainVerdict = 'valid' | 'invalid' | 'unverified';
|
||||
|
||||
const DOMAIN_NOT_FOUND_CODES = new Set(['ENOTFOUND', 'ENONAME', 'EAI_NONAME', 'NXDOMAIN']);
|
||||
const DOMAIN_NO_RECORD_CODES = new Set(['ENODATA', 'ENOENT', 'NODATA']);
|
||||
const DNS_LOOKUP_TIMEOUT_MS = 2000;
|
||||
const DNS_LOOKUP_TRIES = 1;
|
||||
const MAX_CACHED_DOMAINS = 10000;
|
||||
|
||||
async function isInstanceEmailEnabled(): Promise<boolean> {
|
||||
const {getInstanceConfigRepository} = await import('../middleware/ServiceSingletons');
|
||||
return getInstanceConfigRepository().isEmailEnabled();
|
||||
}
|
||||
|
||||
function createLookupTimeoutError(): NodeJS.ErrnoException {
|
||||
const error: NodeJS.ErrnoException = new Error('Email DNS lookup timed out');
|
||||
error.code = 'ETIMEOUT';
|
||||
return error;
|
||||
}
|
||||
|
||||
export class EmailDnsValidationService implements IEmailDnsValidationService {
|
||||
private readonly resolver: IDnsResolver;
|
||||
private readonly enforceInTestMode: boolean;
|
||||
private readonly positiveTtlMs: number;
|
||||
private readonly negativeTtlMs: number;
|
||||
private readonly lookupTimeoutMs: number;
|
||||
private readonly maxCachedDomains: number;
|
||||
private readonly isEmailEnabled: () => Promise<boolean>;
|
||||
private readonly domainCache = new Map<string, DomainValidationCacheEntry>();
|
||||
|
||||
constructor(options: EmailDnsValidationServiceOptions = {}) {
|
||||
this.resolver = options.resolver ?? new Resolver();
|
||||
this.lookupTimeoutMs = options.lookupTimeoutMs ?? DNS_LOOKUP_TIMEOUT_MS;
|
||||
this.resolver =
|
||||
options.resolver ??
|
||||
new Resolver({timeout: this.lookupTimeoutMs, tries: DNS_LOOKUP_TRIES, maxTimeout: this.lookupTimeoutMs});
|
||||
this.enforceInTestMode = options.enforceInTestMode ?? false;
|
||||
this.positiveTtlMs = options.positiveTtlMs ?? ms('30 minutes');
|
||||
this.negativeTtlMs = options.negativeTtlMs ?? ms('5 minutes');
|
||||
this.maxCachedDomains = options.maxCachedDomains ?? MAX_CACHED_DOMAINS;
|
||||
this.isEmailEnabled = options.isEmailEnabled ?? isInstanceEmailEnabled;
|
||||
}
|
||||
|
||||
async hasValidDnsRecords(email: string): Promise<boolean> {
|
||||
@@ -56,13 +82,19 @@ export class EmailDnsValidationService implements IEmailDnsValidationService {
|
||||
if (!domain) {
|
||||
return false;
|
||||
}
|
||||
if (!(await this.isEmailEnabled())) {
|
||||
return true;
|
||||
}
|
||||
const cached = this.getCachedDomainResult(domain);
|
||||
if (cached !== null) {
|
||||
return cached;
|
||||
}
|
||||
const isValid = await this.resolveDomain(domain);
|
||||
this.setCachedDomainResult(domain, isValid);
|
||||
return isValid;
|
||||
const verdict = await this.resolveDomain(domain);
|
||||
if (verdict === 'invalid') {
|
||||
Logger.warn({domain}, 'Email domain publishes no mail exchange or address records, rejecting the address');
|
||||
}
|
||||
this.setCachedDomainResult(domain, verdict);
|
||||
return verdict !== 'invalid';
|
||||
}
|
||||
|
||||
private extractDomain(email: string): string | null {
|
||||
@@ -82,41 +114,49 @@ export class EmailDnsValidationService implements IEmailDnsValidationService {
|
||||
this.domainCache.delete(domain);
|
||||
return null;
|
||||
}
|
||||
this.domainCache.delete(domain);
|
||||
this.domainCache.set(domain, cached);
|
||||
return cached.valid;
|
||||
}
|
||||
|
||||
private setCachedDomainResult(domain: string, isValid: boolean): void {
|
||||
const ttlMs = isValid ? this.positiveTtlMs : this.negativeTtlMs;
|
||||
private setCachedDomainResult(domain: string, verdict: DomainVerdict): void {
|
||||
const ttlMs = verdict === 'valid' ? this.positiveTtlMs : this.negativeTtlMs;
|
||||
if (this.domainCache.size >= this.maxCachedDomains && !this.domainCache.has(domain)) {
|
||||
const oldestDomain = this.domainCache.keys().next().value;
|
||||
if (oldestDomain !== undefined) {
|
||||
this.domainCache.delete(oldestDomain);
|
||||
}
|
||||
}
|
||||
this.domainCache.set(domain, {
|
||||
valid: isValid,
|
||||
valid: verdict !== 'invalid',
|
||||
expiresAtMs: Date.now() + ttlMs,
|
||||
});
|
||||
}
|
||||
|
||||
private async resolveDomain(domain: string): Promise<boolean> {
|
||||
private async resolveDomain(domain: string): Promise<DomainVerdict> {
|
||||
const mxResult = await this.resolveMx(domain);
|
||||
if (mxResult === 'valid') {
|
||||
return true;
|
||||
return 'valid';
|
||||
}
|
||||
if (mxResult === 'invalid') {
|
||||
return false;
|
||||
return 'invalid';
|
||||
}
|
||||
if (mxResult === 'transient_error') {
|
||||
return true;
|
||||
return 'unverified';
|
||||
}
|
||||
const addressResult = await this.resolveAddressRecords(domain);
|
||||
if (addressResult === 'valid') {
|
||||
return true;
|
||||
return 'valid';
|
||||
}
|
||||
if (addressResult === 'invalid') {
|
||||
return false;
|
||||
return 'invalid';
|
||||
}
|
||||
return true;
|
||||
return 'unverified';
|
||||
}
|
||||
|
||||
private async resolveMx(domain: string): Promise<DnsResolutionResult> {
|
||||
try {
|
||||
const records = await this.resolver.resolveMx(domain);
|
||||
const records = await this.withLookupDeadline(this.resolver.resolveMx(domain));
|
||||
if (records.length > 0) {
|
||||
return 'valid';
|
||||
}
|
||||
@@ -128,8 +168,8 @@ export class EmailDnsValidationService implements IEmailDnsValidationService {
|
||||
|
||||
private async resolveAddressRecords(domain: string): Promise<DnsResolutionResult> {
|
||||
const [ipv4Result, ipv6Result] = await Promise.allSettled([
|
||||
this.resolver.resolve4(domain),
|
||||
this.resolver.resolve6(domain),
|
||||
this.withLookupDeadline(this.resolver.resolve4(domain)),
|
||||
this.withLookupDeadline(this.resolver.resolve6(domain)),
|
||||
]);
|
||||
if (ipv4Result.status === 'fulfilled' && ipv4Result.value.length > 0) {
|
||||
return 'valid';
|
||||
@@ -147,6 +187,20 @@ export class EmailDnsValidationService implements IEmailDnsValidationService {
|
||||
return 'invalid';
|
||||
}
|
||||
|
||||
private async withLookupDeadline<T>(lookup: Promise<T>): Promise<T> {
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
try {
|
||||
return await Promise.race([
|
||||
lookup,
|
||||
new Promise<never>((_resolve, reject) => {
|
||||
timer = setTimeout(() => reject(createLookupTimeoutError()), this.lookupTimeoutMs);
|
||||
}),
|
||||
]);
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
private classifyResolverError(
|
||||
error: unknown,
|
||||
domain: string,
|
||||
|
||||
@@ -324,9 +324,6 @@ export class LiveKitService extends ILiveKitService {
|
||||
participants: participants.map((participant) => ({identity: participant.identity})),
|
||||
};
|
||||
} catch (error) {
|
||||
if (LiveKitService.isHttp404(error)) {
|
||||
return {status: 'ok', participants: []};
|
||||
}
|
||||
Logger.warn({error, regionId, serverId, roomName}, 'LiveKit listParticipants failed');
|
||||
const status = LiveKitService.getHttpStatus(error);
|
||||
const isRetryable = status != null && status >= 500;
|
||||
|
||||
@@ -0,0 +1,220 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {ms} from 'itty-time';
|
||||
import {describe, expect, it} from 'vitest';
|
||||
import {Config} from '../../Config';
|
||||
import {getInstanceConfigRepository} from '../../middleware/ServiceSingletons';
|
||||
import {EmailDnsValidationService} from '../EmailDnsValidationService';
|
||||
|
||||
interface MxRecord {
|
||||
exchange: string;
|
||||
priority: number;
|
||||
}
|
||||
|
||||
function dnsError(code: string): NodeJS.ErrnoException {
|
||||
const error: NodeJS.ErrnoException = new Error(`dns lookup failed with ${code}`);
|
||||
error.code = code;
|
||||
return error;
|
||||
}
|
||||
|
||||
class FakeDnsResolver {
|
||||
readonly lookups: Array<string> = [];
|
||||
mxRecords: Array<MxRecord> = [{exchange: 'mx.example.com', priority: 10}];
|
||||
mxErrorCode: string | null = null;
|
||||
addressErrorCode: string | null = 'ENOTFOUND';
|
||||
addresses: Array<string> = [];
|
||||
stall = false;
|
||||
|
||||
async resolveMx(domain: string): Promise<Array<MxRecord>> {
|
||||
this.lookups.push(`mx:${domain}`);
|
||||
if (this.stall) {
|
||||
return new Promise<Array<MxRecord>>(() => {});
|
||||
}
|
||||
if (this.mxErrorCode) {
|
||||
throw dnsError(this.mxErrorCode);
|
||||
}
|
||||
return this.mxRecords;
|
||||
}
|
||||
|
||||
async resolve4(domain: string): Promise<Array<string>> {
|
||||
this.lookups.push(`a:${domain}`);
|
||||
if (this.addressErrorCode) {
|
||||
throw dnsError(this.addressErrorCode);
|
||||
}
|
||||
return this.addresses;
|
||||
}
|
||||
|
||||
async resolve6(domain: string): Promise<Array<string>> {
|
||||
this.lookups.push(`aaaa:${domain}`);
|
||||
if (this.addressErrorCode) {
|
||||
throw dnsError(this.addressErrorCode);
|
||||
}
|
||||
return this.addresses;
|
||||
}
|
||||
}
|
||||
|
||||
describe('EmailDnsValidationService', () => {
|
||||
it('skips the lookup when the instance sends no mail', async () => {
|
||||
const resolver = new FakeDnsResolver();
|
||||
resolver.mxErrorCode = 'ENOTFOUND';
|
||||
const service = new EmailDnsValidationService({
|
||||
resolver,
|
||||
enforceInTestMode: true,
|
||||
isEmailEnabled: async () => false,
|
||||
});
|
||||
expect(await service.hasValidDnsRecords('[email protected]')).toBe(true);
|
||||
expect(resolver.lookups).toEqual([]);
|
||||
});
|
||||
|
||||
it('looks the domain up when the instance sends mail', async () => {
|
||||
const resolver = new FakeDnsResolver();
|
||||
const service = new EmailDnsValidationService({
|
||||
resolver,
|
||||
enforceInTestMode: true,
|
||||
isEmailEnabled: async () => true,
|
||||
});
|
||||
expect(await service.hasValidDnsRecords('[email protected]')).toBe(true);
|
||||
expect(resolver.lookups).toEqual(['mx:gmail.com']);
|
||||
});
|
||||
|
||||
it('follows the env email flag when no gate is supplied and the operator set nothing', async () => {
|
||||
expect(Config.email.enabled).toBe(true);
|
||||
const resolver = new FakeDnsResolver();
|
||||
const service = new EmailDnsValidationService({resolver, enforceInTestMode: true});
|
||||
expect(await service.hasValidDnsRecords('[email protected]')).toBe(true);
|
||||
expect(resolver.lookups).toEqual(['mx:gmail.com']);
|
||||
});
|
||||
|
||||
it('follows the runtime instance email setting over the env flag', async () => {
|
||||
expect(Config.email.enabled).toBe(true);
|
||||
await getInstanceConfigRepository().setInstanceIntegrationsConfig({email: {enabled: false}});
|
||||
const resolver = new FakeDnsResolver();
|
||||
resolver.mxErrorCode = 'ENOTFOUND';
|
||||
const service = new EmailDnsValidationService({resolver, enforceInTestMode: true});
|
||||
expect(await service.hasValidDnsRecords('[email protected]')).toBe(true);
|
||||
expect(resolver.lookups).toEqual([]);
|
||||
});
|
||||
|
||||
it('still rejects a syntactically broken address when the instance sends no mail', async () => {
|
||||
const resolver = new FakeDnsResolver();
|
||||
const service = new EmailDnsValidationService({
|
||||
resolver,
|
||||
enforceInTestMode: true,
|
||||
isEmailEnabled: async () => false,
|
||||
});
|
||||
expect(await service.hasValidDnsRecords('probe@')).toBe(false);
|
||||
expect(resolver.lookups).toEqual([]);
|
||||
});
|
||||
|
||||
it('rejects a domain that does not exist', async () => {
|
||||
const resolver = new FakeDnsResolver();
|
||||
resolver.mxErrorCode = 'ENOTFOUND';
|
||||
const service = new EmailDnsValidationService({
|
||||
resolver,
|
||||
enforceInTestMode: true,
|
||||
isEmailEnabled: async () => true,
|
||||
});
|
||||
expect(await service.hasValidDnsRecords('[email protected]')).toBe(false);
|
||||
expect(resolver.lookups).toEqual(['mx:asdf.asdf']);
|
||||
});
|
||||
|
||||
it('accepts a domain that publishes address records but no mail records', async () => {
|
||||
const resolver = new FakeDnsResolver();
|
||||
resolver.mxErrorCode = 'ENODATA';
|
||||
resolver.addressErrorCode = null;
|
||||
resolver.addresses = ['198.51.100.10'];
|
||||
const service = new EmailDnsValidationService({
|
||||
resolver,
|
||||
enforceInTestMode: true,
|
||||
isEmailEnabled: async () => true,
|
||||
});
|
||||
expect(await service.hasValidDnsRecords('[email protected]')).toBe(true);
|
||||
expect(resolver.lookups).toEqual(['mx:mail-less.test', 'a:mail-less.test', 'aaaa:mail-less.test']);
|
||||
});
|
||||
|
||||
it('rejects a domain that publishes neither mail nor address records', async () => {
|
||||
const resolver = new FakeDnsResolver();
|
||||
resolver.mxErrorCode = 'ENODATA';
|
||||
resolver.addressErrorCode = 'ENODATA';
|
||||
const service = new EmailDnsValidationService({
|
||||
resolver,
|
||||
enforceInTestMode: true,
|
||||
isEmailEnabled: async () => true,
|
||||
});
|
||||
expect(await service.hasValidDnsRecords('[email protected]')).toBe(false);
|
||||
expect(resolver.lookups).toEqual(['mx:no-records.test', 'a:no-records.test', 'aaaa:no-records.test']);
|
||||
});
|
||||
|
||||
it('allows the address when the resolver fails transiently', async () => {
|
||||
const resolver = new FakeDnsResolver();
|
||||
resolver.mxErrorCode = 'ESERVFAIL';
|
||||
const service = new EmailDnsValidationService({
|
||||
resolver,
|
||||
enforceInTestMode: true,
|
||||
isEmailEnabled: async () => true,
|
||||
});
|
||||
expect(await service.hasValidDnsRecords('[email protected]')).toBe(true);
|
||||
});
|
||||
|
||||
it('does not cache a transient failure as a verified domain', async () => {
|
||||
const resolver = new FakeDnsResolver();
|
||||
resolver.mxErrorCode = 'ESERVFAIL';
|
||||
const service = new EmailDnsValidationService({
|
||||
resolver,
|
||||
enforceInTestMode: true,
|
||||
isEmailEnabled: async () => true,
|
||||
positiveTtlMs: ms('30 minutes'),
|
||||
negativeTtlMs: 0,
|
||||
});
|
||||
expect(await service.hasValidDnsRecords('[email protected]')).toBe(true);
|
||||
expect(await service.hasValidDnsRecords('[email protected]')).toBe(true);
|
||||
expect(resolver.lookups).toEqual(['mx:asdf.asdf', 'mx:asdf.asdf']);
|
||||
});
|
||||
|
||||
it('caches a verified domain for the positive ttl', async () => {
|
||||
const resolver = new FakeDnsResolver();
|
||||
const service = new EmailDnsValidationService({
|
||||
resolver,
|
||||
enforceInTestMode: true,
|
||||
isEmailEnabled: async () => true,
|
||||
positiveTtlMs: ms('30 minutes'),
|
||||
negativeTtlMs: 0,
|
||||
});
|
||||
expect(await service.hasValidDnsRecords('[email protected]')).toBe(true);
|
||||
expect(await service.hasValidDnsRecords('[email protected]')).toBe(true);
|
||||
expect(resolver.lookups).toEqual(['mx:gmail.com']);
|
||||
});
|
||||
|
||||
it('gives up on a stalled resolver instead of hanging', async () => {
|
||||
const resolver = new FakeDnsResolver();
|
||||
resolver.stall = true;
|
||||
const service = new EmailDnsValidationService({
|
||||
resolver,
|
||||
enforceInTestMode: true,
|
||||
isEmailEnabled: async () => true,
|
||||
lookupTimeoutMs: 10,
|
||||
});
|
||||
const startedAtMs = Date.now();
|
||||
expect(await service.hasValidDnsRecords('[email protected]')).toBe(true);
|
||||
expect(Date.now() - startedAtMs).toBeLessThan(ms('5 seconds'));
|
||||
expect(resolver.lookups).toEqual(['mx:stalled.test']);
|
||||
});
|
||||
|
||||
it('bounds the domain cache', async () => {
|
||||
const resolver = new FakeDnsResolver();
|
||||
resolver.mxErrorCode = 'ENOTFOUND';
|
||||
const service = new EmailDnsValidationService({
|
||||
resolver,
|
||||
enforceInTestMode: true,
|
||||
isEmailEnabled: async () => true,
|
||||
maxCachedDomains: 2,
|
||||
});
|
||||
expect(await service.hasValidDnsRecords('[email protected]')).toBe(false);
|
||||
expect(await service.hasValidDnsRecords('[email protected]')).toBe(false);
|
||||
expect(await service.hasValidDnsRecords('[email protected]')).toBe(false);
|
||||
expect(await service.hasValidDnsRecords('[email protected]')).toBe(false);
|
||||
expect(resolver.lookups).toEqual(['mx:first.test', 'mx:second.test', 'mx:third.test']);
|
||||
expect(await service.hasValidDnsRecords('[email protected]')).toBe(false);
|
||||
expect(resolver.lookups).toEqual(['mx:first.test', 'mx:second.test', 'mx:third.test', 'mx:first.test']);
|
||||
});
|
||||
});
|
||||
@@ -2,7 +2,8 @@
|
||||
|
||||
import {AccessToken, TrackSource} from 'livekit-server-sdk';
|
||||
import {describe, expect, it} from 'vitest';
|
||||
import {computeLiveKitPublishSources, VOICE_TOKEN_TTL_SECONDS} from '../LiveKitService';
|
||||
import {createChannelID, createGuildID} from '../../BrandedTypes';
|
||||
import {computeLiveKitPublishSources, LiveKitService, VOICE_TOKEN_TTL_SECONDS} from '../LiveKitService';
|
||||
|
||||
function decodeJwtPayload(token: string): Record<string, unknown> {
|
||||
const [, payload] = token.split('.');
|
||||
@@ -57,3 +58,78 @@ describe('LiveKitService publish permissions', () => {
|
||||
expect(exp - nowSeconds).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
class FakeTwirpError extends Error {
|
||||
status: number;
|
||||
code?: string;
|
||||
constructor(message: string, status: number, code?: string) {
|
||||
super(message);
|
||||
this.name = 'TwirpError';
|
||||
this.status = status;
|
||||
this.code = code;
|
||||
}
|
||||
}
|
||||
|
||||
function createServiceWithRoomServiceClient(roomServiceClient: unknown): LiveKitService {
|
||||
const service = Object.create(LiveKitService.prototype) as LiveKitService;
|
||||
Reflect.set(
|
||||
service,
|
||||
'serverClients',
|
||||
new Map([
|
||||
[
|
||||
'region-1',
|
||||
new Map([
|
||||
[
|
||||
'region-1-server-1',
|
||||
{
|
||||
endpoint: 'ws://livekit.test/livekit',
|
||||
apiKey: 'test-key',
|
||||
apiSecret: 'test-secret',
|
||||
isActive: true,
|
||||
roomServiceClient,
|
||||
},
|
||||
],
|
||||
]),
|
||||
],
|
||||
]),
|
||||
);
|
||||
return service;
|
||||
}
|
||||
|
||||
describe('LiveKitService listParticipants', () => {
|
||||
const params = {
|
||||
guildId: createGuildID(1n),
|
||||
channelId: createChannelID(2n),
|
||||
regionId: 'region-1',
|
||||
serverId: 'region-1-server-1',
|
||||
};
|
||||
|
||||
it('reports a 404 as an unreadable room instead of an empty one', async () => {
|
||||
const service = createServiceWithRoomServiceClient({
|
||||
listParticipants: async () => {
|
||||
throw new FakeTwirpError('not_found', 404, 'not_found');
|
||||
},
|
||||
});
|
||||
const result = await service.listParticipants(params);
|
||||
expect(result.status).toBe('error');
|
||||
});
|
||||
|
||||
it('reports a bad_route 404 as an unreadable room instead of an empty one', async () => {
|
||||
const service = createServiceWithRoomServiceClient({
|
||||
listParticipants: async () => {
|
||||
throw new FakeTwirpError('invalid path prefix', 404, 'bad_route');
|
||||
},
|
||||
});
|
||||
const result = await service.listParticipants(params);
|
||||
expect(result.status).toBe('error');
|
||||
expect(result.status === 'error' && result.retryable).toBe(false);
|
||||
});
|
||||
|
||||
it('still reports a genuinely empty room as empty', async () => {
|
||||
const service = createServiceWithRoomServiceClient({
|
||||
listParticipants: async () => [],
|
||||
});
|
||||
const result = await service.listParticipants(params);
|
||||
expect(result).toEqual({status: 'ok', participants: []});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -63,8 +63,6 @@ describe('GatewayRolloutConfigPublisher', () => {
|
||||
gateway_dispatch_relay_shards: 32,
|
||||
gateway_dispatch_relay_max_queue: 50000,
|
||||
voice_e2ee_scope: 'guild_feature_only',
|
||||
voice_reconciliation_v3_percentage: 100,
|
||||
voice_reconciliation_v3_interval_ms: 2000,
|
||||
};
|
||||
|
||||
await publisher.publish(config);
|
||||
|
||||
@@ -42,8 +42,6 @@ const DEFAULT_GATEWAY_ROLLOUT_CONFIG: GatewayRolloutConfig = {
|
||||
gateway_dispatch_relay_shards: 32,
|
||||
gateway_dispatch_relay_max_queue: 50000,
|
||||
voice_e2ee_scope: 'guild_feature_only',
|
||||
voice_reconciliation_v3_percentage: 100,
|
||||
voice_reconciliation_v3_interval_ms: 2000,
|
||||
};
|
||||
export type InstanceRegistrationMode = 'open' | 'approval' | 'closed';
|
||||
export interface InstanceRegistrationConfig {
|
||||
|
||||
@@ -343,7 +343,7 @@ export class ReportService {
|
||||
const normalizedEmail = this.normalizeEmail(email);
|
||||
const hasValidDns = await this.emailDnsValidationService.hasValidDnsRecords(normalizedEmail);
|
||||
if (!hasValidDns) {
|
||||
throw InputValidationError.fromCode('email', ValidationErrorCodes.INVALID_EMAIL_ADDRESS);
|
||||
throw InputValidationError.fromCode('email', ValidationErrorCodes.EMAIL_DOMAIN_CANNOT_RECEIVE_MAIL);
|
||||
}
|
||||
const verificationCode = this.generateDsaVerificationCode();
|
||||
const expiresAt = new Date(Date.now() + ms('10 minutes'));
|
||||
|
||||
@@ -7,9 +7,14 @@ import {isTrustedCommercialPrivacyProvider} from './TrustedPrivacyProviders';
|
||||
|
||||
const VERDICT_CACHE_TTL_MS = 60 * 60 * 1000;
|
||||
|
||||
interface IpBanBlastRadiusVerdict {
|
||||
cgnat: boolean;
|
||||
sharedAccess: boolean;
|
||||
}
|
||||
|
||||
interface CachedVerdict {
|
||||
expiresAtMs: number;
|
||||
highRisk: boolean;
|
||||
verdict: IpBanBlastRadiusVerdict;
|
||||
}
|
||||
|
||||
const verdictCache = new Map<string, CachedVerdict>();
|
||||
@@ -48,7 +53,7 @@ export function getSuspiciousIpSkipReason(result: IpInfoLookupResult): Suspiciou
|
||||
return null;
|
||||
}
|
||||
|
||||
function isHighSharedAccessBlastRadiusRisk(result: IpInfoLookupResult): boolean {
|
||||
export function isHighSharedAccessBlastRadiusRisk(result: IpInfoLookupResult): boolean {
|
||||
if (result.flags.isHosting || isAnonymousAccess(result)) {
|
||||
return false;
|
||||
}
|
||||
@@ -60,29 +65,32 @@ export function isSingleIpBanCandidate(value: string): boolean {
|
||||
return parseIpBanEntry(value)?.type === 'single';
|
||||
}
|
||||
|
||||
export async function hasHighCgnatBlastRadiusRisk(
|
||||
export async function getIpBanBlastRadiusVerdict(
|
||||
ip: string,
|
||||
ipInfoService: IpInfoService,
|
||||
context: {
|
||||
source: string;
|
||||
reason: string;
|
||||
},
|
||||
): Promise<boolean> {
|
||||
): Promise<IpBanBlastRadiusVerdict> {
|
||||
const now = Date.now();
|
||||
const cacheKey = getSameIpDecisionKey(ip) ?? ip;
|
||||
const cached = verdictCache.get(cacheKey);
|
||||
if (cached && cached.expiresAtMs > now) {
|
||||
return cached.highRisk;
|
||||
return cached.verdict;
|
||||
}
|
||||
const result = await ipInfoService.lookup(ip, {
|
||||
source: context.source,
|
||||
reason: context.reason,
|
||||
metadata: {policy: 'ip_ban_cgnat_guard'},
|
||||
});
|
||||
const highRisk = isHighCgnatBlastRadiusRisk(result);
|
||||
const verdict: IpBanBlastRadiusVerdict = {
|
||||
cgnat: isHighCgnatBlastRadiusRisk(result),
|
||||
sharedAccess: isHighSharedAccessBlastRadiusRisk(result),
|
||||
};
|
||||
verdictCache.set(cacheKey, {
|
||||
highRisk,
|
||||
verdict,
|
||||
expiresAtMs: now + VERDICT_CACHE_TTL_MS,
|
||||
});
|
||||
return highRisk;
|
||||
return verdict;
|
||||
}
|
||||
|
||||
@@ -2,7 +2,11 @@
|
||||
|
||||
import type {IpInfoLookupResult} from '@pkgs/geoip/src/IpInfoService';
|
||||
import {describe, expect, it} from 'vitest';
|
||||
import {isHighCgnatBlastRadiusRisk, isSingleIpBanCandidate} from '../IpBanCgnatGuard';
|
||||
import {
|
||||
isHighCgnatBlastRadiusRisk,
|
||||
isHighSharedAccessBlastRadiusRisk,
|
||||
isSingleIpBanCandidate,
|
||||
} from '../IpBanCgnatGuard';
|
||||
|
||||
function ipInfoResult(overrides: Partial<IpInfoLookupResult> = {}): IpInfoLookupResult {
|
||||
return {
|
||||
@@ -96,4 +100,63 @@ describe('IpBanCgnatGuard', () => {
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
it('flags satellite, anycast and education networks as high blast-radius risk', () => {
|
||||
expect(
|
||||
isHighSharedAccessBlastRadiusRisk(
|
||||
ipInfoResult({
|
||||
flags: {isAnycast: false, isHosting: false, isMobile: false, isSatellite: true},
|
||||
}),
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isHighSharedAccessBlastRadiusRisk(
|
||||
ipInfoResult({
|
||||
flags: {isAnycast: true, isHosting: false, isMobile: false, isSatellite: false},
|
||||
}),
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isHighSharedAccessBlastRadiusRisk(
|
||||
ipInfoResult({asn: {asn: 'AS64500', number: 64500, name: 'Test University', domain: null, type: 'education'}}),
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
it('does not flag ordinary residential networks as shared-access risk', () => {
|
||||
expect(isHighSharedAccessBlastRadiusRisk(ipInfoResult())).toBe(false);
|
||||
});
|
||||
it('does not treat shared-access networks as CGNAT risk', () => {
|
||||
expect(
|
||||
isHighCgnatBlastRadiusRisk(
|
||||
ipInfoResult({
|
||||
flags: {isAnycast: false, isHosting: false, isMobile: false, isSatellite: true},
|
||||
}),
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
it('does not exempt hosting or anonymous shared-access infrastructure', () => {
|
||||
expect(
|
||||
isHighSharedAccessBlastRadiusRisk(
|
||||
ipInfoResult({
|
||||
flags: {isAnycast: true, isHosting: true, isMobile: false, isSatellite: false},
|
||||
}),
|
||||
),
|
||||
).toBe(false);
|
||||
expect(
|
||||
isHighSharedAccessBlastRadiusRisk(
|
||||
ipInfoResult({
|
||||
anonymous: {
|
||||
isAnonymous: true,
|
||||
providerName: 'Example VPN',
|
||||
isVpn: true,
|
||||
isProxy: false,
|
||||
isResidentialProxy: false,
|
||||
isTor: false,
|
||||
isRelay: false,
|
||||
percentDaysSeen: null,
|
||||
},
|
||||
flags: {isAnycast: false, isHosting: false, isMobile: false, isSatellite: true},
|
||||
}),
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -344,7 +344,7 @@ describe('Message Search Permissions', () => {
|
||||
}
|
||||
expect(result.messages.length).toBe(0);
|
||||
});
|
||||
test('age-restricted guild requires include_nsfw: true', async () => {
|
||||
test('age-restricted guild is searchable by an adult member without include_nsfw', async () => {
|
||||
const owner = await createTestAccount(harness, {dateOfBirth: '2000-01-01'});
|
||||
const guild = await createGuild(harness, owner.token, 'Age Restricted Search Guild');
|
||||
const systemChannelId = guild.system_channel_id!;
|
||||
@@ -363,7 +363,7 @@ describe('Message Search Permissions', () => {
|
||||
if (!isSearchResult(excluded)) {
|
||||
expect.fail('Expected search result but got indexing response');
|
||||
}
|
||||
expect(excluded.messages.length).toBe(0);
|
||||
expect(excluded.messages.some((m) => m.channel_id === systemChannelId)).toBe(true);
|
||||
const included = await createBuilder<MessageSearchResponse>(harness, owner.token)
|
||||
.post('/search/messages')
|
||||
.body({
|
||||
@@ -379,6 +379,30 @@ describe('Message Search Permissions', () => {
|
||||
expect(included.messages.length).toBeGreaterThan(0);
|
||||
expect(included.messages.some((m) => m.channel_id === systemChannelId)).toBe(true);
|
||||
});
|
||||
test('age-restricted guild is searchable in a channel pinned to nsfw_override: false', async () => {
|
||||
const owner = await createTestAccount(harness, {dateOfBirth: '2000-01-01'});
|
||||
const guild = await createGuild(harness, owner.token, 'Age Restricted Override Guild');
|
||||
const channel = await createBuilder<{id: string; nsfw_override?: boolean | null}>(harness, owner.token)
|
||||
.post(`/guilds/${guild.id}/channels`)
|
||||
.body({name: 'override-channel', type: ChannelTypes.GUILD_TEXT, nsfw: false})
|
||||
.execute();
|
||||
expect(channel.nsfw_override).toBe(false);
|
||||
await sendChannelMessage(harness, owner.token, channel.id, 'age restricted override searchable message');
|
||||
await updateGuild(harness, owner.token, guild.id, {nsfw_level: GuildNSFWLevel.AGE_RESTRICTED});
|
||||
await markGuildChannelsAsIndexed(harness, owner.token, guild.id);
|
||||
const result = await createBuilder<MessageSearchResponse>(harness, owner.token)
|
||||
.post('/search/messages')
|
||||
.body({
|
||||
content: 'age restricted override searchable',
|
||||
context_guild_id: guild.id,
|
||||
})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
if (!isSearchResult(result)) {
|
||||
expect.fail('Expected search result but got indexing response');
|
||||
}
|
||||
expect(result.messages.some((m) => m.channel_id === channel.id)).toBe(true);
|
||||
});
|
||||
test('underage user cannot search messages in an age-restricted guild', async () => {
|
||||
const owner = await createTestAccount(harness, {dateOfBirth: '2000-01-01'});
|
||||
const underageMember = await createTestAccount(harness, {dateOfBirth: '2012-01-01'});
|
||||
|
||||
@@ -441,16 +441,25 @@ function collectGuildText(doc: SearchableGuild): Array<string | null> {
|
||||
return [doc.name, doc.vanityUrlCode, doc.discoveryDescription, ...doc.discoveryTags];
|
||||
}
|
||||
|
||||
const sortGuildsByCreatedAt = sortNumericField<SearchableGuild, GuildSearchFilters>('createdAt', 'asc');
|
||||
const sortGuildsByMemberCount = sortNumericField<SearchableGuild, GuildSearchFilters>('memberCount', 'desc');
|
||||
|
||||
function sortGuilds(left: SearchableGuild, right: SearchableGuild, filters: GuildSearchFilters, query: string): number {
|
||||
const sorter = filters.sortBy === 'memberCount' ? sortGuildsByMemberCount : sortGuildsByCreatedAt;
|
||||
const delta = sorter(left, right, filters, query);
|
||||
if (delta !== 0) return delta;
|
||||
const leftId = BigInt(left.id);
|
||||
const rightId = BigInt(right.id);
|
||||
if (leftId === rightId) return 0;
|
||||
return leftId > rightId ? -1 : 1;
|
||||
}
|
||||
|
||||
class InMemoryGuildSearchService
|
||||
extends InMemorySearchServiceBase<GuildSearchFilters, SearchableGuild>
|
||||
implements IGuildSearchService
|
||||
{
|
||||
constructor() {
|
||||
super(
|
||||
matchesGuildFilters,
|
||||
collectGuildText,
|
||||
sortNumericField<SearchableGuild, GuildSearchFilters>('createdAt', 'asc'),
|
||||
);
|
||||
super(matchesGuildFilters, collectGuildText, sortGuilds);
|
||||
}
|
||||
|
||||
async indexGuild(guild: Guild, discovery?: GuildDiscoveryContext): Promise<void> {
|
||||
|
||||
@@ -178,7 +178,7 @@ export class EmailChangeService {
|
||||
}
|
||||
const hasValidDns = await emailDnsValidation.hasValidDnsRecords(trimmedEmail);
|
||||
if (!hasValidDns) {
|
||||
throw InputValidationError.fromCode('new_email', ValidationErrorCodes.INVALID_EMAIL_ADDRESS);
|
||||
throw InputValidationError.fromCode('new_email', ValidationErrorCodes.EMAIL_DOMAIN_CANNOT_RECEIVE_MAIL);
|
||||
}
|
||||
const existing = await users.findByEmail(trimmedEmail.toLowerCase());
|
||||
if (existing && existing.id !== user.id) {
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
import {DiscoveryApplicationStatus} from '@fluxer/constants/src/DiscoveryConstants';
|
||||
import type {WorkerTaskHandler} from '@pkgs/worker/src/contracts/WorkerTask';
|
||||
import type {GuildID} from '../../BrandedTypes';
|
||||
import {GuildDiscoveryRepository} from '../../guild/repositories/GuildDiscoveryRepository';
|
||||
import {getGuildSearchService} from '../../SearchFactory';
|
||||
import {mapWithConcurrency} from '../../utils/ConcurrencyUtils';
|
||||
@@ -17,7 +16,7 @@ const syncDiscoveryIndex: WorkerTaskHandler = async (_payload, helpers) => {
|
||||
helpers.logger.warn('Search service not available, skipping discovery index sync');
|
||||
return;
|
||||
}
|
||||
const {guildRepository, gatewayService} = getWorkerDependencies();
|
||||
const {guildRepository} = getWorkerDependencies();
|
||||
const discoveryRepository = new GuildDiscoveryRepository();
|
||||
const approvedRows = await discoveryRepository.listByStatus(DiscoveryApplicationStatus.APPROVED);
|
||||
if (approvedRows.length === 0) {
|
||||
@@ -25,21 +24,6 @@ const syncDiscoveryIndex: WorkerTaskHandler = async (_payload, helpers) => {
|
||||
return;
|
||||
}
|
||||
const guildIds = approvedRows.map((row) => row.guild_id);
|
||||
let freshCounts = new Map<
|
||||
GuildID,
|
||||
{
|
||||
memberCount: number;
|
||||
onlineCount: number;
|
||||
}
|
||||
>();
|
||||
try {
|
||||
freshCounts = await gatewayService.getDiscoveryGuildCounts(guildIds);
|
||||
} catch (error) {
|
||||
helpers.logger.warn(
|
||||
{error: error instanceof Error ? error.message : String(error)},
|
||||
'Failed to fetch fresh guild counts from gateway, using database values',
|
||||
);
|
||||
}
|
||||
let synced = 0;
|
||||
for (let i = 0; i < guildIds.length; i += BATCH_SIZE) {
|
||||
const batch = guildIds.slice(i, i + BATCH_SIZE);
|
||||
@@ -54,7 +38,7 @@ const syncDiscoveryIndex: WorkerTaskHandler = async (_payload, helpers) => {
|
||||
if (!guild) return null;
|
||||
const discoveryRow = discoveryRows[index];
|
||||
if (!discoveryRow || discoveryRow.status !== DiscoveryApplicationStatus.APPROVED) return null;
|
||||
return {guild, discoveryRow, counts: freshCounts.get(guildId)};
|
||||
return {guild, discoveryRow};
|
||||
})
|
||||
.filter((update): update is NonNullable<typeof update> => update != null);
|
||||
await mapWithConcurrency(updates, UPDATE_CONCURRENCY, (update) =>
|
||||
@@ -63,7 +47,6 @@ const syncDiscoveryIndex: WorkerTaskHandler = async (_payload, helpers) => {
|
||||
categoryId: update.discoveryRow.category_type,
|
||||
primaryLanguage: update.discoveryRow.primary_language ?? null,
|
||||
tags: update.discoveryRow.custom_tags ?? [],
|
||||
memberCount: update.counts?.memberCount,
|
||||
}),
|
||||
);
|
||||
synced += updates.length;
|
||||
|
||||
@@ -28,6 +28,12 @@ const MODULE_REGISTRY_TEST_FILES = [
|
||||
'src/api/risk/__tests__/AccountPolicyService.test.ts',
|
||||
];
|
||||
|
||||
const INSTANCE_POLICY_TEST_FILES = [
|
||||
'src/api/admin/tests/InstanceConfigPendingRegistrationApproval.test.ts',
|
||||
'src/api/auth/tests/DeferredPhoneGate.test.ts',
|
||||
'src/api/instance/tests/SingleCommunityService.test.ts',
|
||||
];
|
||||
|
||||
const sharedExclude = [
|
||||
...configDefaults.exclude,
|
||||
'pkgs/**',
|
||||
@@ -76,7 +82,7 @@ export default defineConfig({
|
||||
...sharedTestConfig,
|
||||
name: 'api',
|
||||
include: ['src/**/*.{test,spec}.{ts,tsx}'],
|
||||
exclude: [...sharedExclude, ...MODULE_REGISTRY_TEST_FILES],
|
||||
exclude: [...sharedExclude, ...MODULE_REGISTRY_TEST_FILES, ...INSTANCE_POLICY_TEST_FILES],
|
||||
isolate: false,
|
||||
},
|
||||
},
|
||||
@@ -90,6 +96,16 @@ export default defineConfig({
|
||||
isolate: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
plugins: [tsconfigPaths()],
|
||||
test: {
|
||||
...sharedTestConfig,
|
||||
name: 'api-instance-policy',
|
||||
include: INSTANCE_POLICY_TEST_FILES,
|
||||
exclude: sharedExclude,
|
||||
isolate: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
@@ -7,114 +7,65 @@
|
||||
|
||||
## Fluxer modifications
|
||||
|
||||
Changes applied on top of the upstream v2.17.2 source. Previously maintained as
|
||||
a pnpm patch at `patches/[email protected]`; now maintained as regular
|
||||
source edits in this package.
|
||||
Changes applied on top of the upstream v2.17.2 source. These were previously a pnpm patch at `patches/[email protected]` and are now plain source edits in this package.
|
||||
|
||||
1. **AV1 E2EE support** (`src/e2ee/worker/av1Crypto.ts`, `FrameCryptor.ts`, `e2ee.worker.ts`)
|
||||
OBU-level AV1 encryption and decryption for end-to-end encrypted voice/video.
|
||||
|
||||
OBU-level AV1 encryption and decryption for end-to-end encrypted voice and video.
|
||||
|
||||
2. **UpdateTrackContext message** (`src/e2ee/types.ts`, worker dispatch)
|
||||
Replaced `updateCodec` with richer `updateTrackContext` carrying participant
|
||||
identity and track ID, preventing codec mismatch on track reuse.
|
||||
|
||||
`updateCodec` replaced with `updateTrackContext`, which carries participant identity and track id so a reused track cannot pick up the wrong codec.
|
||||
|
||||
3. **E2EEManager state tracking** (`src/e2ee/E2eeManager.ts`)
|
||||
Added `getE2EETransformState()` / `setE2EETransformState()` for proper
|
||||
transform lifecycle management.
|
||||
|
||||
4. **Screenshare scalability mode** (`src/room/participant/LocalParticipant.ts`)
|
||||
Preserve caller-supplied `scalabilityMode` for screen shares instead of forcing
|
||||
`L3T3_KEY`, so VP9/AV1 screen shares can use the browser's compatible default
|
||||
unless Fluxer explicitly requests an SVC layer layout.
|
||||
`getE2EETransformState()` and `setE2EETransformState()` for transform lifecycle management.
|
||||
|
||||
4. **Screen share scalability mode** (`src/room/participant/LocalParticipant.ts`)
|
||||
|
||||
A caller-supplied `scalabilityMode` is preserved for screen shares instead of being forced to `L3T3_KEY`, so VP9 and AV1 screen shares use the browser default unless Fluxer asks for a specific SVC layout.
|
||||
|
||||
5. **E2EE frame layout guards** (`src/e2ee/worker/FrameCryptor.ts`)
|
||||
Validate encrypted frame trailer, IV, tag, and clear-prefix bounds before
|
||||
constructing typed-array views, and drop malformed encrypted frames without
|
||||
tearing down the transform stream.
|
||||
|
||||
Encrypted frame trailer, IV, tag and clear-prefix bounds are validated before any typed-array view is constructed, and malformed frames are dropped without tearing down the transform stream.
|
||||
|
||||
6. **Encrypted backup codec publishing** (`src/room/participant/LocalParticipant.ts`, `src/e2ee/E2eeManager.ts`)
|
||||
Allows backup codec tracks to be advertised and published while E2EE is
|
||||
enabled, and attaches sender transforms to backup codec senders using their
|
||||
cloned media track ID and codec.
|
||||
|
||||
Backup codec tracks can be advertised and published while E2EE is on, with sender transforms attached using the cloned media track id and codec.
|
||||
|
||||
7. **Publisher codec preferences** (`src/room/RTCEngine.ts`)
|
||||
Applies `RTCRtpTransceiver.setCodecPreferences()` to publisher transceivers
|
||||
so browser SDP follows the selected primary or backup codec, and prefers
|
||||
H.264 profiles that use Chromium's external/hardware encoder before the
|
||||
OpenH264 software profile.
|
||||
|
||||
`setCodecPreferences()` is applied to publisher transceivers so the browser's SDP follows the selected primary or backup codec. H.264 profiles rank Baseline `42001f` first, then Constrained Baseline `42e01f`, then everything else. Main, High and Constrained High rank last on purpose.
|
||||
|
||||
livekit-server registers H.264 High `640032` on the publisher peer connection but filters it off the subscriber peer connection, and its `CodecParametersFuzzySearch` falls back to a mime-only match. A High publication therefore reaches subscribers under their `42e01f` payload type and decodes to nothing on a Constrained-Baseline-only decoder such as Firefox's OpenH264 GMP. `42001f` is the one profile Chromium's accelerated encoder factory advertises that such a decoder can still handle, because Chromium's VAAPI encoder and OpenH264 both write a Constrained Baseline SPS for `H264PROFILE_BASELINE`.
|
||||
|
||||
The trade is that livekit-server does not register `42001f` either, so Windows and macOS negotiate `42e01f`, which Chromium's accelerated encoder factory does not advertise there (`kPlatformH264CbpEncoding` is off by default on Windows, and `IsH264ConstrainedBaselineProfileAvailableForAcceleratedEncoder` returns false on Apple). Those publishers fall back to software H.264, which is what an unpatched browser does anyway. Linux, ChromeOS and Android keep hardware encoding.
|
||||
|
||||
8. **Media publishing defaults** (`src/room/defaults.ts`, `src/room/utils.ts`, `src/room/track/options.ts`)
|
||||
Falls back to H.264, then VP9, VP8, AV1, and HEVC/H.265 according to actual
|
||||
sender capabilities, pairs advanced codecs with H.264 backup simulcast, and
|
||||
uses maintain-resolution screen-share defaults with a 4K60-ready bitrate cap.
|
||||
The order puts AV1 and HEVC last because both are opt-in in Fluxer, so a
|
||||
fallback inside `publishTrack` must not land on a codec the user did not
|
||||
enable. Fluxer picks the codec itself before publishing, so this list only
|
||||
applies when the client overrides the request, such as the reconnect
|
||||
republish that runs outside Fluxer's own flows.
|
||||
|
||||
Codec fallback follows actual sender capabilities in the order H.264, VP9, VP8, AV1, HEVC. Advanced codecs are paired with an H.264 backup simulcast, and screen shares default to maintain-resolution with a 4K60-ready bitrate cap. AV1 and HEVC come last because both are opt-in in Fluxer, so a fallback inside `publishTrack` must not land on a codec the user did not enable. Fluxer picks the codec itself before publishing, so this order only applies when the client overrides the request, such as the reconnect republish that runs outside Fluxer's own flows.
|
||||
|
||||
9. **High-fidelity Opus SDP munging** (`src/room/PCTransport.ts`)
|
||||
Forces Opus RED/FEC, stereo signaling, 10 ms packet time, no DTX, and a
|
||||
510 kbps maximum average bitrate in local offers and remote answers.
|
||||
|
||||
Local offers and remote answers are munged to force Opus RED and FEC, 10 ms packet time, no DTX, and a 510 kbps maximum average bitrate. Stereo signalling stays opt-in. `stereo=1` and `sprop-stereo=1` are added only for publications whose `TrackBitrateInfo.stereo` is set (studio mode above the stereo bitrate threshold, and screen-share audio) and for the subscriber mids the server advertised as stereo, so a mono microphone is not encoded and decoded as a two-channel stream.
|
||||
|
||||
10. **Remote audio volume restore at exactly zero** (`src/room/track/RemoteAudioTrack.ts`)
|
||||
`attach()`, `connectWebAudio()` and `getVolume()` guarded the remembered
|
||||
`elementVolume` with a truthiness check, so a track deliberately held at `0`
|
||||
came back at full volume whenever it was re-attached or its Web Audio graph
|
||||
was rebuilt. All three guards now test `!== undefined`. Note that remote
|
||||
gains above `1.0` are only legal because `setVolume()` takes the Web Audio
|
||||
`gainNode` branch; the `el.volume` branch would throw `IndexSizeError`.
|
||||
`webAudioMix` must stay unconditional.
|
||||
|
||||
`attach()`, `connectWebAudio()` and `getVolume()` guarded the remembered `elementVolume` with a truthiness check, so a track deliberately held at `0` came back at full volume whenever it was re-attached or its Web Audio graph was rebuilt. All three now test `!== undefined`. Remote gains above `1.0` are only legal because `setVolume()` takes the Web Audio `gainNode` branch, as the `el.volume` branch would throw `IndexSizeError`, so `webAudioMix` must stay unconditional.
|
||||
|
||||
11. **Processor teardown before source stop** (`src/room/track/LocalTrack.ts`)
|
||||
`stop()` called `super.stop()` first, killing the source `MediaStreamTrack`
|
||||
and closing the readable feeding a track processor before `processor.destroy()`
|
||||
ran. A camera-effect worker therefore saw input EOF before its owner's stop
|
||||
command and reported an operational failure during an ordinary camera-off.
|
||||
The processor is now captured, detached, and its teardown initiated before
|
||||
`super.stop()`.
|
||||
|
||||
12. **Transactional source and processor swaps** (`src/room/track/LocalTrack.ts`,
|
||||
`LocalVideoTrack.ts`, `LocalAudioTrack.ts`)
|
||||
`setMediaStreamTrack()` applied the new source, restarted the processor and
|
||||
re-armed the sender with no unwind path, so a failure anywhere in the middle
|
||||
left a half-applied track: listeners moved, elements detached, sender pointing
|
||||
at a dead track. It now takes `SetMediaStreamTrackOptions`
|
||||
(`force`, `deferEndedListener`, `preservePreviousTrack`) and, on failure,
|
||||
restores the previous source, constraints, `enabled` state, listeners,
|
||||
processor and sender, throwing `TrackInvalidError` when the previous source is
|
||||
no longer `live` because an ended track cannot be restored. Both errors are
|
||||
surfaced together as an `AggregateError` when the unwind itself fails.
|
||||
`stageTrackReplacement()` / `commitStagedTrackReplacement()` expose a two-phase
|
||||
swap: the candidate becomes the active source with its `ended` listener
|
||||
deferred and the previous source preserved, and only the commit adopts the
|
||||
`ended` listener and clears the staged identity, so a caller can validate its
|
||||
publication before the swap is observable. `replaceTrack()` and `restart()`
|
||||
guard the `providedByUser` flip behind a `replacementCommitted` flag.
|
||||
`restart()` still detaches and stops the previous source before calling
|
||||
`getUserMedia()`, as upstream does, because Safari ends a freshly acquired
|
||||
track with a capture failure while the old track for the same device is
|
||||
live. `setSimulcastTrackSender()` routes an already-installed processor's
|
||||
`processedTrack` to a newly registered secondary sender so a backup codec
|
||||
never publishes raw frames while the primary is processed.
|
||||
Processor install and teardown in all three classes roll the processed/raw
|
||||
sender track back, including `LocalVideoTrack`'s secondary simulcast senders,
|
||||
and aggregate every cleanup failure instead of discarding it.
|
||||
`stop()` called `super.stop()` first, killing the source `MediaStreamTrack` and closing the readable that feeds a track processor before `processor.destroy()` ran. A camera-effect worker therefore saw input EOF before its owner's stop command and reported an operational failure during an ordinary camera-off. The processor is now captured, detached and torn down before `super.stop()`.
|
||||
|
||||
13. **Start bitrate for every video codec** (`src/room/PCTransport.ts`,
|
||||
`src/room/participant/LocalParticipant.ts`, `src/room/participant/publishUtils.ts`)
|
||||
`x-google-start-bitrate` was reachable only by AV1 and VP9, gated twice: the
|
||||
publish path registered a track bitrate only for SVC codecs, and the offer
|
||||
munging returned early for everything else. H264, H265 and VP8 therefore
|
||||
opened at the Chromium default and had to ramp, which showed up as a 3000 kbps
|
||||
screen share encoding at 346 kbps twenty seconds in. The bitrate is now
|
||||
registered for every video codec from the highest encoding
|
||||
(`maxEncodingBitrate()`, so a simulcast ladder contributes its top layer), and
|
||||
the offer munging applies the start bitrate whenever a max bitrate is known.
|
||||
The dependency descriptor extension stays SVC-only.
|
||||
`appendStartBitrateToFmtp()` holds the fmtp edit so it can be tested, and
|
||||
`setTrackCodecBitrate()` now replaces an entry for the same cid or transceiver
|
||||
instead of appending, since `trackBitrates` is never cleared.
|
||||
12. **Transactional source and processor swaps** (`src/room/track/LocalTrack.ts`, `LocalVideoTrack.ts`, `LocalAudioTrack.ts`)
|
||||
|
||||
`setMediaStreamTrack()` applied the new source, restarted the processor and re-armed the sender with no unwind path, so a failure part-way through left a half-applied track with listeners moved, elements detached and the sender pointing at a dead track. It now takes `SetMediaStreamTrackOptions` (`force`, `deferEndedListener`, `preservePreviousTrack`) and, on failure, restores the previous source, constraints, `enabled` state, listeners, processor and sender. It throws `TrackInvalidError` when the previous source is no longer `live`, because an ended track cannot be restored, and surfaces both failures as an `AggregateError` when the unwind itself fails.
|
||||
|
||||
`stageTrackReplacement()` and `commitStagedTrackReplacement()` add a two-phase swap. The candidate becomes the active source with its `ended` listener deferred and the previous source preserved, and only the commit adopts the `ended` listener and clears the staged identity, so a caller can validate its publication before the swap is observable. `replaceTrack()` and `restart()` guard the `providedByUser` flip behind a `replacementCommitted` flag. `restart()` still detaches and stops the previous source before calling `getUserMedia()`, as upstream does, because Safari ends a freshly acquired track with a capture failure while the old track for the same device is still live. `setSimulcastTrackSender()` routes an installed processor's `processedTrack` to a newly registered secondary sender so a backup codec never publishes raw frames while the primary is processed. Processor install and teardown in all three classes roll the processed and raw sender track back, including `LocalVideoTrack`'s secondary simulcast senders, and aggregate every cleanup failure instead of discarding it.
|
||||
|
||||
13. **Start bitrate for every video codec** (`src/room/PCTransport.ts`, `src/room/participant/LocalParticipant.ts`, `src/room/participant/publishUtils.ts`)
|
||||
|
||||
`x-google-start-bitrate` was reachable only by AV1 and VP9 because it was gated twice. The publish path registered a track bitrate only for SVC codecs, and the offer munging returned early for everything else. H.264, H.265 and VP8 therefore opened at the Chromium default and had to ramp, which showed up as a 3000 kbps screen share encoding at 346 kbps twenty seconds in. The bitrate is now registered for every video codec from the highest encoding (`maxEncodingBitrate()`, so a simulcast ladder contributes its top layer), and the offer munging applies it whenever a max bitrate is known. The dependency descriptor extension stays SVC-only. `appendStartBitrateToFmtp()` holds the fmtp edit so it can be tested, and `setTrackCodecBitrate()` replaces an entry for the same cid or transceiver instead of appending, since `trackBitrates` is never cleared.
|
||||
|
||||
## Updating from upstream
|
||||
|
||||
|
||||
@@ -3,12 +3,43 @@
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
import type {MediaDescription} from 'sdp-transform';
|
||||
import {describe, expect, it} from 'vitest';
|
||||
import {appendStartBitrateToFmtp} from './PCTransport.ts';
|
||||
import type {TrackBitrateInfo} from './PCTransport.ts';
|
||||
import {appendStartBitrateToFmtp, collectStereoMids, ensureAudioNackAndStereo, ensureOpusFmtp} from './PCTransport.ts';
|
||||
|
||||
function mediaWithFmtp(entries: Array<{payload: number; config: string}>): MediaDescription {
|
||||
return {fmtp: entries} as unknown as MediaDescription;
|
||||
}
|
||||
|
||||
function opusMedia(config: string, mid = '0'): MediaDescription {
|
||||
return {
|
||||
type: 'audio',
|
||||
mid,
|
||||
port: 9,
|
||||
protocol: 'UDP/TLS/RTP/SAVPF',
|
||||
rtp: [{payload: 109, codec: 'opus', rate: 48000, encoding: 2}],
|
||||
fmtp: [{payload: 109, config}],
|
||||
} as unknown as MediaDescription;
|
||||
}
|
||||
|
||||
function offerMedia(mid: string, trackId: string): MediaDescription {
|
||||
const media = opusMedia('useinbandfec=1', mid);
|
||||
media.msid = `- ${trackId}`;
|
||||
return media;
|
||||
}
|
||||
|
||||
function audioBitrateInfo(mid: string | null, trackId: string, stereo: boolean): TrackBitrateInfo {
|
||||
return {
|
||||
transceiver: {mid, sender: {track: {id: trackId}}} as unknown as RTCRtpTransceiver,
|
||||
codec: 'opus',
|
||||
maxbr: 320,
|
||||
stereo,
|
||||
};
|
||||
}
|
||||
|
||||
function opusConfig(media: MediaDescription): string {
|
||||
return media.fmtp.find((fmtp) => fmtp.payload === 109)?.config ?? '';
|
||||
}
|
||||
|
||||
describe('appendStartBitrateToFmtp', () => {
|
||||
it('appends the start bitrate to a non-SVC codec fmtp line', () => {
|
||||
const media = mediaWithFmtp([
|
||||
@@ -44,3 +75,72 @@ describe('appendStartBitrateToFmtp', () => {
|
||||
expect(media.fmtp[0]?.config).toBe('profile-level-id=42e01f');
|
||||
});
|
||||
});
|
||||
|
||||
describe('ensureOpusFmtp', () => {
|
||||
it('does not force stereo on a mono publication', () => {
|
||||
const media = opusMedia('maxplaybackrate=48000;stereo=0;useinbandfec=1');
|
||||
ensureOpusFmtp(media, 48000, false);
|
||||
const config = opusConfig(media);
|
||||
expect(config).toContain('minptime=10');
|
||||
expect(config).toContain('useinbandfec=1');
|
||||
expect(config).toContain('usedtx=0');
|
||||
expect(config).toContain('maxaveragebitrate=48000');
|
||||
expect(config).not.toContain('stereo=1');
|
||||
});
|
||||
|
||||
it('keeps stereo for a stereo publication', () => {
|
||||
const media = opusMedia('maxplaybackrate=48000;useinbandfec=1');
|
||||
ensureOpusFmtp(media, 320000, true);
|
||||
const config = opusConfig(media);
|
||||
expect(config).toContain('stereo=1');
|
||||
expect(config).toContain('sprop-stereo=1');
|
||||
expect(config).toContain('maxaveragebitrate=320000');
|
||||
});
|
||||
|
||||
it('preserves a stereo parameter the server negotiated', () => {
|
||||
const media = opusMedia('minptime=10;stereo=1');
|
||||
ensureOpusFmtp(media, 48000, false);
|
||||
expect(opusConfig(media)).toContain('stereo=1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('ensureAudioNackAndStereo', () => {
|
||||
it('only stamps stereo on the listed mids', () => {
|
||||
const mono = opusMedia('useinbandfec=1', '0');
|
||||
ensureAudioNackAndStereo(mono as never, ['1'], []);
|
||||
expect(opusConfig(mono)).not.toContain('stereo=1');
|
||||
|
||||
const stereo = opusMedia('useinbandfec=1', '1');
|
||||
ensureAudioNackAndStereo(stereo as never, ['1'], []);
|
||||
expect(opusConfig(stereo)).toContain('stereo=1');
|
||||
expect(opusConfig(stereo)).toContain('sprop-stereo=1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('collectStereoMids', () => {
|
||||
it('matches the offer media section by msid before the transceiver has a mid', () => {
|
||||
const media = [offerMedia('0', 'mic-track'), offerMedia('1', 'screenshare-track')];
|
||||
expect(collectStereoMids([audioBitrateInfo(null, 'screenshare-track', true)], media)).toEqual(['1']);
|
||||
});
|
||||
|
||||
it('stamps stereo on the first offer for a new stereo publication', () => {
|
||||
const media = [offerMedia('0', 'mic-track'), offerMedia('1', 'screenshare-track')];
|
||||
const stereoMids = collectStereoMids([audioBitrateInfo(null, 'screenshare-track', true)], media);
|
||||
for (const m of media) {
|
||||
ensureAudioNackAndStereo(m as never, stereoMids, []);
|
||||
}
|
||||
expect(opusConfig(media[0]!)).not.toContain('stereo=1');
|
||||
expect(opusConfig(media[1]!)).toContain('stereo=1');
|
||||
expect(opusConfig(media[1]!)).toContain('sprop-stereo=1');
|
||||
});
|
||||
|
||||
it('uses the assigned mid once renegotiation has one', () => {
|
||||
const media = [offerMedia('0', 'mic-track'), offerMedia('1', 'screenshare-track')];
|
||||
expect(collectStereoMids([audioBitrateInfo('1', 'screenshare-track', true)], media)).toEqual(['1']);
|
||||
});
|
||||
|
||||
it('leaves mono publications out', () => {
|
||||
const media = [offerMedia('0', 'mic-track')];
|
||||
expect(collectStereoMids([audioBitrateInfo(null, 'mic-track', false)], media)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -11,11 +11,12 @@ import {NegotiationError, UnexpectedConnectionState} from './errors.ts';
|
||||
import type {LoggerOptions} from './types.ts';
|
||||
import {ddExtensionURI, isFireFox, isSafari, isSVCCodec} from './utils.ts';
|
||||
|
||||
interface TrackBitrateInfo {
|
||||
export interface TrackBitrateInfo {
|
||||
cid?: string;
|
||||
transceiver?: RTCRtpTransceiver;
|
||||
codec: string;
|
||||
maxbr: number;
|
||||
stereo?: boolean;
|
||||
}
|
||||
|
||||
const startBitrateFraction = 0.7;
|
||||
@@ -25,8 +26,6 @@ const requiredOpusFmtpParameters = {
|
||||
minptime: '10',
|
||||
useinbandfec: '1',
|
||||
usedtx: '0',
|
||||
stereo: '1',
|
||||
'sprop-stereo': '1',
|
||||
};
|
||||
const debounceInterval = 20;
|
||||
export const PCEvents = {
|
||||
@@ -156,20 +155,18 @@ export default class PCTransport extends EventEmitter {
|
||||
sdpParsed.media.forEach((media) => {
|
||||
const mid = getMidString(media.mid!);
|
||||
if (media.type === 'audio') {
|
||||
ensureOpusFmtp(media);
|
||||
this.trackBitrates.some((trackbr): boolean => {
|
||||
if (!trackbr.transceiver || mid !== trackbr.transceiver.mid) {
|
||||
return false;
|
||||
}
|
||||
const codecPayload = getCodecPayload(media, trackbr.codec);
|
||||
if (codecPayload === 0) {
|
||||
return true;
|
||||
}
|
||||
if (trackbr.codec.toLowerCase() === 'opus') {
|
||||
ensureOpusFmtp(media, trackbr.maxbr > 0 ? trackbr.maxbr * 1000 : opusMaxAverageBitrateBps);
|
||||
}
|
||||
return true;
|
||||
});
|
||||
const trackbr = this.trackBitrates.find(
|
||||
(br) => br.transceiver !== undefined && mid === br.transceiver.mid && br.codec.toLowerCase() === 'opus',
|
||||
);
|
||||
if (trackbr && getCodecPayload(media, trackbr.codec) !== 0) {
|
||||
ensureOpusFmtp(
|
||||
media,
|
||||
trackbr.maxbr > 0 ? trackbr.maxbr * 1000 : opusMaxAverageBitrateBps,
|
||||
trackbr.stereo === true,
|
||||
);
|
||||
} else {
|
||||
ensureOpusFmtp(media);
|
||||
}
|
||||
}
|
||||
});
|
||||
mungedSDP = write(sdpParsed);
|
||||
@@ -240,10 +237,11 @@ export default class PCTransport extends EventEmitter {
|
||||
const offer = await this.pc.createOffer(options);
|
||||
this.log.debug('original offer', {sdp: offer.sdp, ...this.logContext});
|
||||
const sdpParsed = parse(offer.sdp ?? '');
|
||||
const stereoMids = collectStereoMids(this.trackBitrates, sdpParsed.media);
|
||||
sdpParsed.media.forEach((media) => {
|
||||
ensureIPAddrMatchVersion(media);
|
||||
if (media.type === 'audio') {
|
||||
ensureAudioNackAndStereo(media, ['all'], []);
|
||||
ensureAudioNackAndStereo(media, stereoMids, []);
|
||||
} else if (media.type === 'video') {
|
||||
this.trackBitrates.some((trackbr): boolean => {
|
||||
if (!media.msid || !trackbr.cid || !media.msid.includes(trackbr.cid)) {
|
||||
@@ -587,7 +585,11 @@ function ensureAudioRedFmtp(media: MediaDescription, opusPayload: number): void
|
||||
}
|
||||
}
|
||||
|
||||
function ensureOpusFmtp(media: MediaDescription, maxAverageBitrateBps: number = opusMaxAverageBitrateBps): number {
|
||||
export function ensureOpusFmtp(
|
||||
media: MediaDescription,
|
||||
maxAverageBitrateBps: number = opusMaxAverageBitrateBps,
|
||||
stereo = false,
|
||||
): number {
|
||||
const opusPayload = getCodecPayload(media, 'opus');
|
||||
if (opusPayload <= 0) return 0;
|
||||
media.ptime = opusPacketTimeMs;
|
||||
@@ -596,6 +598,10 @@ function ensureOpusFmtp(media: MediaDescription, maxAverageBitrateBps: number =
|
||||
for (const [key, value] of Object.entries(requiredOpusFmtpParameters)) {
|
||||
config = setFmtpParameter(config, key, value);
|
||||
}
|
||||
if (stereo) {
|
||||
config = setFmtpParameter(config, 'stereo', '1');
|
||||
config = setFmtpParameter(config, 'sprop-stereo', '1');
|
||||
}
|
||||
if (maxAverageBitrateBps > 0) {
|
||||
config = setFmtpParameter(config, 'maxaveragebitrate', String(maxAverageBitrateBps));
|
||||
}
|
||||
@@ -604,18 +610,18 @@ function ensureOpusFmtp(media: MediaDescription, maxAverageBitrateBps: number =
|
||||
return opusPayload;
|
||||
}
|
||||
|
||||
function ensureAudioNackAndStereo(
|
||||
export function ensureAudioNackAndStereo(
|
||||
media: {
|
||||
type: string;
|
||||
port: number;
|
||||
protocol: string;
|
||||
payloads?: string | undefined;
|
||||
} & MediaDescription,
|
||||
_stereoMids: Array<string>,
|
||||
stereoMids: Array<string>,
|
||||
nackMids: Array<string>,
|
||||
) {
|
||||
const mid = getMidString(media.mid!);
|
||||
const opusPayload = ensureOpusFmtp(media);
|
||||
const opusPayload = ensureOpusFmtp(media, opusMaxAverageBitrateBps, stereoMids.includes(mid));
|
||||
if (opusPayload > 0) {
|
||||
if (!media.rtcpFb) {
|
||||
media.rtcpFb = [];
|
||||
@@ -629,6 +635,32 @@ function ensureAudioNackAndStereo(
|
||||
}
|
||||
}
|
||||
|
||||
export function collectStereoMids(
|
||||
trackBitrates: Array<TrackBitrateInfo>,
|
||||
media: Array<MediaDescription>,
|
||||
): Array<string> {
|
||||
const stereoMids: Array<string> = [];
|
||||
for (const trackbr of trackBitrates) {
|
||||
if (trackbr.stereo !== true || !trackbr.transceiver) {
|
||||
continue;
|
||||
}
|
||||
if (trackbr.transceiver.mid) {
|
||||
stereoMids.push(getMidString(trackbr.transceiver.mid));
|
||||
continue;
|
||||
}
|
||||
const trackId = trackbr.transceiver.sender.track?.id;
|
||||
if (trackId === undefined) {
|
||||
continue;
|
||||
}
|
||||
for (const m of media) {
|
||||
if (m.type === 'audio' && m.mid !== undefined && m.msid?.includes(trackId)) {
|
||||
stereoMids.push(getMidString(m.mid));
|
||||
}
|
||||
}
|
||||
}
|
||||
return stereoMids;
|
||||
}
|
||||
|
||||
function extractStereoAndNackAudioFromOffer(offer: RTCSessionDescriptionInit): {
|
||||
stereoMids: Array<string>;
|
||||
nackMids: Array<string>;
|
||||
|
||||
@@ -27,7 +27,59 @@ describe('selectPublisherCodecPreferences', () => {
|
||||
const highProfile = codec('video/H264', 'level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=64001f');
|
||||
const rtx = codec('video/rtx');
|
||||
const preferences = selectPublisherCodecPreferences('h264', [openH264, rtx, externalBaseline, highProfile]);
|
||||
expect(preferences).toEqual([externalBaseline, highProfile, openH264, rtx]);
|
||||
expect(preferences).toEqual([externalBaseline, openH264, highProfile, rtx]);
|
||||
});
|
||||
|
||||
it('ranks Constrained Baseline above Main, High and Constrained High', () => {
|
||||
const constrainedBaseline = codec(
|
||||
'video/H264',
|
||||
'level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=42e01f',
|
||||
);
|
||||
const mainProfile = codec('video/H264', 'level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=4d001f');
|
||||
const highProfileLevel31 = codec(
|
||||
'video/H264',
|
||||
'level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=64001f',
|
||||
);
|
||||
const highProfileLevel51 = codec(
|
||||
'video/H264',
|
||||
'level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=640033',
|
||||
);
|
||||
const constrainedHigh = codec(
|
||||
'video/H264',
|
||||
'level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=640c1f',
|
||||
);
|
||||
const preferences = selectPublisherCodecPreferences('h264', [
|
||||
mainProfile,
|
||||
highProfileLevel31,
|
||||
highProfileLevel51,
|
||||
constrainedHigh,
|
||||
constrainedBaseline,
|
||||
]);
|
||||
expect(preferences).toEqual([
|
||||
constrainedBaseline,
|
||||
mainProfile,
|
||||
highProfileLevel31,
|
||||
highProfileLevel51,
|
||||
constrainedHigh,
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps Constrained Baseline packetization-mode=1 ahead of Constrained Baseline packetization-mode=0 and High', () => {
|
||||
const constrainedBaselineMode0 = codec(
|
||||
'video/H264',
|
||||
'level-asymmetry-allowed=1;packetization-mode=0;profile-level-id=42e01f',
|
||||
);
|
||||
const constrainedBaselineMode1 = codec(
|
||||
'video/H264',
|
||||
'level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=42e01f',
|
||||
);
|
||||
const highProfile = codec('video/H264', 'level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=640033');
|
||||
const preferences = selectPublisherCodecPreferences('h264', [
|
||||
highProfile,
|
||||
constrainedBaselineMode0,
|
||||
constrainedBaselineMode1,
|
||||
]);
|
||||
expect(preferences).toEqual([constrainedBaselineMode1, constrainedBaselineMode0, highProfile]);
|
||||
});
|
||||
|
||||
it('keeps non-H.264 codecs in browser capability order and appends RTX', () => {
|
||||
|
||||
@@ -96,7 +96,7 @@ const videoCodecMimeTypes: Record<VideoCodec, Array<string>> = {
|
||||
vp8: ['video/vp8'],
|
||||
};
|
||||
const h264OpenH264ProfileLevelId = '42e01f';
|
||||
const h264PreferredHardwareProfileLevelIds = new Set(['42001f', '4d001f', '64001f']);
|
||||
const h264PreferredHardwareProfileLevelIds = new Set(['42001f']);
|
||||
type RtpCodecCapability = RTCRtpCapabilities['codecs'][number] & {sdpFmtpLine?: string};
|
||||
|
||||
enum PCState {
|
||||
@@ -1656,8 +1656,8 @@ function getH264PublisherCodecScore(codec: RtpCodecCapability): number {
|
||||
const packetizationMode = getFmtpParameter(codec.sdpFmtpLine, 'packetization-mode');
|
||||
const packetizationScore = packetizationMode === '1' ? 0 : 1;
|
||||
if (profileLevelId && h264PreferredHardwareProfileLevelIds.has(profileLevelId)) return packetizationScore;
|
||||
if (profileLevelId && profileLevelId !== h264OpenH264ProfileLevelId) return 10 + packetizationScore;
|
||||
if (profileLevelId === h264OpenH264ProfileLevelId) return 20 + packetizationScore;
|
||||
if (profileLevelId === h264OpenH264ProfileLevelId) return 10 + packetizationScore;
|
||||
if (profileLevelId) return 20 + packetizationScore;
|
||||
return 30 + packetizationScore;
|
||||
}
|
||||
|
||||
|
||||
@@ -1040,6 +1040,7 @@ export default class LocalParticipant extends Participant {
|
||||
transceiver: trackTransceiver,
|
||||
codec: 'opus',
|
||||
maxbr: encodings[0]?.maxBitrate ? encodings[0].maxBitrate / 1000 : 0,
|
||||
stereo: isStereo,
|
||||
});
|
||||
}
|
||||
} else if (track.codec) {
|
||||
|
||||
@@ -25,7 +25,6 @@ import * as ModalCommands from '@app/features/ui/commands/ModalCommands';
|
||||
import {modal} from '@app/features/ui/commands/ModalCommands';
|
||||
import * as PopoutCommands from '@app/features/ui/commands/PopoutCommands';
|
||||
import * as TextCopyCommands from '@app/features/ui/commands/TextCopyCommands';
|
||||
import * as ToastCommands from '@app/features/ui/commands/ToastCommands';
|
||||
import {MockAvatar} from '@app/features/ui/components/MockAvatar';
|
||||
import {StatusIndicator} from '@app/features/ui/components/StatusIndicator';
|
||||
import FocusRing from '@app/features/ui/focus_ring/FocusRing';
|
||||
@@ -52,9 +51,8 @@ import Users from '@app/features/user/state/Users';
|
||||
import * as NicknameUtils from '@app/features/user/utils/NicknameUtils';
|
||||
import * as ProfileDisplayUtils from '@app/features/user/utils/ProfileDisplayUtils';
|
||||
import {createMockProfile} from '@app/features/user/utils/ProfileUtils';
|
||||
import {COPIED_STATS_JSON_DESCRIPTOR} from '@app/features/voice/components/StatsForNerdsCopyDescriptors';
|
||||
import {copyVoiceDiagnostics} from '@app/features/voice/commands/VoiceDiagnosticsCommands';
|
||||
import MediaEngine from '@app/features/voice/engine/MediaEngineFacade';
|
||||
import {buildStatsForNerdsCopyPayload, collectStatsForNerdsSnapshot} from '@app/features/voice/utils/StatsForNerdsCopy';
|
||||
import {MEDIA_PROXY_PROFILE_BANNER_SIZE_POPOUT} from '@fluxer/constants/src/MediaProxyAssetSizes';
|
||||
import {StatusTypes} from '@fluxer/constants/src/StatusConstants';
|
||||
import type {MessageDescriptor} from '@lingui/core';
|
||||
@@ -462,12 +460,7 @@ export const UserAreaPopout = observer(() => {
|
||||
});
|
||||
}, [i18n]);
|
||||
const handleCopyStats = useCallback(() => {
|
||||
const data = collectStatsForNerdsSnapshot();
|
||||
void buildStatsForNerdsCopyPayload(data).then((payload) => {
|
||||
void navigator.clipboard.writeText(JSON.stringify(payload, null, 2)).then(() => {
|
||||
ToastCommands.createToast({type: 'success', children: i18n._(COPIED_STATS_JSON_DESCRIPTOR)});
|
||||
});
|
||||
});
|
||||
void copyVoiceDiagnostics(i18n);
|
||||
}, [i18n]);
|
||||
const handleCopyUserTag = useCallback(() => {
|
||||
if (!currentUser) {
|
||||
|
||||
@@ -1277,6 +1277,7 @@ const SKELETON_NAGBAR_ROW_SHAPES: Record<NagbarType, SkeletonNagbarRowShape> = {
|
||||
[NagbarType.PREMIUM_ONBOARDING]: {tone: SkeletonNagbarTone.BRAND, hasActions: true},
|
||||
[NagbarType.GIFT_INVENTORY]: {tone: SkeletonNagbarTone.BRAND, hasActions: true},
|
||||
[NagbarType.DESKTOP_DOWNLOAD]: {tone: SkeletonNagbarTone.BRAND, hasActions: true},
|
||||
[NagbarType.DESKTOP_UPDATE_READY]: {tone: SkeletonNagbarTone.BRAND, hasActions: true},
|
||||
[NagbarType.GUILD_MEMBERSHIP_CTA]: {tone: SkeletonNagbarTone.BRAND, hasActions: true},
|
||||
[NagbarType.VISIONARY_MFA]: {tone: SkeletonNagbarTone.BRAND, hasActions: true},
|
||||
[NagbarType.VOICE_SESSION_RESTORE]: {tone: SkeletonNagbarTone.VOICE, hasActions: true},
|
||||
|
||||
@@ -11,6 +11,7 @@ import Config from '@app/features/app/config/Config';
|
||||
import {isClientReconnecting} from '@app/features/app/state/ClientReadiness';
|
||||
import Initialization from '@app/features/app/state/Initialization';
|
||||
import RuntimeConfig from '@app/features/app/state/RuntimeConfig';
|
||||
import Updater from '@app/features/app/state/Updater';
|
||||
import Authentication from '@app/features/auth/state/Authentication';
|
||||
import Channels from '@app/features/channel/state/Channels';
|
||||
import DeveloperOptions from '@app/features/devtools/state/DeveloperOptions';
|
||||
@@ -230,6 +231,7 @@ export const useNagbarConditions = (): NagbarConditions => {
|
||||
const canShowLinuxInputAccess = NativePermission.shouldShowLinuxInputAccessNagbar;
|
||||
const canShowSoftwareEncoder = SoftwareEncoderWarning.showWarning;
|
||||
const canShowStreamerMode = StreamerMode.shouldShowNagbar;
|
||||
const canShowDesktopUpdateReady = Updater.shouldShowUpdateReadyNagbar;
|
||||
const canShowBuildEnvironment =
|
||||
!BUILD_ENVIRONMENT_HIDDEN_RELEASE_CHANNELS.has(Config.PUBLIC_RELEASE_CHANNEL) &&
|
||||
!nagbarState.buildEnvironmentDismissedThisSession;
|
||||
@@ -288,6 +290,7 @@ export const useNagbarConditions = (): NagbarConditions => {
|
||||
canShowLinuxInputAccess,
|
||||
canShowSoftwareEncoder,
|
||||
canShowStreamerMode,
|
||||
canShowDesktopUpdateReady,
|
||||
};
|
||||
};
|
||||
export const useActiveNagbars = (conditions: NagbarConditions): Array<NagbarState> => {
|
||||
@@ -407,6 +410,12 @@ export const useActiveNagbars = (conditions: NagbarConditions): Array<NagbarStat
|
||||
visible: conditions.canShowStreamerMode,
|
||||
dismissible: true,
|
||||
},
|
||||
{
|
||||
type: NagbarType.DESKTOP_UPDATE_READY,
|
||||
priority: -1.5,
|
||||
visible: conditions.canShowDesktopUpdateReady,
|
||||
dismissible: true,
|
||||
},
|
||||
];
|
||||
return selectVisibleNagbars(nagbars);
|
||||
}, [conditions]);
|
||||
|
||||
@@ -15,6 +15,7 @@ export const NagbarType = {
|
||||
PREMIUM_ONBOARDING: 'premium-onboarding',
|
||||
GIFT_INVENTORY: 'gift-inventory',
|
||||
DESKTOP_DOWNLOAD: 'desktop-download',
|
||||
DESKTOP_UPDATE_READY: 'desktop-update-ready',
|
||||
GUILD_MEMBERSHIP_CTA: 'guild-membership-cta',
|
||||
VISIONARY_MFA: 'visionary-mfa',
|
||||
VOICE_SESSION_RESTORE: 'voice-session-restore',
|
||||
@@ -50,6 +51,7 @@ export interface NagbarConditions {
|
||||
canShowPremiumOnboarding: boolean;
|
||||
canShowGiftInventory: boolean;
|
||||
canShowDesktopDownload: boolean;
|
||||
canShowDesktopUpdateReady: boolean;
|
||||
canShowGuildMembershipCta: boolean;
|
||||
canShowVisionaryMfa: boolean;
|
||||
canShowVoiceSessionRestore: boolean;
|
||||
|
||||
@@ -7,6 +7,7 @@ import {ConnectionNagbar} from '@app/features/app/components/layout/app_layout/n
|
||||
import {CorruptedInstallationNagbar} from '@app/features/app/components/layout/app_layout/nagbars/CorruptedInstallationNagbar';
|
||||
import {DesktopDownloadNagbar} from '@app/features/app/components/layout/app_layout/nagbars/DesktopDownloadNagbar';
|
||||
import {DesktopNotificationNagbar} from '@app/features/app/components/layout/app_layout/nagbars/DesktopNotificationNagbar';
|
||||
import {DesktopUpdateReadyNagbar} from '@app/features/app/components/layout/app_layout/nagbars/DesktopUpdateReadyNagbar';
|
||||
import {EmailVerificationNagbar} from '@app/features/app/components/layout/app_layout/nagbars/EmailVerificationNagbar';
|
||||
import {GiftInventoryNagbar} from '@app/features/app/components/layout/app_layout/nagbars/GiftInventoryNagbar';
|
||||
import {GuildMembershipCtaNagbar} from '@app/features/app/components/layout/app_layout/nagbars/GuildMembershipCtaNagbar';
|
||||
@@ -153,6 +154,14 @@ export const NagbarContainer: React.FC<NagbarContainerProps> = observer(({nagbar
|
||||
data-flx="app.app-layout.nagbar-container.desktop-download-nagbar"
|
||||
/>
|
||||
);
|
||||
case NagbarType.DESKTOP_UPDATE_READY:
|
||||
return (
|
||||
<DesktopUpdateReadyNagbar
|
||||
key={nagbar.type}
|
||||
isMobile={mobileLayout.enabled}
|
||||
data-flx="app.app-layout.nagbar-container.desktop-update-ready-nagbar"
|
||||
/>
|
||||
);
|
||||
case NagbarType.GUILD_MEMBERSHIP_CTA:
|
||||
return (
|
||||
<GuildMembershipCtaNagbar
|
||||
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {Nagbar} from '@app/features/app/components/layout/Nagbar';
|
||||
import {NagbarButton} from '@app/features/app/components/layout/NagbarButton';
|
||||
import {NagbarContent} from '@app/features/app/components/layout/NagbarContent';
|
||||
import {NAGBAR_TONES, NagbarToneKind} from '@app/features/app/components/layout/NagbarTones';
|
||||
import {PRODUCT_NAME} from '@app/features/app/config/I18nDisplayConstants';
|
||||
import Updater from '@app/features/app/state/Updater';
|
||||
import {
|
||||
DESKTOP_VERSION_HAS_BEEN_DOWNLOADED_DESCRIPTOR,
|
||||
RESTART_FLUXER_DESCRIPTOR,
|
||||
THE_DESKTOP_UPDATE_HAS_BEEN_DOWNLOADED_DESCRIPTOR,
|
||||
} from '@app/features/updater/commands/UpdaterModalCommands';
|
||||
import {useLingui} from '@lingui/react/macro';
|
||||
import {observer} from 'mobx-react-lite';
|
||||
|
||||
export const DesktopUpdateReadyNagbar = observer(({isMobile}: {isMobile: boolean}) => {
|
||||
const {i18n} = useLingui();
|
||||
const version = Updater.updateInfo.native.version;
|
||||
return (
|
||||
<Nagbar
|
||||
isMobile={isMobile}
|
||||
backgroundColor={NAGBAR_TONES[NagbarToneKind.BRAND].backgroundColor}
|
||||
textColor={NAGBAR_TONES[NagbarToneKind.BRAND].textColor}
|
||||
dismissible
|
||||
onDismiss={Updater.dismissUpdateReadyNagbar}
|
||||
data-flx="app.app-layout.nagbars.desktop-update-ready-nagbar.nagbar"
|
||||
>
|
||||
<NagbarContent
|
||||
isMobile={isMobile}
|
||||
onDismiss={Updater.dismissUpdateReadyNagbar}
|
||||
message={
|
||||
version
|
||||
? i18n._(DESKTOP_VERSION_HAS_BEEN_DOWNLOADED_DESCRIPTOR, {version, productName: PRODUCT_NAME})
|
||||
: i18n._(THE_DESKTOP_UPDATE_HAS_BEEN_DOWNLOADED_DESCRIPTOR, {productName: PRODUCT_NAME})
|
||||
}
|
||||
actions={
|
||||
<NagbarButton
|
||||
isMobile={isMobile}
|
||||
onClick={() => void Updater.applyUpdate()}
|
||||
data-flx="app.app-layout.nagbars.desktop-update-ready-nagbar.restart-button"
|
||||
>
|
||||
{i18n._(RESTART_FLUXER_DESCRIPTOR, {productName: PRODUCT_NAME})}
|
||||
</NagbarButton>
|
||||
}
|
||||
data-flx="app.app-layout.nagbars.desktop-update-ready-nagbar.nagbar-content"
|
||||
/>
|
||||
</Nagbar>
|
||||
);
|
||||
});
|
||||
@@ -3,6 +3,7 @@
|
||||
import * as Modal from '@app/features/app/components/dialogs/Modal';
|
||||
import styles from '@app/features/app/components/setup/SelfHostedSetupWizardGate.module.css';
|
||||
import {
|
||||
classifySetupUnauthorized,
|
||||
fetchInstanceConfig,
|
||||
type SetupBrandingAssetKind,
|
||||
testSmtpConfig,
|
||||
@@ -55,6 +56,7 @@ import {fileToBase64} from '@app/features/user/utils/AvatarUtils';
|
||||
import * as FormUtils from '@app/lib/forms';
|
||||
import {type ThemeType, ThemeTypes} from '@fluxer/constants/src/UserConstants';
|
||||
import type {InstanceConfigResponse} from '@fluxer/schema/src/domains/admin/AdminSchemas';
|
||||
import type {MessageDescriptor} from '@lingui/core';
|
||||
import {msg} from '@lingui/core/macro';
|
||||
import {useLingui} from '@lingui/react/macro';
|
||||
import {ArrowLeftIcon, ArrowRightIcon, CheckIcon, WrenchIcon} from '@phosphor-icons/react';
|
||||
@@ -92,6 +94,11 @@ const LOAD_ERROR_DESCRIPTOR = msg({
|
||||
message: 'Could not load the instance configuration. Try reloading the page.',
|
||||
comment: 'Error shown when the setup wizard fails to load the instance configuration.',
|
||||
});
|
||||
const ORIGIN_MISMATCH_DESCRIPTOR = msg({
|
||||
message:
|
||||
'The API is on a different origin than this page, so setup requests are sent without your session. Check the public origin and port this instance is configured with, then reload.',
|
||||
comment: 'Error shown when the setup wizard cannot load because the API origin differs from the page origin.',
|
||||
});
|
||||
const ASSET_UPLOAD_ERROR_DESCRIPTOR = msg({
|
||||
message: 'That image could not be used. Try a different file.',
|
||||
comment: 'Error shown when a branding image fails to upload in the setup wizard.',
|
||||
@@ -425,7 +432,7 @@ export const SelfHostedSetupWizardGate = observer(() => {
|
||||
const stepNavigationUnlockTimerRef = useRef<number | null>(null);
|
||||
|
||||
const [config, setConfig] = useState<InstanceConfigResponse | null>(null);
|
||||
const [loadError, setLoadError] = useState(false);
|
||||
const [loadError, setLoadError] = useState<MessageDescriptor | null>(null);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [submitError, setSubmitError] = useState<string | null>(null);
|
||||
const [stepNavigationLocked, setStepNavigationLocked] = useState(false);
|
||||
@@ -531,11 +538,11 @@ export const SelfHostedSetupWizardGate = observer(() => {
|
||||
}, [authStoreAuthenticated, forceUnauthenticatedSetup]);
|
||||
|
||||
const resetStaleSetupSession = useCallback(async () => {
|
||||
logger.warn('Instance config fetch returned 401 during setup; clearing stale local setup session');
|
||||
logger.warn('The setup session token was rejected. Clearing the stale local setup session.');
|
||||
setForceUnauthenticatedSetup(true);
|
||||
registerFormDraftsRef.current.clear();
|
||||
setConfig(null);
|
||||
setLoadError(false);
|
||||
setLoadError(null);
|
||||
setSubmitError(null);
|
||||
setSubmitting(false);
|
||||
setWizardSnapshot(createSetupWizardSnapshot());
|
||||
@@ -605,7 +612,7 @@ export const SelfHostedSetupWizardGate = observer(() => {
|
||||
useEffect(() => {
|
||||
if (!isAuthenticated || config) return;
|
||||
let cancelled = false;
|
||||
setLoadError(false);
|
||||
setLoadError(null);
|
||||
void (async () => {
|
||||
try {
|
||||
const next = await fetchInstanceConfig();
|
||||
@@ -613,12 +620,15 @@ export const SelfHostedSetupWizardGate = observer(() => {
|
||||
hydrateFromConfig(next);
|
||||
} catch (error) {
|
||||
if (cancelled) return;
|
||||
if (error instanceof HttpError && error.status === 401) {
|
||||
const cause =
|
||||
error instanceof HttpError && error.status === 401 ? await classifySetupUnauthorized() : 'unknown';
|
||||
if (cancelled) return;
|
||||
if (cause === 'stale_session') {
|
||||
await resetStaleSetupSession();
|
||||
return;
|
||||
}
|
||||
logger.error('Failed to load instance configuration', error);
|
||||
setLoadError(true);
|
||||
setLoadError(cause === 'origin_mismatch' ? ORIGIN_MISMATCH_DESCRIPTOR : LOAD_ERROR_DESCRIPTOR);
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
@@ -868,7 +878,7 @@ export const SelfHostedSetupWizardGate = observer(() => {
|
||||
role="alert"
|
||||
data-flx="app.self-hosted-setup-wizard-gate.load-error"
|
||||
>
|
||||
{i18n._(LOAD_ERROR_DESCRIPTOR)}
|
||||
{i18n._(loadError)}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {Endpoints} from '@app/features/app/constants/Endpoints';
|
||||
import SessionManager from '@app/features/platform/state/AuthSession';
|
||||
import {http} from '@app/features/platform/transport/RestTransport';
|
||||
import {Logger} from '@app/features/platform/utils/AppLogger';
|
||||
import type {
|
||||
BrandingAssetUploadRequest,
|
||||
InstanceConfigResponse,
|
||||
@@ -10,6 +12,8 @@ import type {
|
||||
InstanceEmailSmtpTestResponse,
|
||||
} from '@fluxer/schema/src/domains/admin/AdminSchemas';
|
||||
|
||||
const logger = new Logger('SetupWizardClient');
|
||||
|
||||
export type SetupBrandingAssetKind = BrandingAssetUploadRequest['kind'];
|
||||
|
||||
export async function fetchInstanceConfig(): Promise<InstanceConfigResponse> {
|
||||
@@ -35,3 +39,17 @@ export async function testSmtpConfig(body: InstanceEmailSmtpTestRequest): Promis
|
||||
const response = await http.post<InstanceEmailSmtpTestResponse>(Endpoints.ADMIN_INSTANCE_CONFIG_SMTP_TESTS, {body});
|
||||
return response.body;
|
||||
}
|
||||
|
||||
export type SetupUnauthorizedCause = 'stale_session' | 'origin_mismatch' | 'unknown';
|
||||
|
||||
export async function classifySetupUnauthorized(): Promise<SetupUnauthorizedCause> {
|
||||
if (!SessionManager.token) return 'unknown';
|
||||
if (!http.carriesAuthorization()) return 'origin_mismatch';
|
||||
try {
|
||||
const response = await http.get(Endpoints.USER_ME, {mode: 'silent'});
|
||||
return response.status === 401 ? 'stale_session' : 'unknown';
|
||||
} catch (error) {
|
||||
logger.warn('Could not confirm whether the setup session is still valid', error);
|
||||
return 'unknown';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
// @vitest-environment happy-dom
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {installVoiceMenuTestBootstrap} from '@app/features/ui/action_menu/items/__fixtures__/VoiceMenuTestBootstrap';
|
||||
import type {UpdaterEvent} from '@app/types/electron.d';
|
||||
import {afterEach, describe, expect, test, vi} from 'vitest';
|
||||
|
||||
const {pushUpdateReadyModal} = vi.hoisted(() => ({pushUpdateReadyModal: vi.fn()}));
|
||||
|
||||
vi.mock('@app/features/updater/commands/UpdaterModalCommands', () => ({
|
||||
pushDesktopUpdateDownloadFailedModal: vi.fn(),
|
||||
pushDesktopUpdateInstallFailedModal: vi.fn(),
|
||||
pushManualUpdateAvailableModal: vi.fn(),
|
||||
pushUnsupportedUpdateModal: vi.fn(),
|
||||
pushUpdateAvailableModal: vi.fn(),
|
||||
pushUpdateCheckFailedModal: vi.fn(),
|
||||
pushUpdateReadyModal,
|
||||
pushUpToDateModal: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@lingui/core/macro', () => ({
|
||||
msg: (descriptor: {message: string}) => descriptor,
|
||||
}));
|
||||
|
||||
vi.mock('@app/features/platform/utils/ClientInfo', () => ({
|
||||
getClientInfo: () =>
|
||||
Promise.resolve({desktopVersion: '1.0.0', desktopChannel: 'canary', desktopArch: 'x64', arch: 'x64'}),
|
||||
}));
|
||||
|
||||
installVoiceMenuTestBootstrap();
|
||||
|
||||
let nativeEventListener: ((event: UpdaterEvent) => void) | null = null;
|
||||
let onUpdaterCheck: (() => void) | null = null;
|
||||
let loadedUpdater: {dispose: () => void} | null = null;
|
||||
|
||||
function installElectronApi(): void {
|
||||
nativeEventListener = null;
|
||||
onUpdaterCheck = null;
|
||||
(window as unknown as {electron: unknown}).electron = {
|
||||
platform: 'win32',
|
||||
buildChannel: 'canary',
|
||||
onUpdaterEvent: (listener: (event: UpdaterEvent) => void) => {
|
||||
nativeEventListener = listener;
|
||||
return () => {
|
||||
nativeEventListener = null;
|
||||
};
|
||||
},
|
||||
updaterCheck: () => {
|
||||
onUpdaterCheck?.();
|
||||
return Promise.resolve();
|
||||
},
|
||||
updaterDownload: () => Promise.resolve(),
|
||||
updaterInstall: () => Promise.resolve(),
|
||||
};
|
||||
}
|
||||
|
||||
function emit(event: UpdaterEvent): void {
|
||||
if (!nativeEventListener) throw new Error('Updater never subscribed to native updater events');
|
||||
nativeEventListener(event);
|
||||
}
|
||||
|
||||
async function loadUpdater() {
|
||||
vi.resetModules();
|
||||
installElectronApi();
|
||||
const {default: Updater} = await import('@app/features/app/state/Updater');
|
||||
loadedUpdater = Updater;
|
||||
await vi.waitFor(() => {
|
||||
expect(nativeEventListener).not.toBeNull();
|
||||
expect(Updater.lastCheckedAt).not.toBeNull();
|
||||
});
|
||||
pushUpdateReadyModal.mockClear();
|
||||
return Updater;
|
||||
}
|
||||
|
||||
function emitUserDownloadCompletion(version: string): void {
|
||||
emit({type: 'available', context: 'user', version, downloadSize: 1000, downloadStarted: true});
|
||||
emit({type: 'downloaded', context: 'user', version});
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
loadedUpdater?.dispose();
|
||||
loadedUpdater = null;
|
||||
});
|
||||
|
||||
describe('updater update-ready surface', () => {
|
||||
test('does not push a blocking modal when a user-initiated download finishes outside a check', async () => {
|
||||
const Updater = await loadUpdater();
|
||||
emitUserDownloadCompletion('2.0.0');
|
||||
expect(Updater.nativeUpdateReady).toBe(true);
|
||||
expect(pushUpdateReadyModal).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('announces the ready update through a dismissible nagbar instead', async () => {
|
||||
const Updater = await loadUpdater();
|
||||
expect(Updater.shouldShowUpdateReadyNagbar).toBe(false);
|
||||
emitUserDownloadCompletion('2.0.0');
|
||||
expect(Updater.shouldShowUpdateReadyNagbar).toBe(true);
|
||||
Updater.dismissUpdateReadyNagbar();
|
||||
expect(Updater.shouldShowUpdateReadyNagbar).toBe(false);
|
||||
emit({type: 'downloaded', context: 'background', version: '2.1.0'});
|
||||
expect(Updater.shouldShowUpdateReadyNagbar).toBe(true);
|
||||
});
|
||||
|
||||
test('still answers a user-initiated check with the update ready modal', async () => {
|
||||
const Updater = await loadUpdater();
|
||||
emitUserDownloadCompletion('2.0.0');
|
||||
pushUpdateReadyModal.mockClear();
|
||||
onUpdaterCheck = () => emit({type: 'available', context: 'user', version: '2.0.0', downloadStarted: false});
|
||||
await Updater.checkForUpdates(true, true);
|
||||
expect(pushUpdateReadyModal).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -131,6 +131,7 @@ class Updater {
|
||||
private backgroundCheckInterval: number | null = null;
|
||||
private backgroundCheckCleanups: Array<() => void> = [];
|
||||
private unsubscribeNativeEvents: (() => void) | null = null;
|
||||
private updateReadyNagbarDismissedVersion: string | null = null;
|
||||
|
||||
constructor() {
|
||||
makeAutoObservable(this, {}, {autoBind: true});
|
||||
@@ -193,6 +194,18 @@ class Updater {
|
||||
return this.updateInfo.native.available && this.updateInfo.native.downloaded;
|
||||
}
|
||||
|
||||
get shouldShowUpdateReadyNagbar(): boolean {
|
||||
return (
|
||||
this.nativeUpdateReady &&
|
||||
!this.updateInfo.native.installing &&
|
||||
this.updateReadyNagbarDismissedVersion !== this.updateReadyNagbarVersionKey
|
||||
);
|
||||
}
|
||||
|
||||
private get updateReadyNagbarVersionKey(): string {
|
||||
return this.updateInfo.native.version ?? 'unknown';
|
||||
}
|
||||
|
||||
get nativeDownloadInFlight(): boolean {
|
||||
return this.updateInfo.native.downloading && !this.updateInfo.native.downloaded;
|
||||
}
|
||||
@@ -348,9 +361,6 @@ class Updater {
|
||||
break;
|
||||
}
|
||||
this.transition({type: 'native.downloaded', version: event.version ?? null});
|
||||
if (shouldShowImmediateUserResult) {
|
||||
this.showCurrentUpdateState();
|
||||
}
|
||||
break;
|
||||
case 'progress':
|
||||
if (!shouldSurfaceNativeDesktopUpdate || !this.nativeDownloadProgressSupported) {
|
||||
@@ -661,6 +671,10 @@ class Updater {
|
||||
}
|
||||
}
|
||||
|
||||
dismissUpdateReadyNagbar(): void {
|
||||
this.updateReadyNagbarDismissedVersion = this.updateReadyNagbarVersionKey;
|
||||
}
|
||||
|
||||
reset(): void {
|
||||
this.transition({type: 'reset'});
|
||||
}
|
||||
|
||||
+1
@@ -83,6 +83,7 @@ const EMAIL_VALIDATION_CODES = new Set<string>([
|
||||
ValidationErrorCodes.INVALID_EMAIL_FORMAT,
|
||||
ValidationErrorCodes.INVALID_EMAIL_LOCAL_PART,
|
||||
ValidationErrorCodes.INVALID_EMAIL_ADDRESS,
|
||||
ValidationErrorCodes.EMAIL_DOMAIN_CANNOT_RECEIVE_MAIL,
|
||||
]);
|
||||
const CODE_SESSION_VALIDATION_CODES = new Set<string>([
|
||||
ValidationErrorCodes.EMAIL_TOKEN_EXPIRED,
|
||||
|
||||
@@ -99,9 +99,10 @@ export const DiscoveryPage = observer(function DiscoveryPage() {
|
||||
return Math.min(SKELETON_DISCOVERY_MAX_COLUMNS, Math.max(1, columnsThatFit));
|
||||
}, [containerWidth, zoomLevel]);
|
||||
const guilds = Discovery.guilds;
|
||||
const loadedCount = Discovery.loadedCount;
|
||||
const searchActive = Discovery.query.length > 0;
|
||||
const rowCount = columns > 0 ? Math.ceil(guilds.length / columns) : 0;
|
||||
const hasMore = guilds.length < Discovery.total;
|
||||
const hasMore = loadedCount < Discovery.total;
|
||||
const virtualizer = useVirtualizer({
|
||||
count: rowCount,
|
||||
getScrollElement: () => scrollerRef.current?.getViewportElement() ?? null,
|
||||
@@ -114,10 +115,10 @@ export const DiscoveryPage = observer(function DiscoveryPage() {
|
||||
return;
|
||||
}
|
||||
void Discovery.search({
|
||||
offset: guilds.length,
|
||||
offset: loadedCount,
|
||||
limit: PAGE_SIZE,
|
||||
});
|
||||
}, [guilds.length, hasMore]);
|
||||
}, [loadedCount, hasMore]);
|
||||
useEffect(() => {
|
||||
const items = virtualizer.getVirtualItems();
|
||||
const lastItem = items[items.length - 1];
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import type {DiscoveryGuild, DiscoverySearchResponse} from '@app/features/discovery/commands/DiscoveryCommands';
|
||||
import * as DiscoveryCommands from '@app/features/discovery/commands/DiscoveryCommands';
|
||||
import {beforeEach, describe, expect, test, vi} from 'vitest';
|
||||
|
||||
import Discovery from './Discovery';
|
||||
|
||||
vi.mock('@app/features/discovery/commands/DiscoveryCommands', () => ({
|
||||
searchGuilds: vi.fn(),
|
||||
getCategories: vi.fn(),
|
||||
}));
|
||||
|
||||
function guild(id: string, memberCount: number): DiscoveryGuild {
|
||||
return {
|
||||
id,
|
||||
name: `Guild ${id}`,
|
||||
icon: null,
|
||||
banner: null,
|
||||
description: null,
|
||||
category_type: 0,
|
||||
primary_language: null,
|
||||
custom_tags: [],
|
||||
member_count: memberCount,
|
||||
online_count: 0,
|
||||
features: [],
|
||||
verification_level: 0,
|
||||
};
|
||||
}
|
||||
|
||||
function page(guilds: Array<DiscoveryGuild>, total: number): DiscoverySearchResponse {
|
||||
return {guilds, total, categoryCounts: null};
|
||||
}
|
||||
|
||||
describe('Discovery.search', () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(DiscoveryCommands.searchGuilds).mockReset();
|
||||
Discovery.reset();
|
||||
});
|
||||
|
||||
test('does not append a guild that a previous page already returned', async () => {
|
||||
vi.mocked(DiscoveryCommands.searchGuilds)
|
||||
.mockResolvedValueOnce(page([guild('1', 50), guild('2', 40)], 4))
|
||||
.mockResolvedValueOnce(page([guild('2', 40), guild('3', 30)], 4));
|
||||
await Discovery.search({limit: 2, offset: 0});
|
||||
await Discovery.search({limit: 2, offset: Discovery.loadedCount});
|
||||
expect(Discovery.guilds.map((entry) => entry.id)).toEqual(['1', '2', '3']);
|
||||
});
|
||||
|
||||
test('advances the pagination offset by what the server returned, not by what survived deduping', async () => {
|
||||
vi.mocked(DiscoveryCommands.searchGuilds)
|
||||
.mockResolvedValueOnce(page([guild('1', 50), guild('2', 40)], 4))
|
||||
.mockResolvedValueOnce(page([guild('2', 40), guild('3', 30)], 4));
|
||||
await Discovery.search({limit: 2, offset: 0});
|
||||
await Discovery.search({limit: 2, offset: Discovery.loadedCount});
|
||||
expect(Discovery.loadedCount).toBe(4);
|
||||
});
|
||||
});
|
||||
@@ -8,6 +8,7 @@ const DEFAULT_DISCOVERY_PAGE_SIZE = 36;
|
||||
|
||||
class Discovery {
|
||||
guilds: Array<DiscoveryGuild> = [];
|
||||
loadedCount = 0;
|
||||
total = 0;
|
||||
loading = false;
|
||||
error = false;
|
||||
@@ -53,6 +54,7 @@ class Discovery {
|
||||
this.error = false;
|
||||
if (offset === 0 && searchModeChanged) {
|
||||
this.guilds = [];
|
||||
this.loadedCount = 0;
|
||||
this.total = 0;
|
||||
}
|
||||
this.query = query;
|
||||
@@ -78,8 +80,10 @@ class Discovery {
|
||||
if (offset === 0) {
|
||||
this.guilds = result.guilds;
|
||||
} else {
|
||||
this.guilds = [...this.guilds, ...result.guilds];
|
||||
const seen = new Set(this.guilds.map((guild) => guild.id));
|
||||
this.guilds = [...this.guilds, ...result.guilds.filter((guild) => !seen.has(guild.id))];
|
||||
}
|
||||
this.loadedCount = offset + result.guilds.length;
|
||||
this.total = result.total;
|
||||
this.categoryCounts = result.categoryCounts;
|
||||
this.loading = false;
|
||||
@@ -136,6 +140,7 @@ class Discovery {
|
||||
reset(): void {
|
||||
this.activeSearchToken += 1;
|
||||
this.guilds = [];
|
||||
this.loadedCount = 0;
|
||||
this.total = 0;
|
||||
this.categoryCounts = null;
|
||||
this.loading = false;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1305,6 +1305,9 @@
|
||||
{
|
||||
"msgid": "Add a Klipy API key to enable GIF search at runtime."
|
||||
},
|
||||
{
|
||||
"msgid": "Add new reactions to messages in this channel."
|
||||
},
|
||||
{
|
||||
"msgid": "Adds a manual audio source picker to the stream settings menu. Without it, a shared window already captures that app and a shared screen captures your desktop."
|
||||
},
|
||||
@@ -1353,9 +1356,18 @@
|
||||
{
|
||||
"msgid": "Automatic ({codec})"
|
||||
},
|
||||
{
|
||||
"msgid": "Ban members from the community, with the option to delete their recent messages."
|
||||
},
|
||||
{
|
||||
"msgid": "Banned {targetUserTag} from the community"
|
||||
},
|
||||
{
|
||||
"msgid": "Be heard over other members in this channel while holding the push to talk (priority) keybind."
|
||||
},
|
||||
{
|
||||
"msgid": "Be heard over other members while holding the push to talk (priority) keybind."
|
||||
},
|
||||
{
|
||||
"msgid": "Bluesky connections"
|
||||
},
|
||||
@@ -1377,6 +1389,12 @@
|
||||
{
|
||||
"msgid": "Change stream"
|
||||
},
|
||||
{
|
||||
"msgid": "Change which voice region a voice channel uses."
|
||||
},
|
||||
{
|
||||
"msgid": "Change which voice region this channel uses."
|
||||
},
|
||||
{
|
||||
"msgid": "Choices"
|
||||
},
|
||||
@@ -1443,9 +1461,15 @@
|
||||
{
|
||||
"msgid": "Custom emoji {emojiName} from {communityName}"
|
||||
},
|
||||
{
|
||||
"msgid": "Deafen other members in this voice channel, so they cannot hear or speak."
|
||||
},
|
||||
{
|
||||
"msgid": "Decide how premium limits apply to people on this instance."
|
||||
},
|
||||
{
|
||||
"msgid": "Delete other members' messages in this channel. Pinning is controlled separately."
|
||||
},
|
||||
{
|
||||
"msgid": "Delete user"
|
||||
},
|
||||
@@ -1467,6 +1491,12 @@
|
||||
{
|
||||
"msgid": "Disable direct messages and friend requests"
|
||||
},
|
||||
{
|
||||
"msgid": "Drag members between voice channels they can access, and disconnect them from voice."
|
||||
},
|
||||
{
|
||||
"msgid": "Drag members out of this voice channel into channels they can access, and disconnect them from voice."
|
||||
},
|
||||
{
|
||||
"msgid": "Duplicate role"
|
||||
},
|
||||
@@ -1560,6 +1590,9 @@
|
||||
{
|
||||
"msgid": "If you can't verify by SMS"
|
||||
},
|
||||
{
|
||||
"msgid": "Ignore this channel's slowmode cooldown."
|
||||
},
|
||||
{
|
||||
"msgid": "Image assets"
|
||||
},
|
||||
@@ -1575,9 +1608,21 @@
|
||||
{
|
||||
"msgid": "Input level"
|
||||
},
|
||||
{
|
||||
"msgid": "Invite new people to the community with a link to this channel."
|
||||
},
|
||||
{
|
||||
"msgid": "Invite new people to the community with an invite link."
|
||||
},
|
||||
{
|
||||
"msgid": "Italic"
|
||||
},
|
||||
{
|
||||
"msgid": "Join this voice channel and hear others. Denying it along with View channel for {everyoneMention} makes the channel private."
|
||||
},
|
||||
{
|
||||
"msgid": "Join voice channels and hear others."
|
||||
},
|
||||
{
|
||||
"msgid": "Keep Neko still"
|
||||
},
|
||||
@@ -1611,12 +1656,21 @@
|
||||
{
|
||||
"msgid": "May cause compatibility issues for viewers. We’re working on improving this."
|
||||
},
|
||||
{
|
||||
"msgid": "Mention everyone or any role in this channel (even if the role isn't set to be mentionable)."
|
||||
},
|
||||
{
|
||||
"msgid": "Microphone ({deviceLabel})"
|
||||
},
|
||||
{
|
||||
"msgid": "Mirror tiers"
|
||||
},
|
||||
{
|
||||
"msgid": "Mute other members in this voice channel for everyone."
|
||||
},
|
||||
{
|
||||
"msgid": "Mute other members in voice channels for everyone."
|
||||
},
|
||||
{
|
||||
"msgid": "My community"
|
||||
},
|
||||
@@ -1680,6 +1734,12 @@
|
||||
{
|
||||
"msgid": "Pin Neko to the top-right of the chat input instead of following your cursor"
|
||||
},
|
||||
{
|
||||
"msgid": "Pin or unpin any message in this channel."
|
||||
},
|
||||
{
|
||||
"msgid": "Pin or unpin any message."
|
||||
},
|
||||
{
|
||||
"msgid": "Pop out user"
|
||||
},
|
||||
@@ -1725,12 +1785,21 @@
|
||||
{
|
||||
"msgid": "Public registration is closed."
|
||||
},
|
||||
{
|
||||
"msgid": "Read messages sent before they opened a channel. Without it, they only see messages that arrive while it is open."
|
||||
},
|
||||
{
|
||||
"msgid": "Read messages sent in this channel before they opened it. Without it, they only see messages that arrive while it is open."
|
||||
},
|
||||
{
|
||||
"msgid": "Reason (optional)."
|
||||
},
|
||||
{
|
||||
"msgid": "Registration"
|
||||
},
|
||||
{
|
||||
"msgid": "Remove members from the community. They can rejoin with a new invite."
|
||||
},
|
||||
{
|
||||
"msgid": "Removed timeout from {targetUserTag}"
|
||||
},
|
||||
@@ -1848,12 +1917,21 @@
|
||||
{
|
||||
"msgid": "Security key PIN form"
|
||||
},
|
||||
{
|
||||
"msgid": "See this channel. Denying it for {everyoneMention} makes the channel private."
|
||||
},
|
||||
{
|
||||
"msgid": "Self-host setup"
|
||||
},
|
||||
{
|
||||
"msgid": "Send file messages in order"
|
||||
},
|
||||
{
|
||||
"msgid": "Send messages in channels."
|
||||
},
|
||||
{
|
||||
"msgid": "Send messages in this channel."
|
||||
},
|
||||
{
|
||||
"msgid": "Sequential send"
|
||||
},
|
||||
@@ -1896,6 +1974,12 @@
|
||||
{
|
||||
"msgid": "Show connection volume controls"
|
||||
},
|
||||
{
|
||||
"msgid": "Show embedded previews for links they send in this channel."
|
||||
},
|
||||
{
|
||||
"msgid": "Show embedded previews for links they send."
|
||||
},
|
||||
{
|
||||
"msgid": "Show fewer filters"
|
||||
},
|
||||
@@ -2010,12 +2094,30 @@
|
||||
{
|
||||
"msgid": "Turn off audio sharing for this source or try again in a moment."
|
||||
},
|
||||
{
|
||||
"msgid": "Turn on a camera or share a screen in this voice channel."
|
||||
},
|
||||
{
|
||||
"msgid": "Unable to send verification code"
|
||||
},
|
||||
{
|
||||
"msgid": "Unavailable"
|
||||
},
|
||||
{
|
||||
"msgid": "Upload files and media in messages."
|
||||
},
|
||||
{
|
||||
"msgid": "Upload files and media in this channel."
|
||||
},
|
||||
{
|
||||
"msgid": "Use emoji from other communities in this channel."
|
||||
},
|
||||
{
|
||||
"msgid": "Use stickers from other communities in this channel."
|
||||
},
|
||||
{
|
||||
"msgid": "Use stickers from other communities."
|
||||
},
|
||||
{
|
||||
"msgid": "Use system-wide push-to-talk and shortcuts."
|
||||
},
|
||||
@@ -2055,6 +2157,9 @@
|
||||
{
|
||||
"msgid": "Who can join"
|
||||
},
|
||||
{
|
||||
"msgid": "Without this permission, push-to-talk is required in this channel."
|
||||
},
|
||||
{
|
||||
"msgid": "Wordmark"
|
||||
},
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,113 @@
|
||||
// @vitest-environment happy-dom
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {installVoiceMenuTestBootstrap} from '@app/features/ui/action_menu/items/__fixtures__/VoiceMenuTestBootstrap';
|
||||
import type {GuildMemberData} from '@fluxer/schema/src/domains/guild/GuildMemberSchemas';
|
||||
import type {Guild} from '@fluxer/schema/src/domains/guild/GuildResponseSchemas';
|
||||
import {describe, expect, it, vi} from 'vitest';
|
||||
|
||||
vi.mock('@lingui/core/macro', () => {
|
||||
const descriptor = (value: unknown): unknown => (typeof value === 'string' ? {message: value} : value);
|
||||
return {msg: descriptor, t: descriptor, plural: () => '', select: () => '', selectOrdinal: () => ''};
|
||||
});
|
||||
vi.mock('@app/features/gateway/transport/GatewayConnection', () => ({default: {socket: null}}));
|
||||
vi.mock('@app/features/channel/state/Channels', () => ({default: {getChannel: () => null}}));
|
||||
vi.mock('@app/features/guild/state/Guilds', () => ({
|
||||
default: {
|
||||
getGuild: (guildId: string) => ({
|
||||
id: guildId,
|
||||
disabledOperations: 0,
|
||||
roles: {
|
||||
'10': {id: '10', permissions: 0n, position: 0},
|
||||
'20': {id: '20', permissions: 0n, position: 5},
|
||||
'30': {id: '30', permissions: 0n, position: 9},
|
||||
},
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
installVoiceMenuTestBootstrap();
|
||||
|
||||
const {canManageTargetUser} = await import('@app/features/permissions/utils/PermissionUtils');
|
||||
const {default: GuildMembers} = await import('@app/features/member/state/GuildMembers');
|
||||
const {default: MemberSidebar} = await import('@app/features/member/state/MemberSidebar');
|
||||
|
||||
const MODERATOR_ROLE = {id: '20', permissions: 0n, position: 5};
|
||||
|
||||
let nextGuildId = 0;
|
||||
|
||||
function makeMember(userId: string, roles: Array<string>, nick: string | null = null): GuildMemberData {
|
||||
return {
|
||||
user: {
|
||||
id: userId,
|
||||
username: `user-${userId}`,
|
||||
discriminator: '0001',
|
||||
global_name: null,
|
||||
avatar: null,
|
||||
avatar_color: null,
|
||||
flags: 0,
|
||||
},
|
||||
nick,
|
||||
roles,
|
||||
joined_at: '2026-01-01T00:00:00.000Z',
|
||||
};
|
||||
}
|
||||
|
||||
function makeGuild(guildId: string): Guild {
|
||||
return {id: guildId, owner_id: 'owner'} as unknown as Guild;
|
||||
}
|
||||
|
||||
function syncMemberList(guildId: string, channelId: string, members: Array<GuildMemberData>): void {
|
||||
MemberSidebar.subscribeToChannel(guildId, channelId, [[0, 99]]);
|
||||
MemberSidebar.handleListUpdate({
|
||||
guildId,
|
||||
listId: 'everyone',
|
||||
channelId,
|
||||
memberCount: members.length,
|
||||
onlineCount: members.length,
|
||||
groups: [{id: 'online', count: members.length}],
|
||||
ops: [
|
||||
{
|
||||
op: 'SYNC',
|
||||
range: [0, members.length],
|
||||
items: [{group: {id: 'online', count: members.length}}, ...members.map((member) => ({member}))],
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
function setupGuild(members: Array<GuildMemberData>): string {
|
||||
const guildId = `guild-${++nextGuildId}`;
|
||||
syncMemberList(guildId, `channel-${guildId}`, members);
|
||||
return guildId;
|
||||
}
|
||||
|
||||
describe('MemberSidebar member list hydration', () => {
|
||||
it('makes membership known for members that only ever appeared in the member list', () => {
|
||||
const guildId = setupGuild([makeMember('target-low', []), makeMember('target-high', ['30'])]);
|
||||
expect(GuildMembers.isMembershipKnown(guildId, 'target-low')).toBe(true);
|
||||
expect(GuildMembers.getMember(guildId, 'target-high')?.roles.has('30')).toBe(true);
|
||||
});
|
||||
|
||||
it('lets the role hierarchy check pass for a sidebar member who has never posted', () => {
|
||||
const guildId = setupGuild([makeMember('target-low', [])]);
|
||||
expect(canManageTargetUser(makeGuild(guildId), 'me', MODERATOR_ROLE, 'target-low')).toBe(true);
|
||||
});
|
||||
|
||||
it('still refuses a sidebar member who outranks you', () => {
|
||||
const guildId = setupGuild([makeMember('target-high', ['30'])]);
|
||||
expect(canManageTargetUser(makeGuild(guildId), 'me', MODERATOR_ROLE, 'target-high')).toBe(false);
|
||||
});
|
||||
|
||||
it('does not create members for group header rows', () => {
|
||||
const guildId = setupGuild([makeMember('target-low', []), makeMember('target-high', ['30'])]);
|
||||
expect(GuildMembers.getMemberCount(guildId)).toBe(2);
|
||||
});
|
||||
|
||||
it('leaves an already known member untouched', () => {
|
||||
const guildId = `guild-${++nextGuildId}`;
|
||||
GuildMembers.hydrateIfMissing(guildId, makeMember('target-low', ['20'], 'authoritative'));
|
||||
syncMemberList(guildId, `channel-${guildId}`, [makeMember('target-low', [], 'stale')]);
|
||||
expect(GuildMembers.getMember(guildId, 'target-low')?.nick).toBe('authoritative');
|
||||
});
|
||||
});
|
||||
@@ -5,6 +5,7 @@ import Channels from '@app/features/channel/state/Channels';
|
||||
import GatewayConnection from '@app/features/gateway/transport/GatewayConnection';
|
||||
import Guilds from '@app/features/guild/state/Guilds';
|
||||
import {GuildMember} from '@app/features/member/models/GuildMember';
|
||||
import GuildMembers from '@app/features/member/state/GuildMembers';
|
||||
import {getHydratedMemberListRangesFromNormalized} from '@app/features/member/utils/MemberListHydration';
|
||||
import {deriveMemberListIdentity} from '@app/features/member/utils/MemberListIdentity';
|
||||
import {
|
||||
@@ -695,6 +696,7 @@ class MemberSidebar {
|
||||
}
|
||||
userIdRowCounts.set(userId, (userIdRowCounts.get(userId) ?? 0) + 1);
|
||||
newMembersByUserId.set(userId, member);
|
||||
GuildMembers.hydrateIfMissing(guildId, member);
|
||||
const memberItem = this.convertItem(guildId, row);
|
||||
if (memberItem) {
|
||||
newItems.set(rowIndex, memberItem);
|
||||
|
||||
@@ -61,9 +61,9 @@ const MANAGE_WEBHOOKS_PERMISSION_DESCRIPTION_DESCRIPTOR = msg({
|
||||
'Permission description in the role/permission editor for the Manage Webhooks permission, community-wide scope. Refers to outbound integration webhooks.',
|
||||
});
|
||||
const SEND_TTS_MESSAGES_PERMISSION_DESCRIPTION_DESCRIPTOR = msg({
|
||||
message: 'Send text-to-speech messages.',
|
||||
message: 'Send text-to-speech messages with /tts. Members who turned text-to-speech on hear them read aloud.',
|
||||
comment:
|
||||
'Permission description in the role/permission editor for the Send TTS Messages permission. Keep "text-to-speech" spelled out for clarity.',
|
||||
'Permission description in the role/permission editor for the Send TTS Messages permission. Keep "text-to-speech" spelled out for clarity. "/tts" is a literal command prefix typed in the message box and must not be translated.',
|
||||
});
|
||||
const MANAGE_MESSAGES_PERMISSION_DESCRIPTION_DESCRIPTOR = msg({
|
||||
message: "Delete other members' messages. Pinning is controlled separately.",
|
||||
@@ -106,9 +106,91 @@ const USE_VOICE_ACTIVITY_PERMISSION_DESCRIPTION_DESCRIPTOR = msg({
|
||||
'Permission description in the role/permission editor for the Use Voice Activity permission. Explains the inverse: without it, the user must use push-to-talk in voice channels.',
|
||||
});
|
||||
const MOVE_MEMBERS_PERMISSION_DESCRIPTION_DESCRIPTOR = msg({
|
||||
message: 'Drag members between channels they can access.',
|
||||
message: 'Drag members between voice channels they can access, and disconnect them from voice.',
|
||||
comment:
|
||||
'Permission description in the role/permission editor for the Move Members permission. The target channel must already be accessible to the member being moved.',
|
||||
'Permission description in the role/permission editor for the Move Members permission. The target channel must already be accessible to the member being moved. This permission also covers forcing a member out of voice entirely.',
|
||||
});
|
||||
const CREATE_INVITE_LINKS_PERMISSION_DESCRIPTION_DESCRIPTOR = msg({
|
||||
message: 'Invite new people to the community with an invite link.',
|
||||
comment:
|
||||
'Permission description in the role/permission editor for the Create Invite Links permission. Covers links that let someone outside the community join it.',
|
||||
});
|
||||
const KICK_MEMBERS_PERMISSION_DESCRIPTION_DESCRIPTOR = msg({
|
||||
message: 'Remove members from the community. They can rejoin with a new invite.',
|
||||
comment:
|
||||
'Permission description in the role/permission editor for the Kick Members permission. Note that a kick is not permanent, unlike a ban.',
|
||||
});
|
||||
const BAN_MEMBERS_PERMISSION_DESCRIPTION_DESCRIPTOR = msg({
|
||||
message: 'Ban members from the community, with the option to delete their recent messages.',
|
||||
comment:
|
||||
'Permission description in the role/permission editor for the Ban Members permission. A ban keeps the member out until it is lifted, and the moderator chooses how much recent message history to delete.',
|
||||
});
|
||||
const VIEW_CHANNEL_GUILD_PERMISSION_DESCRIPTION_DESCRIPTOR = msg({
|
||||
message: 'See channels by default. Channels that deny it stay hidden.',
|
||||
comment:
|
||||
'Permission description in the role/permission editor for the View Channel permission at community scope. Per-channel overwrites still decide access for individual channels.',
|
||||
});
|
||||
const SEND_MESSAGES_GUILD_PERMISSION_DESCRIPTION_DESCRIPTOR = msg({
|
||||
message: 'Send messages in channels.',
|
||||
comment: 'Permission description in the role/permission editor for the Send Messages permission at community scope.',
|
||||
});
|
||||
const PIN_MESSAGES_GUILD_PERMISSION_DESCRIPTION_DESCRIPTOR = msg({
|
||||
message: 'Pin or unpin any message.',
|
||||
comment:
|
||||
'Permission description in the role/permission editor for the Pin Messages permission at community scope. Covers messages from anyone, not only their own.',
|
||||
});
|
||||
const EMBED_LINKS_GUILD_PERMISSION_DESCRIPTION_DESCRIPTOR = msg({
|
||||
message: 'Show embedded previews for links they send.',
|
||||
comment:
|
||||
'Permission description in the role/permission editor for the Embed Links permission at community scope. The preview is the card rendered under a message that contains a link.',
|
||||
});
|
||||
const ATTACH_FILES_GUILD_PERMISSION_DESCRIPTION_DESCRIPTOR = msg({
|
||||
message: 'Upload files and media in messages.',
|
||||
comment: 'Permission description in the role/permission editor for the Attach Files permission at community scope.',
|
||||
});
|
||||
const READ_MESSAGE_HISTORY_GUILD_PERMISSION_DESCRIPTION_DESCRIPTOR = msg({
|
||||
message:
|
||||
'Read messages sent before they opened a channel. Without it, they only see messages that arrive while it is open.',
|
||||
comment:
|
||||
'Permission description in the role/permission editor for the Read Message History permission at community scope. Without the permission the member sees only messages that arrive while the channel is open in front of them.',
|
||||
});
|
||||
const USE_EXTERNAL_STICKERS_PERMISSION_DESCRIPTION_DESCRIPTOR = msg({
|
||||
message: 'Use stickers from other communities.',
|
||||
comment:
|
||||
'Permission description in the role/permission editor for the Use External Stickers permission. "Other communities" means stickers uploaded in a different Fluxer community.',
|
||||
});
|
||||
const CONNECT_GUILD_PERMISSION_DESCRIPTION_DESCRIPTOR = msg({
|
||||
message: 'Join voice channels and hear others.',
|
||||
comment: 'Permission description in the role/permission editor for the Connect permission at community scope.',
|
||||
});
|
||||
const SPEAK_GUILD_PERMISSION_DESCRIPTION_DESCRIPTOR = msg({
|
||||
message: 'Talk in voice channels. Without it, members stay muted until someone with Mute members unmutes them.',
|
||||
comment:
|
||||
'Permission description in the role/permission editor for the Speak permission at community scope. "Mute members" is another permission in this same editor, so match the wording used for its name.',
|
||||
});
|
||||
const STREAM_VIDEO_GUILD_PERMISSION_DESCRIPTION_DESCRIPTOR = msg({
|
||||
message: 'Turn on a camera or share a screen in voice channels.',
|
||||
comment:
|
||||
'Permission description in the role/permission editor for the Stream Video permission at community scope. Covers both the webcam and screen sharing.',
|
||||
});
|
||||
const PRIORITY_SPEAKER_GUILD_PERMISSION_DESCRIPTION_DESCRIPTOR = msg({
|
||||
message: 'Be heard over other members while holding the push to talk (priority) keybind.',
|
||||
comment:
|
||||
'Permission description in the role/permission editor for the Priority Speaker permission at community scope. "Push to talk (priority)" is the name of a keybind in the keybind settings, so match the wording used there.',
|
||||
});
|
||||
const MUTE_MEMBERS_GUILD_PERMISSION_DESCRIPTION_DESCRIPTOR = msg({
|
||||
message: 'Mute other members in voice channels for everyone.',
|
||||
comment:
|
||||
'Permission description in the role/permission editor for the Mute Members permission at community scope. The mute applies for every listener, not only for the moderator.',
|
||||
});
|
||||
const DEAFEN_MEMBERS_GUILD_PERMISSION_DESCRIPTION_DESCRIPTOR = msg({
|
||||
message: 'Deafen other members in voice channels, so they cannot hear or speak.',
|
||||
comment: 'Permission description in the role/permission editor for the Deafen Members permission at community scope.',
|
||||
});
|
||||
const SET_VOICE_REGION_GUILD_PERMISSION_DESCRIPTION_DESCRIPTOR = msg({
|
||||
message: 'Change which voice region a voice channel uses.',
|
||||
comment:
|
||||
'Permission description in the role/permission editor for the Set Voice Region permission at community scope. The region is the geographic location of the voice server.',
|
||||
});
|
||||
const MANAGE_CHANNEL_CHANNEL_PERMISSION_DESCRIPTION_DESCRIPTOR = msg({
|
||||
message: "Rename and edit this channel's settings.",
|
||||
@@ -130,6 +212,115 @@ const VIEW_CHANNEL_MEMBERS_CHANNEL_PERMISSION_DESCRIPTION_DESCRIPTOR = msg({
|
||||
comment:
|
||||
'Permission description in the channel-scoped permissions editor for the View Channel Members permission. Scoped to a single channel.',
|
||||
});
|
||||
const CREATE_INVITE_LINKS_CHANNEL_PERMISSION_DESCRIPTION_DESCRIPTOR = msg({
|
||||
message: 'Invite new people to the community with a link to this channel.',
|
||||
comment:
|
||||
'Permission description in the channel-scoped permissions editor for the Create Invite Links permission. The invite drops the new member into this channel.',
|
||||
});
|
||||
const VIEW_CHANNEL_CHANNEL_PERMISSION_DESCRIPTION_DESCRIPTOR = msg({
|
||||
message: 'See this channel. Denying it for {everyoneMention} makes the channel private.',
|
||||
comment:
|
||||
'Permission description in the channel-scoped permissions editor for the View Channel permission. The placeholder renders the literal @everyone role name and must not be translated.',
|
||||
});
|
||||
const SEND_MESSAGES_CHANNEL_PERMISSION_DESCRIPTION_DESCRIPTOR = msg({
|
||||
message: 'Send messages in this channel.',
|
||||
comment: 'Permission description in the channel-scoped permissions editor for the Send Messages permission.',
|
||||
});
|
||||
const MANAGE_MESSAGES_CHANNEL_PERMISSION_DESCRIPTION_DESCRIPTOR = msg({
|
||||
message: "Delete other members' messages in this channel. Pinning is controlled separately.",
|
||||
comment:
|
||||
'Permission description in the channel-scoped permissions editor for the Manage Messages permission. Notes that pinning has its own permission.',
|
||||
});
|
||||
const PIN_MESSAGES_CHANNEL_PERMISSION_DESCRIPTION_DESCRIPTOR = msg({
|
||||
message: 'Pin or unpin any message in this channel.',
|
||||
comment:
|
||||
'Permission description in the channel-scoped permissions editor for the Pin Messages permission. Covers messages from anyone, not only their own.',
|
||||
});
|
||||
const EMBED_LINKS_CHANNEL_PERMISSION_DESCRIPTION_DESCRIPTOR = msg({
|
||||
message: 'Show embedded previews for links they send in this channel.',
|
||||
comment:
|
||||
'Permission description in the channel-scoped permissions editor for the Embed Links permission. The preview is the card rendered under a message that contains a link.',
|
||||
});
|
||||
const ATTACH_FILES_CHANNEL_PERMISSION_DESCRIPTION_DESCRIPTOR = msg({
|
||||
message: 'Upload files and media in this channel.',
|
||||
comment: 'Permission description in the channel-scoped permissions editor for the Attach Files permission.',
|
||||
});
|
||||
const READ_MESSAGE_HISTORY_CHANNEL_PERMISSION_DESCRIPTION_DESCRIPTOR = msg({
|
||||
message:
|
||||
'Read messages sent in this channel before they opened it. Without it, they only see messages that arrive while it is open.',
|
||||
comment:
|
||||
'Permission description in the channel-scoped permissions editor for the Read Message History permission. Without the permission the member sees only messages that arrive while the channel is open in front of them.',
|
||||
});
|
||||
const MENTION_EVERYONE_CHANNEL_PERMISSION_DESCRIPTION_DESCRIPTOR = msg({
|
||||
message: "Mention everyone or any role in this channel (even if the role isn't set to be mentionable).",
|
||||
comment:
|
||||
'Permission description in the channel-scoped permissions editor for the Mention Everyone permission. Notes that this overrides the per-role "mentionable" flag.',
|
||||
});
|
||||
const USE_EXTERNAL_EMOJI_CHANNEL_PERMISSION_DESCRIPTION_DESCRIPTOR = msg({
|
||||
message: 'Use emoji from other communities in this channel.',
|
||||
comment:
|
||||
'Permission description in the channel-scoped permissions editor for the Use External Emoji permission. "Other communities" means custom emoji uploaded in a different Fluxer community.',
|
||||
});
|
||||
const USE_EXTERNAL_STICKERS_CHANNEL_PERMISSION_DESCRIPTION_DESCRIPTOR = msg({
|
||||
message: 'Use stickers from other communities in this channel.',
|
||||
comment:
|
||||
'Permission description in the channel-scoped permissions editor for the Use External Stickers permission. "Other communities" means stickers uploaded in a different Fluxer community.',
|
||||
});
|
||||
const ADD_REACTIONS_CHANNEL_PERMISSION_DESCRIPTION_DESCRIPTOR = msg({
|
||||
message: 'Add new reactions to messages in this channel.',
|
||||
comment:
|
||||
'Permission description in the channel-scoped permissions editor for the Add Reactions permission. "New" reaction means starting a new reaction emoji on a message (not stacking onto an existing one).',
|
||||
});
|
||||
const BYPASS_SLOWMODE_CHANNEL_PERMISSION_DESCRIPTION_DESCRIPTOR = msg({
|
||||
message: "Ignore this channel's slowmode cooldown.",
|
||||
comment:
|
||||
'Permission description in the channel-scoped permissions editor for the Bypass Slowmode permission. Slowmode is the per-channel cooldown between messages.',
|
||||
});
|
||||
const CONNECT_CHANNEL_PERMISSION_DESCRIPTION_DESCRIPTOR = msg({
|
||||
message:
|
||||
'Join this voice channel and hear others. Denying it along with View channel for {everyoneMention} makes the channel private.',
|
||||
comment:
|
||||
'Permission description in the channel-scoped permissions editor for the Connect permission. "View channel" is another permission in this same editor, so match the wording used for its name. The placeholder renders the literal @everyone role name and must not be translated.',
|
||||
});
|
||||
const SPEAK_CHANNEL_PERMISSION_DESCRIPTION_DESCRIPTOR = msg({
|
||||
message: 'Talk in this voice channel. Without it, members stay muted until someone with Mute members unmutes them.',
|
||||
comment:
|
||||
'Permission description in the channel-scoped permissions editor for the Speak permission. "Mute members" is another permission in this same editor, so match the wording used for its name.',
|
||||
});
|
||||
const STREAM_VIDEO_CHANNEL_PERMISSION_DESCRIPTION_DESCRIPTOR = msg({
|
||||
message: 'Turn on a camera or share a screen in this voice channel.',
|
||||
comment:
|
||||
'Permission description in the channel-scoped permissions editor for the Stream Video permission. Covers both the webcam and screen sharing.',
|
||||
});
|
||||
const USE_VOICE_ACTIVITY_CHANNEL_PERMISSION_DESCRIPTION_DESCRIPTOR = msg({
|
||||
message: 'Without this permission, push-to-talk is required in this channel.',
|
||||
comment:
|
||||
'Permission description in the channel-scoped permissions editor for the Use Voice Activity permission. Explains the inverse: without it, the user must use push-to-talk in this voice channel.',
|
||||
});
|
||||
const PRIORITY_SPEAKER_CHANNEL_PERMISSION_DESCRIPTION_DESCRIPTOR = msg({
|
||||
message: 'Be heard over other members in this channel while holding the push to talk (priority) keybind.',
|
||||
comment:
|
||||
'Permission description in the channel-scoped permissions editor for the Priority Speaker permission. "Push to talk (priority)" is the name of a keybind in the keybind settings, so match the wording used there.',
|
||||
});
|
||||
const MUTE_MEMBERS_CHANNEL_PERMISSION_DESCRIPTION_DESCRIPTOR = msg({
|
||||
message: 'Mute other members in this voice channel for everyone.',
|
||||
comment:
|
||||
'Permission description in the channel-scoped permissions editor for the Mute Members permission. The mute applies for every listener, not only for the moderator.',
|
||||
});
|
||||
const DEAFEN_MEMBERS_CHANNEL_PERMISSION_DESCRIPTION_DESCRIPTOR = msg({
|
||||
message: 'Deafen other members in this voice channel, so they cannot hear or speak.',
|
||||
comment: 'Permission description in the channel-scoped permissions editor for the Deafen Members permission.',
|
||||
});
|
||||
const MOVE_MEMBERS_CHANNEL_PERMISSION_DESCRIPTION_DESCRIPTOR = msg({
|
||||
message: 'Drag members out of this voice channel into channels they can access, and disconnect them from voice.',
|
||||
comment:
|
||||
'Permission description in the channel-scoped permissions editor for the Move Members permission. The target channel must already be accessible to the member being moved. This permission also covers forcing a member out of voice entirely.',
|
||||
});
|
||||
const SET_VOICE_REGION_CHANNEL_PERMISSION_DESCRIPTION_DESCRIPTOR = msg({
|
||||
message: 'Change which voice region this channel uses.',
|
||||
comment:
|
||||
'Permission description in the channel-scoped permissions editor for the Set Voice Region permission. The region is the geographic location of the voice server.',
|
||||
});
|
||||
const COMMUNITY_WIDE_DESCRIPTOR = msg({
|
||||
message: 'Community-wide',
|
||||
comment: 'Permission category for permissions that affect the whole community.',
|
||||
@@ -376,31 +567,76 @@ const PERMISSION_DESCRIPTION_DESCRIPTORS = new Map<bigint, MessageDescriptor>([
|
||||
[Permissions.MANAGE_GUILD, MANAGE_COMMUNITY_PERMISSION_DESCRIPTION_DESCRIPTOR],
|
||||
[Permissions.MANAGE_ROLES, MANAGE_ROLES_PERMISSION_DESCRIPTION_DESCRIPTOR],
|
||||
[Permissions.MANAGE_CHANNELS, MANAGE_CHANNELS_PERMISSION_DESCRIPTION_DESCRIPTOR],
|
||||
[Permissions.KICK_MEMBERS, KICK_MEMBERS_PERMISSION_DESCRIPTION_DESCRIPTOR],
|
||||
[Permissions.BAN_MEMBERS, BAN_MEMBERS_PERMISSION_DESCRIPTION_DESCRIPTOR],
|
||||
[Permissions.CREATE_INSTANT_INVITE, CREATE_INVITE_LINKS_PERMISSION_DESCRIPTION_DESCRIPTOR],
|
||||
[Permissions.CHANGE_NICKNAME, CHANGE_OWN_NICKNAME_PERMISSION_DESCRIPTION_DESCRIPTOR],
|
||||
[Permissions.MANAGE_NICKNAMES, MANAGE_NICKNAMES_PERMISSION_DESCRIPTION_DESCRIPTOR],
|
||||
[Permissions.CREATE_EXPRESSIONS, CREATE_EMOJI_AND_STICKERS_PERMISSION_DESCRIPTION_DESCRIPTOR],
|
||||
[Permissions.MANAGE_EXPRESSIONS, MANAGE_EMOJI_AND_STICKERS_PERMISSION_DESCRIPTION_DESCRIPTOR],
|
||||
[Permissions.MANAGE_WEBHOOKS, MANAGE_WEBHOOKS_PERMISSION_DESCRIPTION_DESCRIPTOR],
|
||||
[Permissions.SEND_MESSAGES, SEND_MESSAGES_GUILD_PERMISSION_DESCRIPTION_DESCRIPTOR],
|
||||
[Permissions.SEND_TTS_MESSAGES, SEND_TTS_MESSAGES_PERMISSION_DESCRIPTION_DESCRIPTOR],
|
||||
[Permissions.MANAGE_MESSAGES, MANAGE_MESSAGES_PERMISSION_DESCRIPTION_DESCRIPTOR],
|
||||
[Permissions.PIN_MESSAGES, PIN_MESSAGES_GUILD_PERMISSION_DESCRIPTION_DESCRIPTOR],
|
||||
[Permissions.EMBED_LINKS, EMBED_LINKS_GUILD_PERMISSION_DESCRIPTION_DESCRIPTOR],
|
||||
[Permissions.ATTACH_FILES, ATTACH_FILES_GUILD_PERMISSION_DESCRIPTION_DESCRIPTOR],
|
||||
[Permissions.READ_MESSAGE_HISTORY, READ_MESSAGE_HISTORY_GUILD_PERMISSION_DESCRIPTION_DESCRIPTOR],
|
||||
[Permissions.MENTION_EVERYONE, MENTION_EVERYONE_PERMISSION_DESCRIPTION_DESCRIPTOR],
|
||||
[Permissions.USE_EXTERNAL_EMOJIS, USE_EXTERNAL_EMOJI_PERMISSION_DESCRIPTION_DESCRIPTOR],
|
||||
[Permissions.USE_EXTERNAL_STICKERS, USE_EXTERNAL_STICKERS_PERMISSION_DESCRIPTION_DESCRIPTOR],
|
||||
[Permissions.ADD_REACTIONS, ADD_REACTIONS_PERMISSION_DESCRIPTION_DESCRIPTOR],
|
||||
[Permissions.BYPASS_SLOWMODE, BYPASS_SLOWMODE_PERMISSION_DESCRIPTION_DESCRIPTOR],
|
||||
[Permissions.MODERATE_MEMBERS, TIME_OUT_MEMBERS_PERMISSION_DESCRIPTION_DESCRIPTOR],
|
||||
[Permissions.VIEW_CHANNEL, VIEW_CHANNEL_GUILD_PERMISSION_DESCRIPTION_DESCRIPTOR],
|
||||
[Permissions.VIEW_CHANNEL_MEMBERS, VIEW_CHANNEL_MEMBERS_GUILD_PERMISSION_DESCRIPTION_DESCRIPTOR],
|
||||
[Permissions.CONNECT, CONNECT_GUILD_PERMISSION_DESCRIPTION_DESCRIPTOR],
|
||||
[Permissions.SPEAK, SPEAK_GUILD_PERMISSION_DESCRIPTION_DESCRIPTOR],
|
||||
[Permissions.STREAM, STREAM_VIDEO_GUILD_PERMISSION_DESCRIPTION_DESCRIPTOR],
|
||||
[Permissions.USE_VAD, USE_VOICE_ACTIVITY_PERMISSION_DESCRIPTION_DESCRIPTOR],
|
||||
[Permissions.PRIORITY_SPEAKER, PRIORITY_SPEAKER_GUILD_PERMISSION_DESCRIPTION_DESCRIPTOR],
|
||||
[Permissions.MUTE_MEMBERS, MUTE_MEMBERS_GUILD_PERMISSION_DESCRIPTION_DESCRIPTOR],
|
||||
[Permissions.DEAFEN_MEMBERS, DEAFEN_MEMBERS_GUILD_PERMISSION_DESCRIPTION_DESCRIPTOR],
|
||||
[Permissions.MOVE_MEMBERS, MOVE_MEMBERS_PERMISSION_DESCRIPTION_DESCRIPTOR],
|
||||
[Permissions.UPDATE_RTC_REGION, SET_VOICE_REGION_GUILD_PERMISSION_DESCRIPTION_DESCRIPTOR],
|
||||
]);
|
||||
const CHANNEL_PERMISSION_TITLE_OVERRIDES = new Map<bigint, MessageDescriptor>([
|
||||
[Permissions.MANAGE_CHANNELS, MANAGE_CHANNEL_DESCRIPTOR],
|
||||
[Permissions.MANAGE_ROLES, MANAGE_PERMISSIONS_DESCRIPTOR],
|
||||
]);
|
||||
const CHANNEL_PERMISSION_DESCRIPTION_OVERRIDES = new Map<bigint, MessageDescriptor>([
|
||||
[Permissions.CREATE_INSTANT_INVITE, CREATE_INVITE_LINKS_CHANNEL_PERMISSION_DESCRIPTION_DESCRIPTOR],
|
||||
[Permissions.MANAGE_CHANNELS, MANAGE_CHANNEL_CHANNEL_PERMISSION_DESCRIPTION_DESCRIPTOR],
|
||||
[Permissions.MANAGE_ROLES, MANAGE_PERMISSIONS_CHANNEL_PERMISSION_DESCRIPTION_DESCRIPTOR],
|
||||
[Permissions.MANAGE_WEBHOOKS, MANAGE_WEBHOOKS_CHANNEL_PERMISSION_DESCRIPTION_DESCRIPTOR],
|
||||
[
|
||||
Permissions.VIEW_CHANNEL,
|
||||
{...VIEW_CHANNEL_CHANNEL_PERMISSION_DESCRIPTION_DESCRIPTOR, values: {everyoneMention: EVERYONE_MENTION}},
|
||||
],
|
||||
[Permissions.VIEW_CHANNEL_MEMBERS, VIEW_CHANNEL_MEMBERS_CHANNEL_PERMISSION_DESCRIPTION_DESCRIPTOR],
|
||||
[Permissions.SEND_MESSAGES, SEND_MESSAGES_CHANNEL_PERMISSION_DESCRIPTION_DESCRIPTOR],
|
||||
[Permissions.MANAGE_MESSAGES, MANAGE_MESSAGES_CHANNEL_PERMISSION_DESCRIPTION_DESCRIPTOR],
|
||||
[Permissions.PIN_MESSAGES, PIN_MESSAGES_CHANNEL_PERMISSION_DESCRIPTION_DESCRIPTOR],
|
||||
[Permissions.EMBED_LINKS, EMBED_LINKS_CHANNEL_PERMISSION_DESCRIPTION_DESCRIPTOR],
|
||||
[Permissions.ATTACH_FILES, ATTACH_FILES_CHANNEL_PERMISSION_DESCRIPTION_DESCRIPTOR],
|
||||
[Permissions.READ_MESSAGE_HISTORY, READ_MESSAGE_HISTORY_CHANNEL_PERMISSION_DESCRIPTION_DESCRIPTOR],
|
||||
[Permissions.MENTION_EVERYONE, MENTION_EVERYONE_CHANNEL_PERMISSION_DESCRIPTION_DESCRIPTOR],
|
||||
[Permissions.USE_EXTERNAL_EMOJIS, USE_EXTERNAL_EMOJI_CHANNEL_PERMISSION_DESCRIPTION_DESCRIPTOR],
|
||||
[Permissions.USE_EXTERNAL_STICKERS, USE_EXTERNAL_STICKERS_CHANNEL_PERMISSION_DESCRIPTION_DESCRIPTOR],
|
||||
[Permissions.ADD_REACTIONS, ADD_REACTIONS_CHANNEL_PERMISSION_DESCRIPTION_DESCRIPTOR],
|
||||
[Permissions.BYPASS_SLOWMODE, BYPASS_SLOWMODE_CHANNEL_PERMISSION_DESCRIPTION_DESCRIPTOR],
|
||||
[
|
||||
Permissions.CONNECT,
|
||||
{...CONNECT_CHANNEL_PERMISSION_DESCRIPTION_DESCRIPTOR, values: {everyoneMention: EVERYONE_MENTION}},
|
||||
],
|
||||
[Permissions.SPEAK, SPEAK_CHANNEL_PERMISSION_DESCRIPTION_DESCRIPTOR],
|
||||
[Permissions.STREAM, STREAM_VIDEO_CHANNEL_PERMISSION_DESCRIPTION_DESCRIPTOR],
|
||||
[Permissions.USE_VAD, USE_VOICE_ACTIVITY_CHANNEL_PERMISSION_DESCRIPTION_DESCRIPTOR],
|
||||
[Permissions.PRIORITY_SPEAKER, PRIORITY_SPEAKER_CHANNEL_PERMISSION_DESCRIPTION_DESCRIPTOR],
|
||||
[Permissions.MUTE_MEMBERS, MUTE_MEMBERS_CHANNEL_PERMISSION_DESCRIPTION_DESCRIPTOR],
|
||||
[Permissions.DEAFEN_MEMBERS, DEAFEN_MEMBERS_CHANNEL_PERMISSION_DESCRIPTION_DESCRIPTOR],
|
||||
[Permissions.MOVE_MEMBERS, MOVE_MEMBERS_CHANNEL_PERMISSION_DESCRIPTION_DESCRIPTOR],
|
||||
[Permissions.UPDATE_RTC_REGION, SET_VOICE_REGION_CHANNEL_PERMISSION_DESCRIPTION_DESCRIPTOR],
|
||||
]);
|
||||
|
||||
export function getPermissionTitleDescriptor(
|
||||
|
||||
@@ -84,6 +84,8 @@ interface OnlineWaiter {
|
||||
onAbort: () => void;
|
||||
}
|
||||
|
||||
const strippedAuthorizationOrigins = new Set<string>();
|
||||
|
||||
const onlineWaiters = new Set<OnlineWaiter>();
|
||||
let onlineListenerActive = false;
|
||||
|
||||
@@ -167,6 +169,10 @@ export class RestClient {
|
||||
this.state.globalIntercept = hooks.intercept;
|
||||
}
|
||||
|
||||
carriesAuthorization(): boolean {
|
||||
return !isOffOrigin(resolveUrl(this.state, '/', undefined));
|
||||
}
|
||||
|
||||
dispatch<T = unknown>(method: HttpMethod, path: string, options: RestRequestOptions = {}): Promise<RestResponse<T>> {
|
||||
return runWithSudoEscalation<T>(this.state, method, path, options, 'fresh');
|
||||
}
|
||||
@@ -319,7 +325,12 @@ function composePlan(
|
||||
): Plan {
|
||||
const url = resolveUrl(state, path, options.query);
|
||||
const body = encodeBody(options);
|
||||
const sameOrigin = !looksAbsolute(path) && !isOffOrigin(url);
|
||||
const targetsApiBase = !looksAbsolute(path);
|
||||
const apiOrigin = targetsApiBase ? originOf(url) : null;
|
||||
const sameOrigin = targetsApiBase && (apiOrigin === null || apiOrigin === window.location.origin);
|
||||
if (apiOrigin !== null && !sameOrigin) {
|
||||
reportStrippedAuthorization(state, apiOrigin, options.auth);
|
||||
}
|
||||
const headers = assembleHeaders({
|
||||
state,
|
||||
callerHeaders: options.headers,
|
||||
@@ -365,14 +376,26 @@ function looksAbsolute(path: string): boolean {
|
||||
return path.startsWith('//') || /^[a-z][a-z0-9+.-]*:\/\//i.test(path);
|
||||
}
|
||||
|
||||
function isOffOrigin(url: string): boolean {
|
||||
function originOf(url: string): string | null {
|
||||
try {
|
||||
return new URL(url).origin !== window.location.origin;
|
||||
return new URL(url).origin;
|
||||
} catch {
|
||||
return false;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function isOffOrigin(url: string): boolean {
|
||||
const origin = originOf(url);
|
||||
return origin !== null && origin !== window.location.origin;
|
||||
}
|
||||
|
||||
function reportStrippedAuthorization(state: RuntimeState, apiOrigin: string, auth: RestAuthMode | undefined): void {
|
||||
if (auth === 'none' || strippedAuthorizationOrigins.has(apiOrigin)) return;
|
||||
if (!state.authProvider()) return;
|
||||
strippedAuthorizationOrigins.add(apiOrigin);
|
||||
log.warn(`authorization withheld from off-origin api base: ${apiOrigin} (page ${window.location.origin})`);
|
||||
}
|
||||
|
||||
function encodeBody(options: RestRequestOptions): BodyShape {
|
||||
if (options.multipart) {
|
||||
return {tag: 'form', payload: buildFormData(options.multipart)};
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {beforeEach, describe, expect, test, vi} from 'vitest';
|
||||
|
||||
let nativeMacOS = false;
|
||||
|
||||
vi.mock('@app/features/ui/utils/NativeUtils', () => ({
|
||||
isNativeMacOS: () => nativeMacOS,
|
||||
}));
|
||||
|
||||
const {isTextInputKeyEvent} = await import('@app/features/platform/utils/IsTextInputKeyEvent');
|
||||
|
||||
const keyEvent = (init: Partial<KeyboardEvent>): KeyboardEvent =>
|
||||
({key: '', ctrlKey: false, metaKey: false, altKey: false, shiftKey: false, ...init}) as KeyboardEvent;
|
||||
|
||||
beforeEach(() => {
|
||||
nativeMacOS = false;
|
||||
});
|
||||
|
||||
describe('isTextInputKeyEvent', () => {
|
||||
test('Alt+digit is a shortcut chord, not typing, off macOS', () => {
|
||||
for (const key of ['1', '2', '3', '4', '5', '6', '7', '8', '9']) {
|
||||
expect(isTextInputKeyEvent(keyEvent({key, altKey: true}))).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
test('Alt+letter is a shortcut chord, not typing, off macOS', () => {
|
||||
expect(isTextInputKeyEvent(keyEvent({key: 'a', altKey: true}))).toBe(false);
|
||||
});
|
||||
|
||||
test('Option-composed characters stay typing on macOS', () => {
|
||||
nativeMacOS = true;
|
||||
expect(isTextInputKeyEvent(keyEvent({key: '¡', altKey: true}))).toBe(true);
|
||||
expect(isTextInputKeyEvent(keyEvent({key: '@', altKey: true}))).toBe(true);
|
||||
});
|
||||
|
||||
test('a bare printable key is still typing', () => {
|
||||
expect(isTextInputKeyEvent(keyEvent({key: 'a'}))).toBe(true);
|
||||
expect(isTextInputKeyEvent(keyEvent({key: '1'}))).toBe(true);
|
||||
});
|
||||
|
||||
test('ctrl and meta chords and named keys are still not typing', () => {
|
||||
expect(isTextInputKeyEvent(keyEvent({key: '1', ctrlKey: true}))).toBe(false);
|
||||
expect(isTextInputKeyEvent(keyEvent({key: '1', metaKey: true}))).toBe(false);
|
||||
expect(isTextInputKeyEvent(keyEvent({key: 'ArrowUp'}))).toBe(false);
|
||||
expect(isTextInputKeyEvent(keyEvent({key: 'ArrowUp', altKey: true}))).toBe(false);
|
||||
});
|
||||
|
||||
test('a dead key still counts as typing while Alt is held', () => {
|
||||
expect(isTextInputKeyEvent(keyEvent({key: 'Dead', altKey: true}))).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,9 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {isNativeMacOS} from '@app/features/ui/utils/NativeUtils';
|
||||
|
||||
export function isTextInputKeyEvent(event: KeyboardEvent): boolean {
|
||||
const {key, ctrlKey, metaKey} = event;
|
||||
const {key, ctrlKey, metaKey, altKey} = event;
|
||||
if (!key || key === 'Unidentified') {
|
||||
return false;
|
||||
}
|
||||
@@ -11,6 +13,9 @@ export function isTextInputKeyEvent(event: KeyboardEvent): boolean {
|
||||
if (key === 'Dead') {
|
||||
return true;
|
||||
}
|
||||
if (altKey && !isNativeMacOS()) {
|
||||
return false;
|
||||
}
|
||||
if (key.length > 1 && NAMED_KEY_PATTERN.test(key)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
+34
-3
@@ -4,7 +4,7 @@
|
||||
import {installVoiceMenuTestBootstrap} from '@app/features/ui/action_menu/items/__fixtures__/VoiceMenuTestBootstrap';
|
||||
import type {VoiceParticipantMenuScreenShareSource} from '@app/features/ui/action_menu/items/VoiceParticipantMenuTypes';
|
||||
import type {I18n} from '@lingui/core';
|
||||
import {expect, test, vi} from 'vitest';
|
||||
import {beforeEach, expect, test, vi} from 'vitest';
|
||||
|
||||
vi.mock('@lingui/core/macro', () => {
|
||||
const descriptor = (value: unknown): unknown => (typeof value === 'string' ? {message: value} : value);
|
||||
@@ -38,6 +38,9 @@ vi.mock('@app/features/voice/state/StreamAudioPrefs', () => ({
|
||||
vi.mock('@app/features/voice/state/VoiceSettings', () => ({
|
||||
default: {showMyOwnScreenShare: false, pauseOwnScreenSharePreviewOnUnfocus: false},
|
||||
}));
|
||||
vi.mock('@app/features/voice/commands/VoiceDiagnosticsCommands', () => ({
|
||||
copyVoiceDiagnostics: vi.fn(async () => undefined),
|
||||
}));
|
||||
vi.mock('@app/features/voice/commands/VoiceSettingsCommands', () => ({
|
||||
update: vi.fn(),
|
||||
}));
|
||||
@@ -47,6 +50,7 @@ installVoiceMenuTestBootstrap();
|
||||
const {buildVoiceParticipantStreamMenu} = await import(
|
||||
'@app/features/ui/action_menu/items/VoiceParticipantStreamMenuBuilder'
|
||||
);
|
||||
const {copyVoiceDiagnostics} = await import('@app/features/voice/commands/VoiceDiagnosticsCommands');
|
||||
|
||||
const i18n = {
|
||||
locale: 'en',
|
||||
@@ -56,6 +60,7 @@ const i18n = {
|
||||
interface MenuLeaf {
|
||||
label?: string;
|
||||
items?: Array<MenuLeaf>;
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
function streamMenu(source: VoiceParticipantMenuScreenShareSource): Array<{items: Array<MenuLeaf>}> {
|
||||
@@ -99,18 +104,44 @@ const WATCHED_REMOTE_STREAM_SOURCE: VoiceParticipantMenuScreenShareSource = {
|
||||
state: {kind: 'remote-watched', hasAudio: true, onStopWatching: () => undefined},
|
||||
};
|
||||
|
||||
const UNWATCHED_REMOTE_STREAM_SOURCE: VoiceParticipantMenuScreenShareSource = {
|
||||
kind: 'screen-share',
|
||||
streamKey: 'stream-key',
|
||||
state: {kind: 'remote-unwatched', onWatch: () => undefined},
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(copyVoiceDiagnostics).mockClear();
|
||||
});
|
||||
|
||||
test('own stream keeps a More options submenu with the screen-share preferences', () => {
|
||||
const groups = streamMenu(OWN_STREAM_SOURCE);
|
||||
const moreOptions = findLeaf(groups, 'More options');
|
||||
expect(moreOptions).not.toBeNull();
|
||||
expect(findLeaf(groups, 'Show my screen share')).not.toBeNull();
|
||||
expect(findLeaf(groups, 'Copy stats JSON')).not.toBeNull();
|
||||
expect(findLeaf(groups, 'Report Problem')).toBeNull();
|
||||
});
|
||||
|
||||
test('remote watched stream omits the now-empty More options submenu and keeps audio controls', () => {
|
||||
test('remote watched stream keeps a More options submenu with the diagnostics entry and audio controls', () => {
|
||||
const groups = streamMenu(WATCHED_REMOTE_STREAM_SOURCE);
|
||||
expect(findLeaf(groups, 'More options')).toBeNull();
|
||||
expect(findLeaf(groups, 'More options')).not.toBeNull();
|
||||
expect(findLeaf(groups, 'Copy stats JSON')).not.toBeNull();
|
||||
expect(findLeaf(groups, 'Mute')).not.toBeNull();
|
||||
expect(findLeaf(groups, 'Stream volume')).not.toBeNull();
|
||||
expect(findLeaf(groups, 'Report Problem')).toBeNull();
|
||||
});
|
||||
|
||||
test('remote unwatched stream keeps the diagnostics entry', () => {
|
||||
const groups = streamMenu(UNWATCHED_REMOTE_STREAM_SOURCE);
|
||||
expect(findLeaf(groups, 'More options')).not.toBeNull();
|
||||
expect(findLeaf(groups, 'Copy stats JSON')).not.toBeNull();
|
||||
});
|
||||
|
||||
test('the diagnostics entry on a remote stream copies voice diagnostics', () => {
|
||||
const groups = streamMenu(WATCHED_REMOTE_STREAM_SOURCE);
|
||||
const copyStats = findLeaf(groups, 'Copy stats JSON');
|
||||
expect(copyStats).not.toBeNull();
|
||||
copyStats?.onClick?.();
|
||||
expect(vi.mocked(copyVoiceDiagnostics)).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
+44
-20
@@ -30,9 +30,11 @@ import type {
|
||||
MenuSliderType,
|
||||
MenuSubmenuItemType,
|
||||
} from '@app/features/ui/menu_bottom_sheet/MenuBottomSheet';
|
||||
import {copyVoiceDiagnostics} from '@app/features/voice/commands/VoiceDiagnosticsCommands';
|
||||
import * as VoiceSettingsCommands from '@app/features/voice/commands/VoiceSettingsCommands';
|
||||
import {changeActiveScreenShare, stopActiveScreenShare} from '@app/features/voice/components/ActiveScreenShareMenu';
|
||||
import {openScreenSharePreviewPrivacyModal} from '@app/features/voice/components/modals/ScreenSharePickerModal';
|
||||
import {COPY_STATS_JSON_DESCRIPTOR} from '@app/features/voice/components/StatsForNerdsCopyDescriptors';
|
||||
import MediaEngine from '@app/features/voice/engine/MediaEngineFacade';
|
||||
import ActiveScreenShareSource from '@app/features/voice/state/ActiveScreenShareSource';
|
||||
import PopoutWindowManager, {isVoicePopoutSupported} from '@app/features/voice/state/PopoutWindowManager';
|
||||
@@ -211,28 +213,39 @@ function buildStreamPopoutAction(options: VoiceParticipantStreamMenuBuilderOptio
|
||||
|
||||
function buildStreamMoreOptions(options: VoiceParticipantStreamMenuBuilderOptions): MenuSubmenuItemType | null {
|
||||
const {i18n, guildId, source, showMyOwnScreenShare, pauseOwnScreenSharePreviewOnUnfocus, onClose} = options;
|
||||
if (source.state.kind !== 'own') return null;
|
||||
const items: Array<MenuItemType | MenuCheckboxType> = [
|
||||
{
|
||||
label: i18n._(SHOW_MY_SCREEN_SHARE_DESCRIPTOR),
|
||||
checked: showMyOwnScreenShare,
|
||||
onChange: (checked: boolean) => VoiceSettingsCommands.update({showMyOwnScreenShare: checked}),
|
||||
},
|
||||
{
|
||||
label: i18n._(PAUSE_OWN_STREAM_PREVIEW_DESCRIPTOR),
|
||||
checked: pauseOwnScreenSharePreviewOnUnfocus,
|
||||
onChange: (checked: boolean) => VoiceSettingsCommands.update({pauseOwnScreenSharePreviewOnUnfocus: checked}),
|
||||
},
|
||||
];
|
||||
if (guildId === undefined) {
|
||||
items.push({
|
||||
label: i18n._(SCREEN_SHARE_PRIVACY_DESCRIPTOR),
|
||||
onClick: () => {
|
||||
onClose();
|
||||
openScreenSharePreviewPrivacyModal();
|
||||
const items: Array<MenuItemType | MenuCheckboxType> = [];
|
||||
if (source.state.kind === 'own') {
|
||||
items.push(
|
||||
{
|
||||
label: i18n._(SHOW_MY_SCREEN_SHARE_DESCRIPTOR),
|
||||
checked: showMyOwnScreenShare,
|
||||
onChange: (checked: boolean) => VoiceSettingsCommands.update({showMyOwnScreenShare: checked}),
|
||||
},
|
||||
});
|
||||
{
|
||||
label: i18n._(PAUSE_OWN_STREAM_PREVIEW_DESCRIPTOR),
|
||||
checked: pauseOwnScreenSharePreviewOnUnfocus,
|
||||
onChange: (checked: boolean) => VoiceSettingsCommands.update({pauseOwnScreenSharePreviewOnUnfocus: checked}),
|
||||
},
|
||||
);
|
||||
if (guildId === undefined) {
|
||||
items.push({
|
||||
label: i18n._(SCREEN_SHARE_PRIVACY_DESCRIPTOR),
|
||||
onClick: () => {
|
||||
onClose();
|
||||
openScreenSharePreviewPrivacyModal();
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
items.push({
|
||||
label: i18n._(COPY_STATS_JSON_DESCRIPTOR),
|
||||
onClick: () => {
|
||||
onClose();
|
||||
void copyVoiceDiagnostics(i18n).catch((error) => {
|
||||
logger.error('Failed to copy voice diagnostics from participant menu', error);
|
||||
});
|
||||
},
|
||||
});
|
||||
if (items.length === 0) return null;
|
||||
return {
|
||||
label: i18n._(MORE_OPTIONS_DESCRIPTOR),
|
||||
@@ -334,6 +347,17 @@ export const VoiceParticipantOwnStreamMenuTail: React.FC<VoiceParticipantOwnStre
|
||||
{i18n._(SCREEN_SHARE_PRIVACY_DESCRIPTOR)}
|
||||
</MenuItem>
|
||||
)}
|
||||
<MenuItem
|
||||
onClick={() => {
|
||||
onClose();
|
||||
void copyVoiceDiagnostics(i18n).catch((error) => {
|
||||
logger.error('Failed to copy voice diagnostics from participant menu', error);
|
||||
});
|
||||
}}
|
||||
data-flx="ui.action-menu.items.voice-participant-stream-menu-builder.voice-participant-own-stream-menu-tail.menu-item.close--2"
|
||||
>
|
||||
{i18n._(COPY_STATS_JSON_DESCRIPTOR)}
|
||||
</MenuItem>
|
||||
</MenuGroup>
|
||||
)}
|
||||
data-flx="ui.action-menu.items.voice-participant-stream-menu-builder.more-options-submenu"
|
||||
|
||||
@@ -88,16 +88,16 @@ const DESKTOP_UPDATE_READY_DESCRIPTOR = msg({
|
||||
message: 'Desktop update ready',
|
||||
comment: 'Modal title shown when a desktop app update has finished downloading.',
|
||||
});
|
||||
const DESKTOP_VERSION_HAS_BEEN_DOWNLOADED_DESCRIPTOR = msg({
|
||||
export const DESKTOP_VERSION_HAS_BEEN_DOWNLOADED_DESCRIPTOR = msg({
|
||||
message: 'Desktop version {version} has been downloaded. Restart {productName} to finish installing.',
|
||||
comment:
|
||||
'Desktop updater modal body. The version placeholder is the downloaded app version; productName is the app name.',
|
||||
});
|
||||
const THE_DESKTOP_UPDATE_HAS_BEEN_DOWNLOADED_DESCRIPTOR = msg({
|
||||
export const THE_DESKTOP_UPDATE_HAS_BEEN_DOWNLOADED_DESCRIPTOR = msg({
|
||||
message: 'The desktop update has been downloaded. Restart {productName} to finish installing.',
|
||||
comment: 'Desktop updater modal body when the downloaded version is unknown. productName is the app name.',
|
||||
});
|
||||
const RESTART_FLUXER_DESCRIPTOR = msg({
|
||||
export const RESTART_FLUXER_DESCRIPTOR = msg({
|
||||
message: 'Restart {productName}',
|
||||
comment: 'Button label that restarts the app to apply a desktop update. productName is the app name.',
|
||||
});
|
||||
|
||||
-55
@@ -1,55 +0,0 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {Button} from '@app/features/ui/button/Button';
|
||||
import * as ToastCommands from '@app/features/ui/commands/ToastCommands';
|
||||
import {
|
||||
COPIED_STATS_JSON_DESCRIPTOR,
|
||||
COPY_STATS_JSON_DESCRIPTOR,
|
||||
} from '@app/features/voice/components/StatsForNerdsCopyDescriptors';
|
||||
import {useStatsForNerds} from '@app/features/voice/components/useStatsForNerds';
|
||||
import {buildStatsForNerdsCopyPayload} from '@app/features/voice/utils/StatsForNerdsCopy';
|
||||
import {useLingui} from '@lingui/react/macro';
|
||||
import {CopySimpleIcon} from '@phosphor-icons/react';
|
||||
import type React from 'react';
|
||||
import {useCallback, useState} from 'react';
|
||||
|
||||
export const StatsForNerdsCopyButton: React.FC = () => {
|
||||
const {i18n} = useLingui();
|
||||
const data = useStatsForNerds();
|
||||
const [copying, setCopying] = useState(false);
|
||||
const handleCopy = useCallback(async () => {
|
||||
if (copying) return;
|
||||
setCopying(true);
|
||||
try {
|
||||
let payload: Record<string, unknown>;
|
||||
try {
|
||||
payload = await buildStatsForNerdsCopyPayload(data);
|
||||
} catch {
|
||||
payload = {
|
||||
schemaVersion: 1,
|
||||
createdAt: new Date().toISOString(),
|
||||
statsForNerds: data,
|
||||
};
|
||||
}
|
||||
await navigator.clipboard.writeText(JSON.stringify(payload, null, 2));
|
||||
ToastCommands.createToast({
|
||||
type: 'success',
|
||||
children: i18n._(COPIED_STATS_JSON_DESCRIPTOR),
|
||||
});
|
||||
} finally {
|
||||
setCopying(false);
|
||||
}
|
||||
}, [copying, data, i18n]);
|
||||
return (
|
||||
<Button
|
||||
variant="secondary"
|
||||
fitContent
|
||||
leftIcon={<CopySimpleIcon size={16} data-flx="user.stats-for-nerds-copy-button.copy-icon" />}
|
||||
submitting={copying}
|
||||
onClick={() => void handleCopy()}
|
||||
data-flx="user.stats-for-nerds-copy-button.copy"
|
||||
>
|
||||
{i18n._(COPY_STATS_JSON_DESCRIPTOR)}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,22 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import * as ToastCommands from '@app/features/ui/commands/ToastCommands';
|
||||
import {COPIED_STATS_JSON_DESCRIPTOR} from '@app/features/voice/components/StatsForNerdsCopyDescriptors';
|
||||
import {buildStatsForNerdsCopyPayload, collectStatsForNerdsSnapshot} from '@app/features/voice/utils/StatsForNerdsCopy';
|
||||
import type {I18n} from '@lingui/core';
|
||||
|
||||
export async function copyVoiceDiagnostics(i18n: I18n): Promise<void> {
|
||||
const data = collectStatsForNerdsSnapshot();
|
||||
let payload: Record<string, unknown>;
|
||||
try {
|
||||
payload = await buildStatsForNerdsCopyPayload(data);
|
||||
} catch {
|
||||
payload = {
|
||||
schemaVersion: 1,
|
||||
createdAt: new Date().toISOString(),
|
||||
statsForNerds: data,
|
||||
};
|
||||
}
|
||||
await navigator.clipboard.writeText(JSON.stringify(payload, null, 2));
|
||||
ToastCommands.createToast({type: 'success', children: i18n._(COPIED_STATS_JSON_DESCRIPTOR)});
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user