Compare commits

...
Author SHA1 Message Date
HampusandGitHub e2abfd476a feat(api): redirect desktop downloads to pkgs (#2853) 2026-09-20 01:20:30 +02:00
HampusandGitHub 487febac8e fix(voice): make stereo microphones work in studio and custom (#2852) 2026-09-20 00:19:53 +02:00
HampusandGitHub a3454e8245 fix(installer): name the services that are not ready (#2851) 2026-09-19 23:34:08 +02:00
HampusandGitHub bf7567b768 fix(user): push guild member updates on profile field changes (#2850) 2026-09-19 23:30:25 +02:00
HampusandGitHub ac3450ab32 feat(ci): publish appimage zsync control files (#2849) 2026-09-19 22:22:54 +02:00
HampusandGitHub 5d034becb8 fix(installer): stop waiting for an absent bucket initialiser (#2848) 2026-09-19 22:14:13 +02:00
HampusandGitHub f9397d0db9 feat(ci): publish linux repositories from the desktop release (#2847) 2026-09-19 22:01:06 +02:00
HampusandGitHub 9005139dc8 fix(voice): stop stereo microphones publishing as mono (#2846) 2026-09-19 21:54:38 +02:00
HampusandGitHub d93604afa2 fix(voice): let screen shares use the hardware H.264 encoder (#2845) 2026-09-19 21:54:30 +02:00
HampusandGitHub c4f0b2ece0 feat(desktop): self-update appimages in place (#2843) 2026-09-19 19:06:53 +02:00
HampusandGitHub 98a42f612b fix(desktop): supersede the legacy linux packages on upgrade (#2842) 2026-09-19 18:50:37 +02:00
HampusandGitHub cc75e1318d fix(ci): raise the macos minimum to 13.0 (#2841) 2026-09-19 18:35:05 +02:00
HampusandGitHub 9027cbdf3e fix(voice): send screen shares at the quality the user picked (#2840) 2026-09-19 16:46:22 +02:00
HampusandGitHub 2119e10ed5 chore(static): update marketing screenshots and readme cover (#2839) 2026-09-19 16:44:32 +02:00
HampusandGitHub 87f3eb3c81 feat(desktop): add flatpak and arch packaging inputs (#2838) 2026-09-19 15:31:41 +02:00
HampusandGitHub f9bb8bd585 test(voice): remove the slow screen share delivery proof (#2836) 2026-09-19 02:33:32 +02:00
HampusandGitHub bc47a724af fix(voice): stop screen shares failing to reach their viewers (#2835) 2026-09-19 02:17:25 +02:00
HampusandGitHub f32356801d feat(api): make tor and breached password lookups opt-in (#2834) 2026-09-19 01:22:17 +02:00
HampusandGitHub efd677f32b feat(api): exempt configured ASNs from abusive IP auto-bans (#2833) 2026-09-18 23:01:58 +02:00
237 changed files with 23549 additions and 22203 deletions
+40 -81
View File
@@ -11,11 +11,6 @@ on:
- stable
- canary
default: stable
test_build:
description: Stash artifacts under desktop-test/ instead of desktop/ (API will not pick these up as a release).
required: false
default: false
type: boolean
build_version:
description: Explicit Fluxer CalVer build version (YYYY.MDD.MICRO, UTC HHMMSS without leading zeroes) to use instead of automatic UTC clock allocation.
required: false
@@ -32,13 +27,12 @@ permissions:
actions: read
concurrency:
group: desktop-${{ inputs.channel }}-${{ inputs.test_build && 'test' || 'release' }}
group: desktop-${{ inputs.channel }}
cancel-in-progress: true
env:
CHANNEL: ${{ inputs.channel }}
BUILD_CHANNEL: ${{ inputs.channel == 'canary' && 'canary' || 'stable' }}
TEST_BUILD: ${{ inputs.test_build && 'true' || 'false' }}
jobs:
meta:
@@ -53,8 +47,6 @@ jobs:
pub_date: ${{ steps.meta.outputs.pub_date }}
channel: ${{ steps.meta.outputs.channel }}
build_channel: ${{ steps.meta.outputs.build_channel }}
test_build: ${{ steps.meta.outputs.test_build }}
s3_prefix: ${{ steps.meta.outputs.s3_prefix }}
source_sha: ${{ steps.meta.outputs.source_sha }}
steps:
- name: Checkout source
@@ -85,7 +77,6 @@ jobs:
cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- build-desktop
--step set_metadata
--channel "${{ inputs.channel }}"
--test-build "${{ inputs.test_build }}"
matrix:
name: Resolve build matrix
@@ -113,7 +104,7 @@ jobs:
--skip-targets "${{ inputs.skip_targets }}"
build:
name: Build ${{ matrix.platform }} (${{ matrix.arch }}, ${{ matrix.desktop_variant }})
name: Build ${{ matrix.platform }} (${{ matrix.arch }})
needs:
- meta
- matrix
@@ -137,15 +128,8 @@ jobs:
PUBLIC_BUILD_VERSION: ${{ needs.meta.outputs.version }}
PUB_DATE: ${{ needs.meta.outputs.pub_date }}
SOURCE_SHA: ${{ needs.meta.outputs.source_sha }}
S3_DESKTOP_PREFIX: ${{ needs.meta.outputs.s3_prefix }}
DESKTOP_HANDOFF_PREFIX: _handoff/desktop/${{ needs.meta.outputs.build_channel }}/${{ needs.meta.outputs.version }}/${{ needs.meta.outputs.source_sha }}
S3_ENDPOINT: ${{ vars.DOWNLOADS_S3_ENDPOINT }}
S3_BUCKET: ${{ vars.DOWNLOADS_S3_BUCKET }}
AWS_ACCESS_KEY_ID: ${{ secrets.DOWNLOADS_AWS_ACCESS_KEY_ID || secrets.AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.DOWNLOADS_AWS_SECRET_ACCESS_KEY || secrets.AWS_SECRET_ACCESS_KEY }}
DESKTOP_PLATFORM: ${{ matrix.platform }}
DESKTOP_ARCH: ${{ matrix.arch }}
DESKTOP_VARIANT: ${{ matrix.desktop_variant }}
PLATFORM: ${{ matrix.platform }}
ARCH: ${{ matrix.arch }}
ELECTRON_ARCH: ${{ matrix.electron_arch }}
@@ -477,6 +461,12 @@ jobs:
cargo run --locked --quiet --manifest-path ${{ github.workspace }}/_ci/tools/ci/Cargo.toml -- build-desktop
--step prepare_artifacts_unix
- name: Build AppImage update feed (Linux)
if: matrix.platform == 'linux'
run: >-
cargo run --locked --quiet --manifest-path ${{ github.workspace }}/_ci/tools/ci/Cargo.toml -- build-desktop
--step build_appimage_update_feed
- name: Normalize updater YAML (macOS)
if: matrix.platform == 'macos'
run: >-
@@ -495,13 +485,23 @@ jobs:
cargo run --locked --quiet --manifest-path ${{ github.workspace }}/_ci/tools/ci/Cargo.toml -- build-desktop
--step generate_checksums_windows
- name: Upload artifacts to S3 handoff
- name: Stage build artifacts
id: handoff
run: >-
cargo run --locked --quiet --manifest-path ${{ github.workspace }}/_ci/tools/ci/Cargo.toml -- build-desktop
--step upload_handoff
--step stage_handoff
- name: Upload build artifacts
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02
with:
name: ${{ steps.handoff.outputs.artifact_name }}
path: upload_staging
if-no-files-found: error
retention-days: 1
compression-level: 0
upload:
name: Upload to S3
name: Assemble desktop release assets
if: ${{ !cancelled() && needs.build.result == 'success' }}
needs:
- meta
@@ -520,17 +520,8 @@ jobs:
BUILD_VERSION: ${{ needs.meta.outputs.version }}
PUBLIC_BUILD_VERSION: ${{ needs.meta.outputs.version }}
PUB_DATE: ${{ needs.meta.outputs.pub_date }}
TEST_BUILD: ${{ needs.meta.outputs.test_build }}
SOURCE_SHA: ${{ needs.meta.outputs.source_sha }}
S3_DESKTOP_PREFIX: ${{ needs.meta.outputs.s3_prefix }}
DESKTOP_HANDOFF_PREFIX: _handoff/desktop/${{ needs.meta.outputs.build_channel }}/${{ needs.meta.outputs.version }}/${{ needs.meta.outputs.source_sha }}
DESKTOP_RELEASE_ASSETS_PREFIX: _handoff/desktop-release-assets/${{ needs.meta.outputs.build_channel }}/${{ needs.meta.outputs.version }}/${{ needs.meta.outputs.source_sha }}
DESKTOP_METADATA_PREFIX: _handoff/desktop-metadata/${{ needs.meta.outputs.build_channel }}/${{ needs.meta.outputs.version }}/${{ needs.meta.outputs.source_sha }}
S3_ENDPOINT: ${{ vars.DOWNLOADS_S3_ENDPOINT }}
S3_BUCKET: ${{ vars.DOWNLOADS_S3_BUCKET }}
PUBLIC_DL_BASE: https://api.fluxer.app/dl
AWS_ACCESS_KEY_ID: ${{ secrets.DOWNLOADS_AWS_ACCESS_KEY_ID || secrets.AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.DOWNLOADS_AWS_SECRET_ACCESS_KEY || secrets.AWS_SECRET_ACCESS_KEY }}
steps:
- name: Checkout source
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
@@ -542,12 +533,13 @@ jobs:
with:
toolchain: "1.98.1"
- name: Download S3 handoff artifacts
run: >-
cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- build-desktop
--step download_handoff
- name: Download build artifacts
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093
with:
path: artifacts
pattern: fluxer-desktop-${{ needs.meta.outputs.build_channel }}-*
- name: Build S3 payload layout (+ manifest.json)
- name: Build payload layout (+ manifest.json)
env:
VERSION: ${{ needs.meta.outputs.version }}
PUB_DATE: ${{ needs.meta.outputs.pub_date }}
@@ -556,42 +548,27 @@ jobs:
--step build_payload
- name: Prepare GitHub release assets
if: needs.meta.outputs.test_build != 'true'
run: >-
cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- build-desktop
--step prepare_release_assets
- name: Publish GitHub release descriptor
if: needs.meta.outputs.test_build != 'true'
run: >-
cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- build-desktop
--step publish_release_descriptor
- name: Upload payload to S3
run: >-
cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- build-desktop
--step upload_payload
- name: Upload GitHub release asset handoff
if: needs.meta.outputs.test_build != 'true'
run: >-
cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- build-desktop
--step upload_release_assets
- name: Upload GitHub release assets
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02
with:
name: fluxer-desktop-release-assets
path: release_assets
if-no-files-found: error
retention-days: 1
compression-level: 0
- name: Build summary
run: >-
cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- build-desktop
--step build_summary
- name: Cleanup S3 handoff
if: ${{ success() }}
run: >-
cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- build-desktop
--step cleanup_handoff
publish_release:
name: Publish GitHub desktop release
if: ${{ !cancelled() && needs.upload.result == 'success' && needs.meta.outputs.test_build != 'true' }}
if: ${{ !cancelled() && needs.upload.result == 'success' }}
needs:
- meta
- upload
@@ -603,13 +580,6 @@ jobs:
env:
CHANNEL: ${{ needs.meta.outputs.build_channel }}
VERSION: ${{ needs.meta.outputs.version }}
S3_DESKTOP_PREFIX: ${{ needs.meta.outputs.s3_prefix }}
DESKTOP_RELEASE_ASSETS_PREFIX: _handoff/desktop-release-assets/${{ needs.meta.outputs.build_channel }}/${{ needs.meta.outputs.version }}/${{ needs.meta.outputs.source_sha }}
DESKTOP_METADATA_PREFIX: _handoff/desktop-metadata/${{ needs.meta.outputs.build_channel }}/${{ needs.meta.outputs.version }}/${{ needs.meta.outputs.source_sha }}
S3_ENDPOINT: ${{ vars.DOWNLOADS_S3_ENDPOINT }}
S3_BUCKET: ${{ vars.DOWNLOADS_S3_BUCKET }}
AWS_ACCESS_KEY_ID: ${{ secrets.DOWNLOADS_AWS_ACCESS_KEY_ID || secrets.AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.DOWNLOADS_AWS_SECRET_ACCESS_KEY || secrets.AWS_SECRET_ACCESS_KEY }}
steps:
- name: Checkout source
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
@@ -622,9 +592,10 @@ jobs:
toolchain: "1.98.1"
- name: Download GitHub release assets
run: >-
cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- build-desktop
--step download_release_assets
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093
with:
name: fluxer-desktop-release-assets
path: release_assets
- name: Create token
id: create-token
@@ -656,15 +627,3 @@ jobs:
release_args+=(--prerelease)
fi
cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- "${release_args[@]}"
- name: Publish GitHub release readiness marker
env:
SOURCE_SHA: ${{ needs.meta.outputs.source_sha }}
run: >-
cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- build-desktop
--step publish_release_marker
- name: Publish payload metadata to S3
run: >-
cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- build-desktop
--step publish_payload_metadata
-1
View File
@@ -52,7 +52,6 @@ FLUXER_S3_SECRET_ACCESS_KEY=fluxer-secret
FLUXER_S3_FORCE_PATH_STYLE=true
FLUXER_S3_BUCKET_CDN=fluxer
FLUXER_S3_BUCKET_UPLOADS=fluxer-uploads
FLUXER_S3_BUCKET_DOWNLOADS=fluxer-downloads
FLUXER_S3_BUCKET_REPORTS=fluxer-reports
FLUXER_S3_BUCKET_HARVESTS=fluxer-harvests
FLUXER_S3_BUCKET_STATIC=fluxer-static
+6 -1
View File
@@ -82,7 +82,6 @@ MEILI_MASTER_KEY=CHANGE_ME
# exist already.
#FLUXER_S3_BUCKET_CDN=fluxer
#FLUXER_S3_BUCKET_UPLOADS=fluxer-uploads
#FLUXER_S3_BUCKET_DOWNLOADS=fluxer-downloads
#FLUXER_S3_BUCKET_REPORTS=fluxer-reports
#FLUXER_S3_BUCKET_HARVESTS=fluxer-harvests
@@ -104,6 +103,12 @@ MEILI_MASTER_KEY=CHANGE_ME
#FLUXER_NCMEC_ENABLED=false
#FLUXER_CLAMAV_ENABLED=false
# Outside lookups, off unless turned on. The Tor exit list comes from
# onionoo.torproject.org and the breached password check asks
# api.pwnedpasswords.com.
#FLUXER_TOR_EXIT_LIST_ENABLED=true
#FLUXER_BREACHED_PASSWORD_CHECK_ENABLED=true
# The client address. Name the header your proxy actually writes, and turn the
# trust off when nothing sits in front.
#FLUXER_CLIENT_IP_HEADER_NAME=cf-connecting-ip
+3 -3
View File
@@ -24,6 +24,8 @@ x-fluxer-env: &fluxer-env
FLUXER_CLIENT_IP_HEADER_NAME: ${FLUXER_CLIENT_IP_HEADER_NAME:-x-forwarded-for}
FLUXER_API_HEADERS_TIMEOUT_MS: ${FLUXER_API_HEADERS_TIMEOUT_MS:-30000}
FLUXER_API_REQUEST_TIMEOUT_MS: ${FLUXER_API_REQUEST_TIMEOUT_MS:-120000}
FLUXER_TOR_EXIT_LIST_ENABLED: "${FLUXER_TOR_EXIT_LIST_ENABLED:-false}"
FLUXER_BREACHED_PASSWORD_CHECK_ENABLED: "${FLUXER_BREACHED_PASSWORD_CHECK_ENABLED:-false}"
FLUXER_KV_URL: ${FLUXER_KV_URL:-redis://valkey:6379/0}
FLUXER_NATS_URL: ${FLUXER_NATS_URL:-nats://nats:4222}
@@ -44,7 +46,6 @@ x-fluxer-env: &fluxer-env
FLUXER_S3_FORCE_PATH_STYLE: "${FLUXER_S3_FORCE_PATH_STYLE:-true}"
FLUXER_S3_BUCKET_CDN: ${FLUXER_S3_BUCKET_CDN:-fluxer}
FLUXER_S3_BUCKET_UPLOADS: ${FLUXER_S3_BUCKET_UPLOADS:-fluxer-uploads}
FLUXER_S3_BUCKET_DOWNLOADS: ${FLUXER_S3_BUCKET_DOWNLOADS:-fluxer-downloads}
FLUXER_S3_BUCKET_REPORTS: ${FLUXER_S3_BUCKET_REPORTS:-fluxer-reports}
FLUXER_S3_BUCKET_HARVESTS: ${FLUXER_S3_BUCKET_HARVESTS:-fluxer-harvests}
AWS_ACCESS_KEY_ID: ${FLUXER_S3_ACCESS_KEY:?set FLUXER_S3_ACCESS_KEY in .env}
@@ -293,14 +294,13 @@ services:
FLUXER_S3_SECRET_KEY: ${FLUXER_S3_SECRET_KEY:?set FLUXER_S3_SECRET_KEY in .env}
FLUXER_S3_BUCKET_CDN: ${FLUXER_S3_BUCKET_CDN:-fluxer}
FLUXER_S3_BUCKET_UPLOADS: ${FLUXER_S3_BUCKET_UPLOADS:-fluxer-uploads}
FLUXER_S3_BUCKET_DOWNLOADS: ${FLUXER_S3_BUCKET_DOWNLOADS:-fluxer-downloads}
FLUXER_S3_BUCKET_REPORTS: ${FLUXER_S3_BUCKET_REPORTS:-fluxer-reports}
FLUXER_S3_BUCKET_HARVESTS: ${FLUXER_S3_BUCKET_HARVESTS:-fluxer-harvests}
entrypoint:
- /bin/sh
- -c
- >
buckets="$$FLUXER_S3_BUCKET_CDN $$FLUXER_S3_BUCKET_UPLOADS $$FLUXER_S3_BUCKET_DOWNLOADS $$FLUXER_S3_BUCKET_REPORTS $$FLUXER_S3_BUCKET_HARVESTS";
buckets="$$FLUXER_S3_BUCKET_CDN $$FLUXER_S3_BUCKET_UPLOADS $$FLUXER_S3_BUCKET_REPORTS $$FLUXER_S3_BUCKET_HARVESTS";
missing="$$buckets";
for attempt in $$(seq 1 60); do
if ! nc -z seaweedfs 9333 2>/dev/null; then
-3
View File
@@ -15213,7 +15213,6 @@
"required": ["guild_id", "backend"]
}
},
"stereo_enabled": {"type": "boolean"},
"suppression_strength": {"type": "integer", "minimum": 0, "maximum": 100}
}
},
@@ -15288,7 +15287,6 @@
"additionalProperties": false
}
},
"stereo_enabled": {"default": false, "type": "boolean"},
"suppression_strength": {"default": 80, "type": "integer", "minimum": 0, "maximum": 100}
},
"required": [
@@ -15302,7 +15300,6 @@
"included_user_ids",
"excluded_user_ids",
"guild_overrides",
"stereo_enabled",
"suppression_strength"
],
"additionalProperties": false
@@ -492,7 +492,6 @@ pub struct VoiceNoiseSuppressionConfigResponse {
pub included_user_ids: Vec<String>,
pub excluded_user_ids: Vec<String>,
pub guild_overrides: Vec<VoiceNoiseSuppressionGuildOverride>,
pub stereo_enabled: bool,
pub suppression_strength: u32,
}
@@ -509,7 +508,6 @@ impl Default for VoiceNoiseSuppressionConfigResponse {
included_user_ids: Vec::new(),
excluded_user_ids: Vec::new(),
guild_overrides: Vec::new(),
stereo_enabled: false,
suppression_strength: 80,
}
}
@@ -536,8 +534,6 @@ pub struct VoiceNoiseSuppressionConfigUpdateRequest {
#[serde(skip_serializing_if = "Option::is_none")]
pub guild_overrides: Option<Vec<VoiceNoiseSuppressionGuildOverride>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub stereo_enabled: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub suppression_strength: Option<u32>,
}
+28 -35
View File
@@ -447,10 +447,10 @@ fn build_gateway_rollout_update(form: &MultiValueForm) -> InstanceConfigUpdateRe
}
}
const VOICE_NS_ROLLOUT_BASIS_POINTS_MAX: u32 = 10_000;
const EXPERIMENT_ROLLOUT_BASIS_POINTS_MAX: u32 = 10_000;
const VOICE_NS_SUPPRESSION_STRENGTH_MAX: u32 = 100;
const VOICE_NS_MAX_ROLLOUT_SALT_CHARS: usize = 64;
const VOICE_NS_MAX_SNOWFLAKE_LENGTH: usize = 20;
const EXPERIMENT_MAX_ROLLOUT_SALT_CHARS: usize = 64;
const EXPERIMENT_MAX_SNOWFLAKE_LENGTH: usize = 20;
const EXPERIMENT_MIN_POLL_INTERVAL_SECONDS: u64 = 60;
const EXPERIMENT_MAX_POLL_INTERVAL_SECONDS: u64 = 86_400;
const EXPERIMENT_MAX_POLL_JITTER_PERCENT: u32 = 50;
@@ -476,35 +476,36 @@ where
Ok(Some(value))
}
fn parse_voice_noise_suppression_rollout_salt(
fn parse_experiment_rollout_salt(
form: &MultiValueForm,
key: &str,
) -> Result<Option<String>, String> {
let Some(raw) = form.first("voice_ns_rollout_salt") else {
let Some(raw) = form.first(key) else {
return Ok(None);
};
let salt = raw.trim();
if salt.is_empty() || salt.encode_utf16().count() > VOICE_NS_MAX_ROLLOUT_SALT_CHARS {
if salt.is_empty() || salt.encode_utf16().count() > EXPERIMENT_MAX_ROLLOUT_SALT_CHARS {
return Err(format!(
"Rollout salt must be between 1 and {VOICE_NS_MAX_ROLLOUT_SALT_CHARS} characters"
"Rollout salt must be between 1 and {EXPERIMENT_MAX_ROLLOUT_SALT_CHARS} characters"
));
}
Ok(Some(salt.to_owned()))
}
fn is_voice_noise_suppression_snowflake(value: &str) -> bool {
fn is_experiment_snowflake(value: &str) -> bool {
!value.is_empty()
&& value.len() <= VOICE_NS_MAX_SNOWFLAKE_LENGTH
&& value.len() <= EXPERIMENT_MAX_SNOWFLAKE_LENGTH
&& value.bytes().all(|byte| byte.is_ascii_digit())
}
fn parse_voice_noise_suppression_user_ids(value: &str, label: &str) -> Result<Vec<String>, String> {
fn parse_experiment_user_ids(value: &str, label: &str) -> Result<Vec<String>, String> {
let mut ids: Vec<String> = Vec::new();
for (index, candidate) in value.split([',', '\n', '\r']).enumerate() {
let candidate = candidate.trim();
if candidate.is_empty() {
continue;
}
if !is_voice_noise_suppression_snowflake(candidate) {
if !is_experiment_snowflake(candidate) {
return Err(format!(
"{label} entry {} must contain 1 to 20 decimal digits",
index + 1
@@ -536,7 +537,7 @@ fn parse_voice_noise_suppression_guild_overrides(
format!("Guild overrides line {line_number} must use guild_id=backend")
})?;
let guild_id = guild_id.trim();
if !is_voice_noise_suppression_snowflake(guild_id) {
if !is_experiment_snowflake(guild_id) {
return Err(format!(
"Guild overrides line {line_number} must use a guild ID with 1 to 20 decimal digits"
));
@@ -602,21 +603,20 @@ fn build_voice_noise_suppression_update(
"voice_ns_rollout_basis_points",
"Rollout basis points",
0,
VOICE_NS_ROLLOUT_BASIS_POINTS_MAX,
EXPERIMENT_ROLLOUT_BASIS_POINTS_MAX,
)?,
rollout_salt: parse_voice_noise_suppression_rollout_salt(form)?,
included_user_ids: Some(parse_voice_noise_suppression_user_ids(
rollout_salt: parse_experiment_rollout_salt(form, "voice_ns_rollout_salt")?,
included_user_ids: Some(parse_experiment_user_ids(
form.first("voice_ns_included_user_ids").unwrap_or_default(),
"Included user IDs",
)?),
excluded_user_ids: Some(parse_voice_noise_suppression_user_ids(
excluded_user_ids: Some(parse_experiment_user_ids(
form.first("voice_ns_excluded_user_ids").unwrap_or_default(),
"Excluded user IDs",
)?),
guild_overrides: Some(parse_voice_noise_suppression_guild_overrides(
form.first("voice_ns_guild_overrides").unwrap_or_default(),
)?),
stereo_enabled: Some(form.bool_value("voice_ns_stereo_enabled")),
suppression_strength: parse_form_number(
form,
"voice_ns_suppression_strength",
@@ -1246,7 +1246,6 @@ mod tests {
.expect("voice noise suppression update");
assert_eq!(update.enabled, Some(true));
assert_eq!(update.allow_user_override, Some(true));
assert_eq!(update.stereo_enabled, Some(false));
assert_eq!(
update.default_backend,
Some(NoiseSuppressionBackend::Rnnoise)
@@ -1272,7 +1271,6 @@ mod tests {
serde_json::json!({"voice_noise_suppression": {
"enabled": false,
"allow_user_override": false,
"stereo_enabled": false,
"enabled_backends": [],
"included_user_ids": [],
"excluded_user_ids": [],
@@ -1307,9 +1305,9 @@ mod tests {
}
#[test]
fn parse_voice_noise_suppression_user_ids_splits_newlines_and_commas() {
fn parse_experiment_user_ids_splits_newlines_and_commas() {
assert_eq!(
parse_voice_noise_suppression_user_ids(" 1 ,2\n3\r\n 4 ,, 5 ", "Included user IDs")
parse_experiment_user_ids(" 1 ,2\n3\r\n 4 ,, 5 ", "Included user IDs")
.expect("valid IDs"),
vec![
"1".to_owned(),
@@ -1322,16 +1320,15 @@ mod tests {
}
#[test]
fn parse_voice_noise_suppression_user_ids_dedupes_preserving_order() {
fn parse_experiment_user_ids_dedupes_preserving_order() {
assert_eq!(
parse_voice_noise_suppression_user_ids("20,10,20,10,30", "Included user IDs")
.expect("valid IDs"),
parse_experiment_user_ids("20,10,20,10,30", "Included user IDs").expect("valid IDs"),
vec!["20".to_owned(), "10".to_owned(), "30".to_owned()]
);
}
#[test]
fn parse_voice_noise_suppression_user_ids_rejects_non_digit_and_overlong_values() {
fn parse_experiment_user_ids_rejects_non_digit_and_overlong_values() {
for value in [
"abc",
"12a",
@@ -1341,11 +1338,8 @@ mod tests {
"<script>",
] {
assert_eq!(
parse_voice_noise_suppression_user_ids(
&format!("123,{value}"),
"Included user IDs"
)
.expect_err("invalid ID"),
parse_experiment_user_ids(&format!("123,{value}"), "Included user IDs")
.expect_err("invalid ID"),
"Included user IDs entry 2 must contain 1 to 20 decimal digits",
"{value}"
);
@@ -1353,18 +1347,17 @@ mod tests {
}
#[test]
fn parse_voice_noise_suppression_user_ids_rejects_exceeding_the_cap() {
fn parse_experiment_user_ids_rejects_exceeding_the_cap() {
let value = (0..VOICE_NS_MAX_TARGETED_USERS)
.map(|index| index.to_string())
.collect::<Vec<_>>()
.join("\n");
let ids =
parse_voice_noise_suppression_user_ids(&format!("{value}\n999"), "Included user IDs")
.expect("valid IDs at cap");
let ids = parse_experiment_user_ids(&format!("{value}\n999"), "Included user IDs")
.expect("valid IDs at cap");
assert_eq!(ids.len(), VOICE_NS_MAX_TARGETED_USERS);
assert_eq!(ids.last(), Some(&"999".to_owned()));
assert_eq!(
parse_voice_noise_suppression_user_ids(&format!("{value}\n1000"), "Included user IDs")
parse_experiment_user_ids(&format!("{value}\n1000"), "Included user IDs")
.expect_err("too many IDs"),
"Included user IDs must contain at most 1000 unique IDs"
);
@@ -1157,17 +1157,6 @@ fn voice_noise_suppression_section(
}
h3 class="text-sm font-semibold text-neutral-900" { "Processing" }
(checkbox(
"voice_ns_stereo_enabled",
"true",
"Process stereo input instead of downmixing to mono",
voice_noise_suppression.stereo_enabled,
true,
))
p class="text-xs text-neutral-500" {
"Costs more CPU on the client. Leave off unless you are testing stereo \
capture."
}
div class="grid grid-cols-1 gap-4 sm:grid-cols-2" {
(number_field(
"voice_ns_suppression_strength",
@@ -403,7 +403,6 @@ fn deserialize_instance_config_response_with_unknown_keys() {
"included_user_ids": [],
"excluded_user_ids": [],
"guild_overrides": [],
"stereo_enabled": false,
"suppression_strength": 80,
"future_presentation_knob": "verbose",
"future_knob": 7,
-1
View File
@@ -1194,7 +1194,6 @@ fn instance_config() -> Value {
"included_user_ids": [],
"excluded_user_ids": [],
"guild_overrides": [],
"stereo_enabled": false,
"suppression_strength": 80
},
"experiment_delivery": {
+1
View File
@@ -49,6 +49,7 @@ export async function createAPIApp(options: CreateAPIAppOptions): Promise<APIApp
trustClientIpHeader: config.proxy.trust_client_ip_header,
clientIpHeaderName: config.proxy.client_ip_header,
maxInflightRequests: config.maxInflightRequests,
torExitBlockingEnabled: config.torExitList.enabled,
});
routes.onError(AbuseAwareAppErrorHandler);
routes.notFound(AppNotFoundHandler);
+54
View File
@@ -166,3 +166,57 @@ describe('buildAPIConfigFromMaster stripe legacy prices', () => {
expect(buildAPIConfigFromMaster(withStripeLegacyPrices(master, undefined)).stripe.legacyPrices).toBeUndefined();
});
});
function withOptionalOutboundLookups(
master: MasterConfig,
selfHosted: boolean,
overrides: {torExitList?: boolean; breachedPasswordCheck?: boolean} = {},
): MasterConfig {
return {
...master,
integrations: {
...master.integrations,
tor_exit_list: {enabled: overrides.torExitList},
breached_password_check: {enabled: overrides.breachedPasswordCheck},
},
instance: {
...master.instance,
self_hosted: selfHosted,
},
};
}
describe('buildAPIConfigFromMaster optional outbound lookups', () => {
let master: MasterConfig;
beforeAll(async () => {
master = await loadConfig();
});
it('keeps both lookups on when the instance is not self-hosted', () => {
const config = buildAPIConfigFromMaster(withOptionalOutboundLookups(master, false));
expect(config.torExitList.enabled).toBe(true);
expect(config.breachedPasswordCheck.enabled).toBe(true);
});
it('leaves both lookups off on a self-hosted instance', () => {
const config = buildAPIConfigFromMaster(withOptionalOutboundLookups(master, true));
expect(config.torExitList.enabled).toBe(false);
expect(config.breachedPasswordCheck.enabled).toBe(false);
});
it('lets a self-hosted operator switch each lookup on', () => {
const config = buildAPIConfigFromMaster(
withOptionalOutboundLookups(master, true, {torExitList: true, breachedPasswordCheck: true}),
);
expect(config.torExitList.enabled).toBe(true);
expect(config.breachedPasswordCheck.enabled).toBe(true);
});
it('lets an operator switch each lookup off when the instance is not self-hosted', () => {
const config = buildAPIConfigFromMaster(
withOptionalOutboundLookups(master, false, {torExitList: false, breachedPasswordCheck: false}),
);
expect(config.torExitList.enabled).toBe(false);
expect(config.breachedPasswordCheck.enabled).toBe(false);
});
});
+6 -20
View File
@@ -3,7 +3,6 @@
import type {APIConfig, BlueskyOAuthConfig} from '@app/api/config/APIConfig';
import type {WorkerTaskName} from '@app/api/worker/WorkerLaneConfig';
import type {MasterConfig} from '@fluxer/config/src/MasterConfig';
import {resolveDownloadsProvider} from '@fluxer/config/src/S3DownloadsProvider';
import {parseIpAddress} from '@fluxer/ip_utils/src/IpAddress';
import {parseGeoipSourceConfig, resolveGeoipRuntimeSourceConfig} from '@pkgs/geoip/src/GeoipStartup';
@@ -92,18 +91,6 @@ function normalizeIpBanExemptIps(values: Array<string>): Array<string> {
return Array.from(normalized);
}
function normalizeCountryCodes(values: Array<string>, configName: string): ReadonlySet<string> {
const normalized = new Set<string>();
for (const value of values) {
const countryCode = value.trim().toUpperCase();
if (!/^[A-Z]{2}$/u.test(countryCode)) {
throw new Error(`${configName} contains an invalid ISO 3166-1 alpha-2 country code: ${value}`);
}
normalized.add(countryCode);
}
return normalized;
}
function mapPushProviderApps(
apps:
| Array<{
@@ -157,7 +144,6 @@ export function buildAPIConfigFromMaster(master: MasterConfig): APIConfig {
const s3Buckets = s3Config.buckets ?? {
cdn: '',
uploads: '',
downloads: '',
reports: '',
harvests: '',
};
@@ -174,10 +160,6 @@ export function buildAPIConfigFromMaster(master: MasterConfig): APIConfig {
requestTimeoutMs: master.services.api.request_timeout_ms,
maxInflightRequests: master.services.api.max_inflight_requests,
ipBanExemptIps: normalizeIpBanExemptIps(master.services.api.ip_ban_exempt_ips),
desktopGitHubRedirectCountries: normalizeCountryCodes(
master.services.api.desktop_github_redirect_countries,
'FLUXER_API_DESKTOP_GITHUB_REDIRECT_COUNTRIES',
),
cassandra: {
hosts: cassandraSource?.hosts.join(',') ?? '',
port: cassandraSource?.port ?? 9042,
@@ -304,7 +286,6 @@ export function buildAPIConfigFromMaster(master: MasterConfig): APIConfig {
cacheMinTtlSeconds: master.services.api.embeds.cache_min_ttl_seconds,
cacheRespectRemoteTtl: master.services.api.embeds.cache_respect_remote_ttl,
},
s3Downloads: resolveDownloadsProvider(master),
s3: {
endpoint: s3Config.endpoint,
presignedUrlBase: s3Config.presigned_url_base,
@@ -348,6 +329,12 @@ export function buildAPIConfigFromMaster(master: MasterConfig): APIConfig {
blocklistFeeds: {
enabled: master.integrations.blocklist_feeds.enabled ?? !master.instance.self_hosted,
},
torExitList: {
enabled: master.integrations.tor_exit_list.enabled ?? !master.instance.self_hosted,
},
breachedPasswordCheck: {
enabled: master.integrations.breached_password_check.enabled ?? !master.instance.self_hosted,
},
captcha: {
enabled: master.integrations.captcha.enabled,
provider: master.integrations.captcha.provider,
@@ -517,7 +504,6 @@ export function buildAPIConfigFromMaster(master: MasterConfig): APIConfig {
validateResponses: resolveValidateResponses(master),
},
presignedAttachmentUploadsEnabled: master.services.api.presigned_attachment_uploads_enabled ?? false,
presignedDownloadsEnabled: master.services.api.presigned_downloads_enabled ?? false,
presignedHarvestDownloadsEnabled: master.services.api.presigned_harvest_downloads_enabled ?? true,
attachmentDecayEnabled: master.attachment_decay_enabled,
deletionGracePeriodHours: master.dev.test_mode_enabled ? 0.01 : master.deletion_grace_period_hours,
@@ -1,14 +1,13 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {UserID} from '@app/api/BrandedTypes';
import {mapGuildMemberToResponse} from '@app/api/guild/GuildModel';
import type {IGuildRepositoryAggregate} from '@app/api/guild/repositories/IGuildRepositoryAggregate';
import type {IGatewayService} from '@app/api/infrastructure/IGatewayService';
import type {UserCacheService} from '@app/api/infrastructure/UserCacheService';
import {createRequestCache} from '@app/api/middleware/RequestCacheMiddleware';
import type {User} from '@app/api/models/User';
import type {IUserRepository} from '@app/api/user/IUserRepository';
import {BaseUserUpdatePropagator} from '@app/api/user/services/BaseUserUpdatePropagator';
import {propagatePartialUserChange} from '@app/api/user/services/PartialUserChangePropagation';
import {hasPartialUserFieldsChanged} from '@app/api/user/UserMappers';
interface AdminUserUpdatePropagatorDeps {
@@ -26,41 +25,11 @@ export class AdminUserUpdatePropagator extends BaseUserUpdatePropagator {
});
}
async propagateUserUpdate({
userId,
oldUser,
updatedUser,
}: {
userId: UserID;
oldUser: User;
updatedUser: User;
}): Promise<void> {
async propagateUserUpdate(params: {userId: UserID; oldUser: User; updatedUser: User}): Promise<void> {
const {oldUser, updatedUser} = params;
await this.dispatchUserUpdate(updatedUser);
if (hasPartialUserFieldsChanged(oldUser, updatedUser)) {
await this.updateUserCache(updatedUser);
await this.propagateToGuilds(userId);
await propagatePartialUserChange(this.deps, updatedUser);
}
}
private async propagateToGuilds(userId: UserID): Promise<void> {
const {userRepository, guildRepository, gatewayService, userCacheService} = this.deps;
const guildIds = await userRepository.getUserGuildIds(userId);
if (guildIds.length === 0) {
return;
}
const requestCache = createRequestCache();
for (const guildId of guildIds) {
const member = await guildRepository.getMember(guildId, userId);
if (!member) {
continue;
}
const memberResponse = await mapGuildMemberToResponse(member, userCacheService, requestCache);
await gatewayService.dispatchGuild({
guildId,
event: 'GUILD_MEMBER_UPDATE',
data: memberResponse,
});
}
requestCache.clear();
}
}
+5 -3
View File
@@ -137,9 +137,11 @@ export function createInitializer(config: APIConfig, logger: ILogger): () => Pro
await initializeRefreshCache(ipBanCache, 'IP ban cache', logger);
await startAbuseReplicationSubscriber(kvClient);
logger.info('Abusive-IP auto-banner replication started');
torExitListCache.setKvClient(kvClient);
await torExitListCache.initialize();
logger.info('Tor exit list cache initialized');
if (config.torExitList.enabled) {
torExitListCache.setKvClient(kvClient);
await torExitListCache.initialize();
logger.info('Tor exit list cache initialized');
}
const {urlBlocklistCache} = await import('@app/api/middleware/UrlBlocklistCache');
urlBlocklistCache.setRefreshSubscriber(kvClient);
const {getStorageService} = await import('@app/api/middleware/ServiceSingletons');
+13 -2
View File
@@ -29,10 +29,19 @@ interface MiddlewarePipelineOptions {
trustClientIpHeader: boolean;
clientIpHeaderName?: string;
maxInflightRequests: number;
torExitBlockingEnabled: boolean;
}
export function configureMiddleware(routes: HonoApp, options: MiddlewarePipelineOptions): void {
const {logger, nodeEnv, corsOrigins, trustClientIpHeader, clientIpHeaderName, maxInflightRequests} = options;
const {
logger,
nodeEnv,
corsOrigins,
trustClientIpHeader,
clientIpHeaderName,
maxInflightRequests,
torExitBlockingEnabled,
} = options;
const resolvedHeader = resolveClientIpHeaderName(clientIpHeaderName);
routes.use('/webhooks/:webhook_id/:token', cors({origins: '*'}));
routes.use('/webhooks/:webhook_id/:token/messages/:message_id', cors({origins: '*'}));
@@ -100,7 +109,9 @@ export function configureMiddleware(routes: HonoApp, options: MiddlewarePipeline
}),
);
}
routes.use(TorExitMiddleware);
if (torExitBlockingEnabled) {
routes.use(TorExitMiddleware);
}
routes.use(AuditLogMiddleware);
routes.use(RequireClientIpMiddleware());
routes.use(ServiceMiddleware);
@@ -0,0 +1,32 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {configureMiddleware} from '@app/api/app/MiddlewarePipeline';
import {TorExitMiddleware} from '@app/api/middleware/TorExitMiddleware';
import {NoopLogger} from '@app/api/test/mocks/NoopLogger';
import type {HonoEnv} from '@app/api/types/HonoEnv';
import {Hono} from 'hono';
import {describe, expect, it} from 'vitest';
function registeredHandlers(torExitBlockingEnabled: boolean): Array<unknown> {
const routes = new Hono<HonoEnv>({strict: true});
configureMiddleware(routes, {
logger: new NoopLogger(),
nodeEnv: 'test',
corsOrigins: ['http://localhost:3000'],
trustClientIpHeader: true,
clientIpHeaderName: 'x-forwarded-for',
maxInflightRequests: 100,
torExitBlockingEnabled,
});
return routes.routes.map((route) => route.handler);
}
describe('tor exit blocking in the middleware pipeline', () => {
it('registers the tor exit middleware when the switch is on', () => {
expect(registeredHandlers(true)).toContain(TorExitMiddleware);
});
it('leaves the tor exit middleware unregistered when the switch is off', () => {
expect(registeredHandlers(false)).not.toContain(TorExitMiddleware);
});
});
+4
View File
@@ -5,6 +5,7 @@ import type {ApiContext} from '@app/api/ApiContext';
import * as AuthSession from '@app/api/auth/AuthSession';
import * as AuthUtility from '@app/api/auth/AuthUtility';
import {createMfaTicket, createPasswordResetToken} from '@app/api/BrandedTypes';
import {Config} from '@app/api/Config';
import {Logger} from '@app/api/Logger';
import type {User} from '@app/api/models/User';
import {EXTERNAL_RESPONSE_LIMITS} from '@app/api/utils/ExternalResponseLimits';
@@ -116,6 +117,9 @@ export async function verifyPassword(
}
export async function isPasswordPwned(_ctx: ApiContext, password: string): Promise<boolean> {
if (!Config.breachedPasswordCheck.enabled) {
return false;
}
const hashed = crypto.createHash('sha1').update(password).digest('hex').toUpperCase();
const hashPrefix = hashed.slice(0, 5);
const hashSuffix = hashed.slice(5);
@@ -3,6 +3,7 @@
import crypto from 'node:crypto';
import type {ApiContext} from '@app/api/ApiContext';
import {isPasswordPwned, resetPwnedPasswordCacheForTesting} from '@app/api/auth/AuthPassword';
import {getConfig} from '@app/api/Config';
import {server} from '@app/api/test/msw/server';
import {delay, HttpResponse, http} from 'msw';
import {beforeEach, describe, expect, test} from 'vitest';
@@ -68,6 +69,19 @@ describe('isPasswordPwned', () => {
await expect(isPasswordPwned(ctx, SAFE_PASSWORD_SAME_PREFIX)).resolves.toBe(false);
expect(requestedPrefixes).toHaveLength(1);
});
test('makes no upstream call when the check is switched off', async () => {
const config = getConfig();
const originalEnabled = config.breachedPasswordCheck.enabled;
const requestedPrefixes: Array<string> = [];
server.use(rangeHandler(requestedPrefixes, [suffixOf(PWNED_PASSWORD)]));
try {
config.breachedPasswordCheck.enabled = false;
await expect(isPasswordPwned(ctx, PWNED_PASSWORD)).resolves.toBe(false);
expect(requestedPrefixes).toHaveLength(0);
} finally {
config.breachedPasswordCheck.enabled = originalEnabled;
}
});
test('fails open on a non-OK response', async () => {
server.use(http.get('https://api.pwnedpasswords.com/range/:prefix', () => HttpResponse.text('', {status: 503})));
await expect(isPasswordPwned(ctx, PWNED_PASSWORD)).resolves.toBe(false);
+6 -5
View File
@@ -2,7 +2,6 @@
import type {WorkerTaskName} from '@app/api/worker/WorkerLaneConfig';
import type {CachePurgeAdapterName} from '@fluxer/config/src/MasterConfig';
import type {ResolvedDownloadsProvider} from '@fluxer/config/src/S3DownloadsProvider';
export type APIWorkerMode = 'all_lanes' | 'single_lane' | 'single_task';
export type APIWorkerLaneName = 'realtime' | 'unfurl' | 'lifecycle' | 'batch';
@@ -48,7 +47,6 @@ export interface APIConfig {
requestTimeoutMs: number;
maxInflightRequests: number;
ipBanExemptIps: Array<string>;
desktopGitHubRedirectCountries: ReadonlySet<string>;
cassandra: {
hosts: string;
port: number;
@@ -171,10 +169,8 @@ export interface APIConfig {
uploads: string;
reports: string;
harvests: string;
downloads: string;
};
};
s3Downloads: ResolvedDownloadsProvider;
email: {
enabled: boolean;
provider: 'smtp' | 'none';
@@ -207,6 +203,12 @@ export interface APIConfig {
blocklistFeeds: {
enabled: boolean;
};
torExitList: {
enabled: boolean;
};
breachedPasswordCheck: {
enabled: boolean;
};
captcha: {
enabled: boolean;
provider: 'hcaptcha' | 'turnstile' | 'none';
@@ -362,7 +364,6 @@ export interface APIConfig {
validateResponses: boolean;
};
presignedAttachmentUploadsEnabled: boolean;
presignedDownloadsEnabled: boolean;
presignedHarvestDownloadsEnabled: boolean;
attachmentDecayEnabled: boolean;
deletionGracePeriodHours: number;
@@ -1,201 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {isJsonRecord} from '@app/api/utils/JsonBoundaryUtils';
import type {DesktopArch, DesktopChannel, DesktopPlatform} from '@fluxer/schema/src/domains/download/DownloadSchemas';
const DESKTOP_BUCKET_PREFIX = 'desktop';
const MIN_RELEASE_ROUTE_COUNT = 28;
const MAX_RELEASE_ROUTE_COUNT = 128;
const MIN_RELEASE_ASSET_COUNT = 24;
interface DesktopReleaseAsset {
storage_key: string;
release_asset: string;
sha256: string;
size: number;
}
interface DesktopReleaseDescriptor {
schema_version: 1;
channel: DesktopChannel;
version: string;
release_tag: string;
source_sha: string;
assets: Array<DesktopReleaseAsset>;
}
interface DesktopReleaseReadiness {
schema_version: 1;
channel: DesktopChannel;
version: string;
release_tag: string;
source_sha: string;
descriptor_sha256: string;
}
interface DesktopArtifactScope {
channel: DesktopChannel;
plat: DesktopPlatform;
arch: DesktopArch;
}
export function parseDesktopArtifactScope(key: string): DesktopArtifactScope | null {
const segments = key.split('/');
if (segments.length !== 5 || segments[0] !== DESKTOP_BUCKET_PREFIX || segments[4].length === 0) {
return null;
}
const [, channel, plat, arch] = segments;
if (
(channel !== 'stable' && channel !== 'canary') ||
(plat !== 'win32' && plat !== 'darwin' && plat !== 'linux') ||
(arch !== 'x64' && arch !== 'arm64')
) {
return null;
}
return {channel, plat, arch};
}
function parseDesktopReleaseAsset(value: unknown): DesktopReleaseAsset | null {
if (
!isJsonRecord(value) ||
typeof value.storage_key !== 'string' ||
typeof value.release_asset !== 'string' ||
typeof value.sha256 !== 'string' ||
typeof value.size !== 'number'
) {
return null;
}
if (
!/^desktop\/(?:stable|canary)\/(?:win32|darwin|linux)\/(?:x64|arm64)\/[A-Za-z0-9._-]+$/u.test(value.storage_key) ||
!/^[A-Za-z0-9._-]+$/u.test(value.release_asset) ||
!/^[a-f0-9]{64}$/u.test(value.sha256) ||
!Number.isSafeInteger(value.size) ||
value.size <= 0
) {
return null;
}
return {
storage_key: value.storage_key,
release_asset: value.release_asset,
sha256: value.sha256,
size: value.size,
};
}
export function parseDesktopReleaseDescriptor(value: unknown): DesktopReleaseDescriptor | null {
if (
!isJsonRecord(value) ||
value.schema_version !== 1 ||
(value.channel !== 'stable' && value.channel !== 'canary') ||
typeof value.version !== 'string' ||
!/^\d+\.\d+\.\d+$/u.test(value.version) ||
typeof value.release_tag !== 'string' ||
typeof value.source_sha !== 'string' ||
!/^[a-f0-9]{40}$/u.test(value.source_sha) ||
!Array.isArray(value.assets) ||
value.assets.length < MIN_RELEASE_ROUTE_COUNT ||
value.assets.length > MAX_RELEASE_ROUTE_COUNT
) {
return null;
}
const expectedTag = `fluxer-desktop-${value.channel}@${value.version}`;
const expectedStoragePrefix = `desktop/${value.channel}/`;
const expectedReleasePrefix = `${value.channel === 'canary' ? 'Fluxer-Canary' : 'Fluxer'}-${value.version}-`;
const descriptorName = `${expectedReleasePrefix}release-manifest.json`;
if (value.release_tag !== expectedTag) {
return null;
}
const storageKeys = new Set<string>();
const routeCounts = new Map<string, number>();
const releaseAssets = new Map<string, {sha256: string; size: number}>();
const releaseAssetNames = new Map<string, string>([[descriptorName.toLowerCase(), descriptorName]]);
const assets: Array<DesktopReleaseAsset> = [];
for (const rawAsset of value.assets) {
const asset = parseDesktopReleaseAsset(rawAsset);
if (
!asset?.storage_key.startsWith(expectedStoragePrefix) ||
!asset.release_asset.startsWith(expectedReleasePrefix) ||
storageKeys.has(asset.storage_key)
) {
return null;
}
storageKeys.add(asset.storage_key);
const [, , platform, arch, filename] = asset.storage_key.split('/');
const platformToken = platform === 'win32' ? 'win' : platform === 'darwin' ? 'mac' : 'linux';
const releaseFilename =
platform === 'darwin' && filename.toLowerCase() === 'releases.json' ? 'releases.json' : filename;
const expectedReleaseAsset = filename.startsWith(expectedReleasePrefix)
? filename
: `${expectedReleasePrefix}${platformToken}-${arch}-${releaseFilename}`;
if (
asset.release_asset !== expectedReleaseAsset ||
asset.release_asset.toLowerCase() === descriptorName.toLowerCase()
) {
return null;
}
const caseFoldedReleaseAsset = asset.release_asset.toLowerCase();
const existingReleaseAssetName = releaseAssetNames.get(caseFoldedReleaseAsset);
if (existingReleaseAssetName && existingReleaseAssetName !== asset.release_asset) {
return null;
}
releaseAssetNames.set(caseFoldedReleaseAsset, asset.release_asset);
const scope = `${platform}/${arch}`;
routeCounts.set(scope, (routeCounts.get(scope) ?? 0) + 1);
const existing = releaseAssets.get(asset.release_asset);
if (existing && (existing.sha256 !== asset.sha256 || existing.size !== asset.size)) {
return null;
}
releaseAssets.set(asset.release_asset, {sha256: asset.sha256, size: asset.size});
assets.push(asset);
}
if (releaseAssets.size < MIN_RELEASE_ASSET_COUNT || releaseAssets.size > MAX_RELEASE_ROUTE_COUNT) {
return null;
}
const expectedRouteCounts = new Map([
['darwin/arm64', 4],
['darwin/x64', 4],
['linux/arm64', 4],
['linux/x64', 4],
['win32/arm64', 6],
['win32/x64', 6],
]);
if (
routeCounts.size !== expectedRouteCounts.size ||
Array.from(expectedRouteCounts).some(([scope, count]) => (routeCounts.get(scope) ?? 0) < count)
) {
return null;
}
return {
schema_version: 1,
channel: value.channel,
version: value.version,
release_tag: value.release_tag,
source_sha: value.source_sha,
assets,
};
}
export function parseDesktopReleaseReadiness(value: unknown): DesktopReleaseReadiness | null {
if (
!isJsonRecord(value) ||
value.schema_version !== 1 ||
(value.channel !== 'stable' && value.channel !== 'canary') ||
typeof value.version !== 'string' ||
!/^\d+\.\d+\.\d+$/u.test(value.version) ||
typeof value.release_tag !== 'string' ||
typeof value.source_sha !== 'string' ||
!/^[a-f0-9]{40}$/u.test(value.source_sha) ||
typeof value.descriptor_sha256 !== 'string' ||
!/^[a-f0-9]{64}$/u.test(value.descriptor_sha256)
) {
return null;
}
return {
schema_version: 1,
channel: value.channel,
version: value.version,
release_tag: value.release_tag,
source_sha: value.source_sha,
descriptor_sha256: value.descriptor_sha256,
};
}
+27 -322
View File
@@ -1,370 +1,75 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {Readable} from 'node:stream';
import {Config} from '@app/api/Config';
import {resolveArtifactRoute} from '@app/api/download/DownloadRouting';
import type {DesktopChecksumFile, DownloadService, DownloadStreamResult} from '@app/api/download/DownloadService';
import {
DESKTOP_REDIRECT_PREFIX,
DOWNLOAD_PREFIX,
downloadCacheControlForKey,
UnsatisfiableRangeError,
} from '@app/api/download/DownloadService';
import {OpenAPI} from '@app/api/middleware/ResponseTypeMiddleware';
import {DESKTOP_REDIRECT_PREFIX, DOWNLOAD_PREFIX, resolveDownloadRedirect} from '@app/api/download/DownloadRedirects';
import type {HonoEnv} from '@app/api/types/HonoEnv';
import {Validator} from '@app/api/Validator';
import {
DesktopChecksumRedirectParam,
DesktopRedirectParam,
DesktopTestBuildQuery,
DesktopVersionedChecksumRedirectParam,
DesktopVersionedRedirectParam,
DesktopVersionedZsyncRedirectParam,
DesktopVersionsParam,
DesktopVersionsQuery,
DesktopVersionsResponse,
DownloadChecksumResponse,
DownloadFileResponse,
VersionInfoResponse,
DesktopZsyncRedirectParam,
} from '@fluxer/schema/src/domains/download/DownloadSchemas';
import type {Context, Hono} from 'hono';
function artifactFilename(key: string, filenameOverride?: string): string {
return filenameOverride ?? key.split('/').pop() ?? 'download';
}
function artifactRedirectResponse(location: string, cacheControl = 'no-store'): Response {
function redirectToPackageOrigin(ctx: Context<HonoEnv>): Response {
const redirect = resolveDownloadRedirect(ctx.req.path);
if (!redirect) {
return ctx.text('Not Found', 404);
}
return new Response(null, {
status: 302,
headers: new Headers({
Location: location,
'Cache-Control': cacheControl,
Location: redirect.location,
'Cache-Control': redirect.cacheControl,
'Accept-Ranges': 'bytes',
}),
});
}
function setCommonArtifactHeaders(
headers: Headers,
key: string,
cacheControl: string,
filenameOverride: string | undefined,
contentType: string | null | undefined,
contentDisposition: string | null | undefined,
etag: string | null | undefined,
lastModified: Date | null | undefined,
): void {
const filename = artifactFilename(key, filenameOverride);
headers.set('Content-Type', contentType ?? 'application/octet-stream');
headers.set('Content-Disposition', contentDisposition ?? `attachment; filename="${encodeURIComponent(filename)}"`);
headers.set('Accept-Ranges', 'bytes');
headers.set('Cache-Control', cacheControl);
if (etag) {
headers.set('ETag', etag);
}
if (lastModified) {
headers.set('Last-Modified', lastModified.toUTCString());
}
}
async function headArtifactResponse(
ctx: Context<HonoEnv>,
downloadService: DownloadService,
key: string,
cacheControl: string,
filenameOverride?: string,
): Promise<Response> {
const metadata = await downloadService.getDownloadMetadata({key});
if (!metadata) {
return ctx.text('Not Found', 404);
}
const headers = new Headers();
setCommonArtifactHeaders(
headers,
key,
cacheControl,
filenameOverride,
metadata.contentType,
undefined,
metadata.etag,
metadata.lastModified,
);
headers.set('Content-Length', String(metadata.contentLength));
return new Response(null, {status: 200, headers});
}
const PRESIGNED_DOWNLOAD_TTL_SECONDS = 900;
async function streamArtifactResponse(
ctx: Context<HonoEnv>,
downloadService: DownloadService,
key: string,
cacheControl: string,
filenameOverride?: string,
): Promise<Response> {
const route = await resolveArtifactRoute({request: ctx.req.raw, downloadService, key, cacheControl});
if (route.kind === 'redirect') {
return artifactRedirectResponse(route.location, route.cacheControl);
}
if (ctx.req.method === 'HEAD') {
return headArtifactResponse(ctx, downloadService, key, route.cacheControl, filenameOverride);
}
if (downloadService.isPresignedDownloadEnabled()) {
const location = await downloadService.getPresignedDownloadRedirect({
key,
filename: artifactFilename(key, filenameOverride),
expiresIn: PRESIGNED_DOWNLOAD_TTL_SECONDS,
});
if (!location) {
return ctx.text('Not Found', 404);
}
return artifactRedirectResponse(location);
}
const range = ctx.req.header('range') ?? undefined;
let result: DownloadStreamResult | null;
try {
result = await downloadService.streamDownload({key, range});
} catch (error) {
if (error instanceof UnsatisfiableRangeError) {
const headers = new Headers();
headers.set('Accept-Ranges', 'bytes');
headers.set('Content-Range', `bytes */${error.totalSize}`);
headers.set('Cache-Control', route.cacheControl);
return new Response(null, {status: 416, headers});
}
throw error;
}
if (!result) {
return ctx.text('Not Found', 404);
}
const headers = new Headers();
setCommonArtifactHeaders(
headers,
key,
route.cacheControl,
filenameOverride,
result.contentType,
result.contentDisposition,
result.etag,
result.lastModified,
);
headers.set('Content-Length', String(result.contentLength));
if (result.contentRange) {
headers.set('Content-Range', result.contentRange);
}
const body = Readable.toWeb(result.body) as ReadableStream;
return new Response(body, {status: result.contentRange ? 206 : 200, headers});
}
function checksumFileResponse(ctx: Context<HonoEnv>, checksum: DesktopChecksumFile, cacheControl: string): Response {
const headers = new Headers();
const body = ctx.req.method === 'HEAD' ? null : checksum.body;
headers.set('Content-Type', 'text/plain; charset=utf-8');
headers.set('Content-Disposition', `attachment; filename="${encodeURIComponent(`${checksum.filename}.sha256`)}"`);
headers.set('Cache-Control', cacheControl);
headers.set('Content-Length', String(new TextEncoder().encode(checksum.body).byteLength));
return new Response(body, {status: 200, headers});
}
export function DownloadController(routes: Hono<HonoEnv>): void {
routes.get(
`${DESKTOP_REDIRECT_PREFIX}/:channel/:plat/:arch/latest`,
Validator('param', DesktopVersionsParam),
Validator('query', DesktopTestBuildQuery),
OpenAPI({
operationId: 'get_latest_desktop_version',
summary: 'Get latest desktop version',
responseSchema: VersionInfoResponse,
statusCode: 200,
security: [],
tags: ['Downloads'],
description:
'Returns metadata for the latest desktop version including download URLs and SHA-256 checksums for all available formats. Pass ?test=1 to resolve against unreleased test builds.',
}),
async (ctx) => {
const {channel, plat, arch} = ctx.req.valid('param');
const {test} = ctx.req.valid('query');
const result = await ctx.get('downloadService').getLatestDesktopVersion({
channel,
plat,
arch,
baseUrl: Config.endpoints.apiClient,
test,
});
if (!result) {
return ctx.text('Not Found', 404);
}
return ctx.json(result, 200, {
'Cache-Control': 'public, max-age=300',
});
},
async (ctx) => redirectToPackageOrigin(ctx),
);
routes.on(
['GET', 'HEAD'],
`${DESKTOP_REDIRECT_PREFIX}/:channel/:plat/:arch/latest/:format{[a-z_]+\\.sha256}`,
Validator('param', DesktopChecksumRedirectParam),
Validator('query', DesktopTestBuildQuery),
OpenAPI({
operationId: 'download_latest_desktop_version_checksum',
summary: 'Download latest desktop version checksum',
responseSchema: DownloadChecksumResponse,
responseContentType: 'text/plain',
statusCode: 200,
security: [],
tags: ['Downloads'],
description:
'Returns a plain text SHA-256 checksum file for the latest available desktop application version. The format path segment must end in .sha256, for example appimage.sha256.',
}),
async (ctx) => {
const {channel, plat, arch, format} = ctx.req.valid('param');
const {test} = ctx.req.valid('query');
const checksum = await ctx
.get('downloadService')
.resolveLatestDesktopChecksumFile({channel, plat, arch, format, test});
if (!checksum) {
return ctx.text('Not Found', 404);
}
return checksumFileResponse(ctx, checksum, 'no-store');
},
async (ctx) => redirectToPackageOrigin(ctx),
);
routes.on(
['GET', 'HEAD'],
`${DESKTOP_REDIRECT_PREFIX}/:channel/:plat/:arch/latest/:format{[a-z_]+\\.zsync}`,
Validator('param', DesktopZsyncRedirectParam),
async (ctx) => redirectToPackageOrigin(ctx),
);
routes.on(
['GET', 'HEAD'],
`${DESKTOP_REDIRECT_PREFIX}/:channel/:plat/:arch/latest/:format`,
Validator('param', DesktopRedirectParam),
Validator('query', DesktopTestBuildQuery),
OpenAPI({
operationId: 'download_latest_desktop_version',
summary: 'Download latest desktop version',
responseSchema: DownloadFileResponse,
responseContentType: '*/*',
statusCode: [200, 206, 302],
bodylessStatusCodes: [302],
security: [],
tags: ['Downloads'],
description:
'Streams the latest available desktop application version for the specified platform and architecture. Pass ?test=1 to download an unreleased test build.',
}),
async (ctx) => {
const {channel, plat, arch, format} = ctx.req.valid('param');
const {test} = ctx.req.valid('query');
const downloadService = ctx.get('downloadService');
const key = await downloadService.resolveLatestDesktopKey({channel, plat, arch, format, test});
if (!key) {
return ctx.text('Not Found', 404);
}
return streamArtifactResponse(ctx, downloadService, key, 'no-store');
},
async (ctx) => redirectToPackageOrigin(ctx),
);
routes.get(
`${DESKTOP_REDIRECT_PREFIX}/:channel/:plat/:arch/versions`,
Validator('param', DesktopVersionsParam),
Validator('query', DesktopVersionsQuery),
OpenAPI({
operationId: 'list_desktop_versions',
summary: 'List desktop versions',
responseSchema: DesktopVersionsResponse,
statusCode: 200,
security: [],
tags: ['Downloads'],
description: 'Lists available desktop versions with pagination for the specified platform and architecture.',
}),
async (ctx) => {
const {channel, plat, arch} = ctx.req.valid('param');
const {limit, before, after, test} = ctx.req.valid('query');
const {versions, hasMore} = await ctx.get('downloadService').listDesktopVersions({
channel,
plat,
arch,
limit,
before,
after,
baseUrl: Config.endpoints.apiClient,
test,
});
return ctx.json({versions, has_more: hasMore}, 200, {
'Cache-Control': 'public, max-age=300',
});
},
routes.on(
['GET', 'HEAD'],
`${DESKTOP_REDIRECT_PREFIX}/:channel/:plat/:arch/:version/:format{[a-z_]+\\.zsync}`,
Validator('param', DesktopVersionedZsyncRedirectParam),
async (ctx) => redirectToPackageOrigin(ctx),
);
routes.on(
['GET', 'HEAD'],
`${DESKTOP_REDIRECT_PREFIX}/:channel/:plat/:arch/:version/:format{[a-z_]+\\.sha256}`,
Validator('param', DesktopVersionedChecksumRedirectParam),
Validator('query', DesktopTestBuildQuery),
OpenAPI({
operationId: 'download_desktop_version_checksum',
summary: 'Download desktop version checksum',
responseSchema: DownloadChecksumResponse,
responseContentType: 'text/plain',
statusCode: 200,
security: [],
tags: ['Downloads'],
description:
'Returns a plain text SHA-256 checksum file for a specific desktop application version. The format path segment must end in .sha256, for example appimage.sha256.',
}),
async (ctx) => {
const {channel, plat, arch, version, format} = ctx.req.valid('param');
const {test} = ctx.req.valid('query');
const checksum = await ctx
.get('downloadService')
.resolveVersionedDesktopChecksumFile({channel, plat, arch, version, format, test});
if (!checksum) {
return ctx.text('Not Found', 404);
}
return checksumFileResponse(ctx, checksum, downloadCacheControlForKey(checksum.key));
},
async (ctx) => redirectToPackageOrigin(ctx),
);
routes.on(
['GET', 'HEAD'],
`${DESKTOP_REDIRECT_PREFIX}/:channel/:plat/:arch/:version/:format`,
Validator('param', DesktopVersionedRedirectParam),
Validator('query', DesktopTestBuildQuery),
OpenAPI({
operationId: 'download_desktop_version',
summary: 'Download desktop version',
responseSchema: DownloadFileResponse,
responseContentType: '*/*',
statusCode: [200, 206, 302],
bodylessStatusCodes: [302],
security: [],
tags: ['Downloads'],
description:
'Streams a specific desktop application version for the given platform and architecture. Pass ?test=1 to download an unreleased test build.',
}),
async (ctx) => {
const {channel, plat, arch, version, format} = ctx.req.valid('param');
const {test} = ctx.req.valid('query');
const downloadService = ctx.get('downloadService');
const key = await downloadService.resolveVersionedDesktopKey({channel, plat, arch, version, format, test});
if (!key) {
return ctx.text('Not Found', 404);
}
return streamArtifactResponse(ctx, downloadService, key, downloadCacheControlForKey(key));
},
);
routes.on(
['GET', 'HEAD'],
`${DOWNLOAD_PREFIX}/*`,
Validator('query', DesktopTestBuildQuery),
OpenAPI({
operationId: 'download_file',
summary: 'Download file',
responseSchema: DownloadFileResponse,
responseContentType: '*/*',
statusCode: [200, 206, 302],
bodylessStatusCodes: [302],
security: [],
tags: ['Downloads'],
description:
'Streams the requested file from storage. Pass ?test=1 on a desktop/ path to resolve against the desktop-test/ bucket prefix instead.',
}),
async (ctx) => {
const {test} = ctx.req.valid('query');
const downloadService = ctx.get('downloadService');
const key = await downloadService.resolveDownloadKey({path: ctx.req.path, test});
if (!key) {
return ctx.text('Not Found', 404);
}
return streamArtifactResponse(ctx, downloadService, key, downloadCacheControlForKey(key));
},
async (ctx) => redirectToPackageOrigin(ctx),
);
routes.on(['GET', 'HEAD'], `${DOWNLOAD_PREFIX}/*`, async (ctx) => redirectToPackageOrigin(ctx));
}
@@ -0,0 +1,83 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {posix} from 'node:path';
export const PKGS_BASE_URL = 'https://pkgs.fluxer.com';
export const DOWNLOAD_PREFIX = '/dl';
export const DESKTOP_REDIRECT_PREFIX = `${DOWNLOAD_PREFIX}/desktop`;
export const DESKTOP_COORDINATE_DOCUMENTS = new Map<string, string>([['latest', 'latest.json']]);
const DESKTOP_PATH_PREFIX = 'desktop/';
const PLATFORM_ARCH_PATH = /^(desktop\/(?:stable|canary)\/(?:win32|darwin|linux))-(x64|arm64)(\/.+)$/u;
const COORDINATE_DOCUMENT_PATH = /^(desktop\/(?:stable|canary)\/(?:win32|darwin|linux)\/(?:x64|arm64))\/([a-z]+)$/u;
const MUTABLE_REDIRECT_CACHE_CONTROL = 'no-store';
const VERSIONED_REDIRECT_CACHE_CONTROL = 'public, max-age=31536000';
const SCOPE_SEGMENT_INDEX = 4;
interface DownloadRedirect {
location: string;
cacheControl: string;
}
function isReleaseFeedFilename(filename: string): boolean {
return (
filename === 'manifest.json' ||
filename === 'latest.json' ||
filename === 'version.json' ||
filename.endsWith('.yml') ||
filename.endsWith('.yaml') ||
filename.startsWith('RELEASES') ||
(filename.startsWith('releases') && filename.endsWith('.json')) ||
(filename.startsWith('assets') && filename.endsWith('.json'))
);
}
function normalizePlatformArchPath(path: string): string {
const match = path.match(PLATFORM_ARCH_PATH);
return match ? `${match[1]}/${match[2]}${match[3]}` : path;
}
function resolveCoordinateDocument(path: string): string {
const match = path.match(COORDINATE_DOCUMENT_PATH);
const document = match ? DESKTOP_COORDINATE_DOCUMENTS.get(match[2]) : undefined;
return match && document ? `${match[1]}/${document}` : path;
}
export function resolveDownloadObjectPath(requestPath: string): string | null {
if (!requestPath.startsWith(DOWNLOAD_PREFIX)) {
return null;
}
const normalized = posix.normalize(requestPath.slice(DOWNLOAD_PREFIX.length).replace(/^\/+/u, ''));
if (normalized.length === 0 || normalized.startsWith('/') || normalized.startsWith('..')) {
return null;
}
for (const segment of normalized.split('/')) {
if (segment.length === 0 || segment === '.' || segment === '..' || segment.includes('\0')) {
return null;
}
}
const objectPath = resolveCoordinateDocument(normalizePlatformArchPath(normalized));
return objectPath.startsWith(DESKTOP_PATH_PREFIX) ? objectPath : null;
}
export function downloadRedirectCacheControl(objectPath: string): string {
const segments = objectPath.split('/');
if (segments[SCOPE_SEGMENT_INDEX] === 'latest') {
return MUTABLE_REDIRECT_CACHE_CONTROL;
}
return isReleaseFeedFilename(segments[segments.length - 1])
? MUTABLE_REDIRECT_CACHE_CONTROL
: VERSIONED_REDIRECT_CACHE_CONTROL;
}
export function resolveDownloadRedirect(requestPath: string): DownloadRedirect | null {
const objectPath = resolveDownloadObjectPath(requestPath);
if (!objectPath) {
return null;
}
return {
location: `${PKGS_BASE_URL}/${objectPath}`,
cacheControl: downloadRedirectCacheControl(objectPath),
};
}
@@ -1,53 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {Config} from '@app/api/Config';
import {parseDesktopArtifactScope} from '@app/api/download/DesktopReleaseContract';
import type {DownloadService, GitHubDesktopReleaseResolution} from '@app/api/download/DownloadService';
import {Logger} from '@app/api/Logger';
import {lookupGeoip} from '@app/api/utils/IpUtils';
const COUNTRY_DEPENDENT_CACHE_CONTROL = 'private, no-store';
type ArtifactRoute =
| {kind: 'storage'; cacheControl: string}
| {kind: 'redirect'; cacheControl: string; location: string};
export async function resolveArtifactRoute(params: {
request: Request;
downloadService: DownloadService;
key: string;
cacheControl: string;
}): Promise<ArtifactRoute> {
if (Config.instance.selfHosted) {
return {kind: 'storage', cacheControl: params.cacheControl};
}
if (Config.desktopGitHubRedirectCountries.size === 0) {
return {kind: 'storage', cacheControl: params.cacheControl};
}
if (!parseDesktopArtifactScope(params.key)) {
return {kind: 'storage', cacheControl: params.cacheControl};
}
const geoip = await lookupGeoip(params.request);
const countryCode = geoip.countryCode?.trim().toUpperCase();
if (!countryCode || !Config.desktopGitHubRedirectCountries.has(countryCode)) {
return {kind: 'storage', cacheControl: COUNTRY_DEPENDENT_CACHE_CONTROL};
}
let release: GitHubDesktopReleaseResolution;
try {
release = await params.downloadService.resolveGitHubDesktopRelease(params.key);
} catch (error) {
Logger.error({error, key: params.key}, 'Failed to resolve GitHub desktop download route');
return {kind: 'storage', cacheControl: COUNTRY_DEPENDENT_CACHE_CONTROL};
}
if (release.kind === 'not_current') {
return {kind: 'storage', cacheControl: COUNTRY_DEPENDENT_CACHE_CONTROL};
}
if (release.kind === 'ready') {
return {
kind: 'redirect',
cacheControl: COUNTRY_DEPENDENT_CACHE_CONTROL,
location: release.location,
};
}
return {kind: 'storage', cacheControl: COUNTRY_DEPENDENT_CACHE_CONTROL};
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,187 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {DownloadController} from '@app/api/download/DownloadController';
import {PKGS_BASE_URL} from '@app/api/download/DownloadRedirects';
import type {HonoEnv} from '@app/api/types/HonoEnv';
import {Hono} from 'hono';
import {describe, expect, it} from 'vitest';
const COUNTRY_HEADERS = {'cf-ipcountry': 'BR', 'x-forwarded-for': '203.0.113.7'};
function createApp() {
const app = new Hono<HonoEnv>();
app.onError((_error, ctx) => ctx.text('Bad Request', 400));
DownloadController(app);
return app;
}
async function request(path: string, init?: RequestInit) {
const response = await createApp().request(path, init);
return {
status: response.status,
location: response.headers.get('Location'),
cacheControl: response.headers.get('Cache-Control'),
body: await response.text(),
};
}
describe('legacy desktop download routes', () => {
it('redirects the latest metadata route at the document the publisher writes', async () => {
const response = await request('/dl/desktop/stable/darwin/arm64/latest');
expect(response.status).toBe(302);
expect(response.location).toBe(`${PKGS_BASE_URL}/desktop/stable/darwin/arm64/latest.json`);
expect(response.cacheControl).toBe('no-store');
expect(response.body).toBe('');
});
it('redirects the latest artifact route', async () => {
const response = await request('/dl/desktop/stable/linux/x64/latest/appimage');
expect(response.status).toBe(302);
expect(response.location).toBe(`${PKGS_BASE_URL}/desktop/stable/linux/x64/latest/appimage`);
expect(response.cacheControl).toBe('no-store');
});
it('redirects the latest checksum route', async () => {
const response = await request('/dl/desktop/stable/linux/x64/latest/appimage.sha256');
expect(response.status).toBe(302);
expect(response.location).toBe(`${PKGS_BASE_URL}/desktop/stable/linux/x64/latest/appimage.sha256`);
expect(response.cacheControl).toBe('no-store');
});
it('stops mapping the retired version listing route, so it passes through to an origin path that serves nothing', async () => {
const response = await request('/dl/desktop/canary/linux/arm64/versions');
expect(response.status).toBe(302);
expect(response.location).toBe(`${PKGS_BASE_URL}/desktop/canary/linux/arm64/versions`);
});
it('redirects the latest appimage zsync sidecar rather than rejecting it', async () => {
const response = await request('/dl/desktop/canary/linux/x64/latest/appimage.zsync');
expect(response.status).toBe(302);
expect(response.location).toBe(`${PKGS_BASE_URL}/desktop/canary/linux/x64/latest/appimage.zsync`);
expect(response.cacheControl).toBe('no-store');
});
it('redirects the versioned appimage zsync sidecar and lets the redirect be cached', async () => {
const response = await request('/dl/desktop/canary/linux/x64/1.4.2/appimage.zsync');
expect(response.status).toBe(302);
expect(response.location).toBe(`${PKGS_BASE_URL}/desktop/canary/linux/x64/1.4.2/appimage.zsync`);
expect(response.cacheControl).toBe('public, max-age=31536000');
});
it('answers HEAD on the zsync sidecar the way it answers GET', async () => {
const response = await request('/dl/desktop/canary/linux/x64/latest/appimage.zsync', {method: 'HEAD'});
expect(response.status).toBe(302);
expect(response.location).toBe(`${PKGS_BASE_URL}/desktop/canary/linux/x64/latest/appimage.zsync`);
});
it('still rejects a zsync sidecar for a format that publishes none', async () => {
const response = await request('/dl/desktop/canary/linux/x64/latest/deb.zsync');
expect(response.status).toBe(400);
});
it('redirects the versioned artifact route and lets the redirect be cached', async () => {
const response = await request('/dl/desktop/stable/win32/x64/1.4.2/setup');
expect(response.status).toBe(302);
expect(response.location).toBe(`${PKGS_BASE_URL}/desktop/stable/win32/x64/1.4.2/setup`);
expect(response.cacheControl).toBe('public, max-age=31536000');
});
it('redirects the versioned checksum route', async () => {
const response = await request('/dl/desktop/stable/win32/x64/1.4.2/setup.sha256');
expect(response.status).toBe(302);
expect(response.location).toBe(`${PKGS_BASE_URL}/desktop/stable/win32/x64/1.4.2/setup.sha256`);
expect(response.cacheControl).toBe('public, max-age=31536000');
});
it('answers HEAD on the artifact routes the way it answers GET', async () => {
const response = await request('/dl/desktop/stable/win32/x64/1.4.2/setup', {method: 'HEAD'});
expect(response.status).toBe(302);
expect(response.location).toBe(`${PKGS_BASE_URL}/desktop/stable/win32/x64/1.4.2/setup`);
});
it('rejects a format outside the closed registry before it reaches the redirector', async () => {
const response = await request('/dl/desktop/stable/linux/x64/latest/msix');
expect(response.status).toBe(400);
});
});
describe('release feed routes', () => {
it.each([
'/dl/desktop/stable/darwin/arm64/RELEASES.json',
'/dl/desktop/stable/win32/x64/RELEASES',
'/dl/desktop/canary/win32/arm64/releases.canary.json',
'/dl/desktop/stable/win32/x64/releases.win.json',
'/dl/desktop/stable/linux/x64/manifest.json',
])('redirects %s without caching the redirect', async (path) => {
const response = await request(path);
expect(response.status).toBe(302);
expect(response.location).toBe(`${PKGS_BASE_URL}${path.slice('/dl'.length)}`);
expect(response.cacheControl).toBe('no-store');
});
it('redirects a nupkg named by a RELEASES body', async () => {
const response = await request('/dl/desktop/stable/win32/x64/fluxer_app-0.0.8-full.nupkg');
expect(response.status).toBe(302);
expect(response.location).toBe(`${PKGS_BASE_URL}/desktop/stable/win32/x64/fluxer_app-0.0.8-full.nupkg`);
expect(response.cacheControl).toBe('public, max-age=31536000');
});
});
describe('channel, platform and architecture targeting', () => {
it.each([
['stable', 'darwin', 'arm64'],
['stable', 'darwin', 'x64'],
['stable', 'win32', 'x64'],
['stable', 'win32', 'arm64'],
['stable', 'linux', 'x64'],
['stable', 'linux', 'arm64'],
['canary', 'darwin', 'arm64'],
['canary', 'win32', 'x64'],
['canary', 'linux', 'arm64'],
])('keeps %s/%s/%s in the redirect target', async (channel, plat, arch) => {
const response = await request(`/dl/desktop/${channel}/${plat}/${arch}/latest`);
expect(response.status).toBe(302);
expect(response.location).toBe(`${PKGS_BASE_URL}/desktop/${channel}/${plat}/${arch}/latest.json`);
});
});
describe('the geoip and github release route is gone', () => {
it('sends every country to the package origin with the same cache control', async () => {
const path = '/dl/desktop/stable/darwin/arm64/1.4.2/dmg';
const withCountry = await request(path, {headers: COUNTRY_HEADERS});
const withoutCountry = await request(path);
expect(withCountry).toEqual(withoutCountry);
expect(withCountry.location).toBe(`${PKGS_BASE_URL}/desktop/stable/darwin/arm64/1.4.2/dmg`);
expect(withCountry.cacheControl).not.toBe('private, no-store');
});
it('never points a download at github', async () => {
const response = await request('/dl/desktop/stable/darwin/arm64/1.4.2/zip', {headers: COUNTRY_HEADERS});
expect(response.location).not.toContain('github.com');
expect(response.location?.startsWith(`${PKGS_BASE_URL}/`)).toBe(true);
});
});
describe('paths the redirector refuses', () => {
it('answers 404 for a key outside the desktop prefix', async () => {
const response = await request('/dl/harvests/dump.zip');
expect(response.status).toBe(404);
expect(response.body).toBe('Not Found');
});
it('answers 404 for a traversal attempt', async () => {
const response = await request('/dl/desktop/../harvests/dump.zip');
expect(response.status).toBe(404);
});
it('answers 404 for the retired test build prefix', async () => {
const response = await request('/dl/desktop-test/canary/linux/x64/latest/appimage');
expect(response.status).toBe(404);
});
it('ignores a test query parameter rather than resolving another prefix', async () => {
const response = await request('/dl/desktop/canary/linux/x64/latest/appimage?test=1');
expect(response.status).toBe(302);
expect(response.location).toBe(`${PKGS_BASE_URL}/desktop/canary/linux/x64/latest/appimage`);
});
});
@@ -1,79 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {DownloadService} from '@app/api/download/DownloadService';
import type {IStorageService} from '@app/api/infrastructure/IStorageService';
import {describe, expect, it} from 'vitest';
const OBJECT_METADATA = {
contentLength: 285_567_850,
contentType: 'application/x-apple-diskimage',
etag: '"abc123"',
lastModified: new Date('2026-08-17T00:00:00Z'),
};
interface PresignCall {
bucket: string;
key: string;
expiresIn?: number;
responseContentType?: string;
responseContentDisposition?: string;
}
function createService(overrides: {metadata?: typeof OBJECT_METADATA | null} = {}) {
const presignCalls: Array<PresignCall> = [];
const storageService = {
getObjectMetadata: async () => (overrides.metadata === undefined ? OBJECT_METADATA : overrides.metadata),
getPresignedDownloadURL: async (params: PresignCall) => {
presignCalls.push(params);
return `https://storage.example.test/${params.key}?signed=1`;
},
} as unknown as IStorageService;
return {service: new DownloadService(storageService), presignCalls};
}
describe('presigned download redirects', () => {
it('signs the requested object and returns its URL', async () => {
const {service, presignCalls} = createService();
const url = await service.getPresignedDownloadRedirect({
key: 'desktop/canary/darwin/universal/Fluxer.dmg',
filename: 'Fluxer.dmg',
expiresIn: 900,
});
expect(url).toBe('https://storage.example.test/desktop/canary/darwin/universal/Fluxer.dmg?signed=1');
expect(presignCalls).toHaveLength(1);
expect(presignCalls[0]?.key).toBe('desktop/canary/darwin/universal/Fluxer.dmg');
expect(presignCalls[0]?.expiresIn).toBe(900);
});
it('preserves the download filename and content type through the redirect', async () => {
const {service, presignCalls} = createService();
await service.getPresignedDownloadRedirect({
key: 'desktop/canary/darwin/universal/Fluxer.dmg',
filename: 'Fluxer Canary.dmg',
expiresIn: 900,
});
expect(presignCalls[0]?.responseContentType).toBe('application/x-apple-diskimage');
expect(presignCalls[0]?.responseContentDisposition).toBe(
`attachment; filename="${encodeURIComponent('Fluxer Canary.dmg')}"`,
);
});
it('falls back to a binary content type when storage reports none', async () => {
const {service, presignCalls} = createService({
metadata: {...OBJECT_METADATA, contentType: null} as unknown as typeof OBJECT_METADATA,
});
await service.getPresignedDownloadRedirect({key: 'desktop/x.bin', filename: 'x.bin', expiresIn: 900});
expect(presignCalls[0]?.responseContentType).toBe('application/octet-stream');
});
it('returns null for a missing object so the caller can answer 404 without signing', async () => {
const {service, presignCalls} = createService({metadata: null});
const url = await service.getPresignedDownloadRedirect({
key: 'desktop/missing.dmg',
filename: 'missing.dmg',
expiresIn: 900,
});
expect(url).toBeNull();
expect(presignCalls).toHaveLength(0);
});
});
@@ -0,0 +1,135 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {
DESKTOP_COORDINATE_DOCUMENTS,
downloadRedirectCacheControl,
PKGS_BASE_URL,
resolveDownloadObjectPath,
resolveDownloadRedirect,
} from '@app/api/download/DownloadRedirects';
import {describe, expect, it} from 'vitest';
const MUTABLE = 'no-store';
const IMMUTABLE = 'public, max-age=31536000';
describe('the coordinate document contract the publisher writes', () => {
it('names the exact files scripts/packages/stage-desktop.sh and retention.sh publish', () => {
expect(Object.fromEntries(DESKTOP_COORDINATE_DOCUMENTS)).toEqual({
latest: 'latest.json',
});
});
it('resolves the bare coordinate names to those files on every coordinate', () => {
for (const channel of ['stable', 'canary']) {
for (const plat of ['win32', 'darwin', 'linux']) {
for (const arch of ['x64', 'arm64']) {
expect(resolveDownloadObjectPath(`/dl/desktop/${channel}/${plat}/${arch}/latest`)).toBe(
`desktop/${channel}/${plat}/${arch}/latest.json`,
);
expect(resolveDownloadObjectPath(`/dl/desktop/${channel}/${plat}/${arch}/versions`)).toBe(
`desktop/${channel}/${plat}/${arch}/versions`,
);
}
}
}
});
it('leaves the latest directory alone when a format or a sidecar follows it', () => {
expect(resolveDownloadObjectPath('/dl/desktop/stable/linux/x64/latest/appimage')).toBe(
'desktop/stable/linux/x64/latest/appimage',
);
expect(resolveDownloadObjectPath('/dl/desktop/stable/linux/x64/latest/appimage.zsync')).toBe(
'desktop/stable/linux/x64/latest/appimage.zsync',
);
});
it('rewrites the legacy platform-arch form to the same documents', () => {
expect(resolveDownloadObjectPath('/dl/desktop/stable/linux-x64/latest')).toBe(
'desktop/stable/linux/x64/latest.json',
);
expect(resolveDownloadObjectPath('/dl/desktop/stable/linux-x64/versions')).toBe(
'desktop/stable/linux/x64/versions',
);
});
it('rewrites nothing else that sits at the coordinate root', () => {
expect(resolveDownloadObjectPath('/dl/desktop/stable/linux/x64/manifest.json')).toBe(
'desktop/stable/linux/x64/manifest.json',
);
expect(resolveDownloadObjectPath('/dl/desktop/stable/win32/x64/RELEASES')).toBe(
'desktop/stable/win32/x64/RELEASES',
);
expect(resolveDownloadObjectPath('/dl/desktop/stable/linux/x64/constructor')).toBe(
'desktop/stable/linux/x64/constructor',
);
});
});
describe('download object paths', () => {
it('strips the /dl prefix and keeps the rest of the path verbatim', () => {
expect(resolveDownloadObjectPath('/dl/desktop/stable/darwin/arm64/RELEASES.json')).toBe(
'desktop/stable/darwin/arm64/RELEASES.json',
);
expect(resolveDownloadObjectPath('/dl/desktop/stable/win32/x64/1.4.2/Fluxer-1.4.2-win-x64.exe')).toBe(
'desktop/stable/win32/x64/1.4.2/Fluxer-1.4.2-win-x64.exe',
);
});
it('normalises the legacy platform-arch segment to the published layout', () => {
expect(resolveDownloadObjectPath('/dl/desktop/stable/linux-x64/manifest.json')).toBe(
'desktop/stable/linux/x64/manifest.json',
);
});
it('refuses a key outside the desktop prefix', () => {
expect(resolveDownloadObjectPath('/dl/reports/secret.json')).toBeNull();
expect(resolveDownloadObjectPath('/dl/desktop-test/canary/linux/x64/latest/appimage')).toBeNull();
expect(resolveDownloadObjectPath('/dl/')).toBeNull();
expect(resolveDownloadObjectPath('/other/desktop/stable/linux/x64/latest')).toBeNull();
});
it('refuses a traversal attempt rather than pointing at another prefix', () => {
expect(resolveDownloadObjectPath('/dl/desktop/../harvests/dump.zip')).toBeNull();
expect(resolveDownloadObjectPath('/dl/../desktop/stable/linux/x64/latest')).toBeNull();
expect(resolveDownloadObjectPath('/dl/desktop/stable/linux/x64/lat\0est')).toBeNull();
});
});
describe('download redirect cache control', () => {
it('never caches a redirect to a mutable document', () => {
expect(downloadRedirectCacheControl('desktop/stable/darwin/arm64/latest.json')).toBe(MUTABLE);
expect(downloadRedirectCacheControl('desktop/stable/darwin/arm64/version.json')).toBe(MUTABLE);
expect(downloadRedirectCacheControl('desktop/stable/linux/x64/latest/appimage')).toBe(MUTABLE);
expect(downloadRedirectCacheControl('desktop/stable/linux/x64/latest/appimage.sha256')).toBe(MUTABLE);
expect(downloadRedirectCacheControl('desktop/stable/linux/x64/latest/appimage.zsync')).toBe(MUTABLE);
expect(downloadRedirectCacheControl('desktop/stable/darwin/arm64/RELEASES.json')).toBe(MUTABLE);
expect(downloadRedirectCacheControl('desktop/stable/win32/x64/RELEASES')).toBe(MUTABLE);
expect(downloadRedirectCacheControl('desktop/canary/win32/x64/releases.canary.json')).toBe(MUTABLE);
expect(downloadRedirectCacheControl('desktop/stable/linux/x64/manifest.json')).toBe(MUTABLE);
expect(downloadRedirectCacheControl('desktop/stable/linux/x64/latest-linux.yml')).toBe(MUTABLE);
});
it('caches a redirect to a version pinned artifact for a year', () => {
expect(downloadRedirectCacheControl('desktop/stable/darwin/arm64/1.4.2/dmg')).toBe(IMMUTABLE);
expect(downloadRedirectCacheControl('desktop/stable/darwin/arm64/1.4.2/dmg.sha256')).toBe(IMMUTABLE);
expect(downloadRedirectCacheControl('desktop/stable/linux/x64/1.4.2/appimage.zsync')).toBe(IMMUTABLE);
expect(downloadRedirectCacheControl('desktop/stable/win32/x64/fluxer_app-0.0.8-full.nupkg')).toBe(IMMUTABLE);
});
});
describe('download redirects', () => {
it('points every desktop path at the package origin', () => {
expect(resolveDownloadRedirect('/dl/desktop/stable/darwin/arm64/1.4.2/dmg')).toEqual({
location: `${PKGS_BASE_URL}/desktop/stable/darwin/arm64/1.4.2/dmg`,
cacheControl: IMMUTABLE,
});
expect(resolveDownloadRedirect('/dl/desktop/canary/linux/arm64/latest')).toEqual({
location: `${PKGS_BASE_URL}/desktop/canary/linux/arm64/latest.json`,
cacheControl: MUTABLE,
});
});
it('returns null for a path it refuses to map', () => {
expect(resolveDownloadRedirect('/dl/harvests/dump.zip')).toBeNull();
});
});
@@ -1,71 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {Readable} from 'node:stream';
import {DownloadService} from '@app/api/download/DownloadService';
import type {IStorageService} from '@app/api/infrastructure/IStorageService';
import {describe, expect, it} from 'vitest';
const PREFIX = 'desktop/stable/darwin/x64';
const MANIFEST_KEY = `${PREFIX}/manifest.json`;
const LISTED_FILENAME = 'Fluxer-1.2.3-mac-universal.dmg';
const MANIFEST_FILENAME = 'Fluxer-1.3.0-mac-universal.dmg';
const LATEST_PARAMS = {
channel: 'stable',
plat: 'darwin',
arch: 'x64',
format: 'dmg',
} as const;
function createService(overrides: {manifestBody?: string | null; objectKeys?: Array<string>} = {}) {
const objectKeys = overrides.objectKeys ?? [`${PREFIX}/${LISTED_FILENAME}`];
const storageService = {
streamObject: async (params: {key: string}) => {
if (params.key !== MANIFEST_KEY) {
return null;
}
const body = overrides.manifestBody;
if (body == null) {
return null;
}
const buffer = Buffer.from(body, 'utf8');
return {body: Readable.from([buffer]), contentLength: buffer.byteLength};
},
listObjects: async () => objectKeys.map((key) => ({key})),
getObjectMetadata: async (_bucket: string, key: string) =>
objectKeys.includes(key) ? {contentLength: 1, contentType: 'application/x-apple-diskimage'} : null,
} as unknown as IStorageService;
return new DownloadService(storageService);
}
describe('desktop manifest parsing', () => {
it('falls back to the object listing when the manifest is not valid JSON', async () => {
const service = createService({manifestBody: '{not json'});
await expect(service.resolveLatestDesktopKey({...LATEST_PARAMS})).resolves.toBe(`${PREFIX}/${LISTED_FILENAME}`);
});
it('returns null rather than throwing when the manifest is malformed and no artifact is listed', async () => {
const service = createService({manifestBody: '{not json', objectKeys: []});
await expect(service.resolveLatestDesktopKey({...LATEST_PARAMS})).resolves.toBeNull();
});
it('falls back to the object listing when the manifest parses to an array', async () => {
const service = createService({manifestBody: '[]'});
await expect(service.resolveLatestDesktopKey({...LATEST_PARAMS})).resolves.toBe(`${PREFIX}/${LISTED_FILENAME}`);
});
it('still resolves through a well-formed manifest', async () => {
const service = createService({
manifestBody: JSON.stringify({
channel: 'stable',
platform: 'darwin',
arch: 'x64',
version: '1.3.0',
pub_date: '2026-08-17T00:00:00Z',
files: {dmg: MANIFEST_FILENAME},
}),
objectKeys: [`${PREFIX}/${LISTED_FILENAME}`, `${PREFIX}/${MANIFEST_FILENAME}`],
});
await expect(service.resolveLatestDesktopKey({...LATEST_PARAMS})).resolves.toBe(`${PREFIX}/${MANIFEST_FILENAME}`);
});
});
@@ -1,340 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {createHash} from 'node:crypto';
import {Readable} from 'node:stream';
import {getConfig} from '@app/api/Config';
import {DownloadService} from '@app/api/download/DownloadService';
import type {IStorageService} from '@app/api/infrastructure/IStorageService';
import {S3ServiceException} from '@aws-sdk/client-s3';
import {describe, expect, it} from 'vitest';
const PREFIX = 'desktop/canary/linux/x64';
const TEST_PREFIX = 'desktop-test/canary/linux/x64';
const RELEASES_PREFIX = 'desktop/canary/github-releases';
const SOURCE_SHA = 'b'.repeat(40);
const V904 = '2026.904.135113';
const V908 = '2026.908.173325';
const V909 = '2026.909.202036';
const LATEST_PARAMS = {channel: 'canary', plat: 'linux', arch: 'x64'} as const;
const APPIMAGE_PARAMS = {...LATEST_PARAMS, format: 'appimage'} as const;
const RELEASE_ROUTES: ReadonlyArray<readonly [string, string, number]> = [
['darwin', 'arm64', 4],
['darwin', 'x64', 4],
['linux', 'arm64', 4],
['linux', 'x64', 4],
['win32', 'arm64', 6],
['win32', 'x64', 6],
];
type StoredObjects = Map<string, string>;
function sha256Hex(value: string): string {
return createHash('sha256').update(value).digest('hex');
}
function appImageFilename(version: string): string {
return `Fluxer-Canary-${version}-linux-x86_64.AppImage`;
}
function uploadBuild(objects: StoredObjects, version: string, options: {prefix?: string; checksum?: boolean} = {}) {
const prefix = options.prefix ?? PREFIX;
const filename = appImageFilename(version);
objects.set(`${prefix}/${filename}`, filename);
if (options.checksum !== false) {
objects.set(`${prefix}/${filename}.sha256`, `${sha256Hex(filename)} ${filename}`);
}
objects.set(
`${prefix}/manifest.json`,
JSON.stringify({
channel: 'canary',
platform: 'linux',
arch: 'x64',
version,
pub_date: '2026-09-08T18:06:00Z',
files: {appimage: {filename, sha256: sha256Hex(filename)}},
}),
);
}
function publishDescriptor(objects: StoredObjects, version: string, routes = RELEASE_ROUTES): string {
const assets = routes.flatMap(([plat, arch, count]) =>
Array.from({length: count}, (_, index) => {
const filename =
plat === 'linux' && arch === 'x64' && index === 0
? appImageFilename(version)
: `Fluxer-Canary-${version}-${plat}-${arch}-${index}.bin`;
return {
storage_key: `desktop/canary/${plat}/${arch}/${filename}`,
release_asset: filename,
sha256: sha256Hex(filename),
size: 1,
};
}),
);
const descriptor = JSON.stringify({
schema_version: 1,
channel: 'canary',
version,
release_tag: `fluxer-desktop-canary@${version}`,
source_sha: SOURCE_SHA,
assets,
});
objects.set(`${RELEASES_PREFIX}/${version}.json`, descriptor);
return descriptor;
}
function publishMarker(objects: StoredObjects, version: string, descriptor: string) {
objects.set(
`${RELEASES_PREFIX}/${version}.ready.json`,
JSON.stringify({
schema_version: 1,
channel: 'canary',
version,
release_tag: `fluxer-desktop-canary@${version}`,
source_sha: SOURCE_SHA,
descriptor_sha256: sha256Hex(descriptor),
}),
);
}
function releaseBuild(objects: StoredObjects, version: string) {
const descriptor = publishDescriptor(objects, version);
uploadBuild(objects, version);
publishMarker(objects, version, descriptor);
}
function incidentObjects(): StoredObjects {
const objects: StoredObjects = new Map();
releaseBuild(objects, V904);
publishDescriptor(objects, V908);
uploadBuild(objects, V908);
return objects;
}
function createService(objects: StoredObjects, onRead?: (key: string) => void) {
const reads: Array<string> = [];
const listings: Array<string> = [];
const storageService = {
streamObject: async (params: {key: string}) => {
reads.push(params.key);
onRead?.(params.key);
const body = objects.get(params.key);
if (body == null) {
return null;
}
const buffer = Buffer.from(body, 'utf8');
return {body: Readable.from([buffer]), contentLength: buffer.byteLength};
},
listObjects: async (params: {prefix: string}) => {
listings.push(params.prefix);
return Array.from(objects.keys())
.filter((key) => key.startsWith(params.prefix))
.sort()
.map((key) => ({key}));
},
getObjectMetadata: async (_bucket: string, key: string) =>
objects.has(key) ? {contentLength: 1, contentType: 'application/octet-stream'} : null,
} as unknown as IStorageService;
return {service: new DownloadService(storageService), reads, listings};
}
async function resolveLatest(service: DownloadService, test?: boolean) {
const metadata = await service.getLatestDesktopVersion({...LATEST_PARAMS, test});
const key = await service.resolveLatestDesktopKey({...APPIMAGE_PARAMS, test});
const checksum = await service.resolveLatestDesktopChecksumFile({...APPIMAGE_PARAMS, test});
return {version: metadata?.version, key, checksum: checksum?.body};
}
function latestOf(version: string, prefix = PREFIX) {
const filename = appImageFilename(version);
return {version, key: `${prefix}/${filename}`, checksum: `${sha256Hex(filename)} ${filename}\n`};
}
describe('desktop release readiness', () => {
it('offers a published manifest version after reading only its release state', async () => {
const objects: StoredObjects = new Map();
releaseBuild(objects, V904);
releaseBuild(objects, V909);
const {service, reads, listings} = createService(objects);
await expect(service.getLatestDesktopVersion({...LATEST_PARAMS})).resolves.toMatchObject({version: V909});
expect(reads).toEqual([
`${PREFIX}/manifest.json`,
`${RELEASES_PREFIX}/${V909}.json`,
`${RELEASES_PREFIX}/${V909}.ready.json`,
]);
expect(listings).toEqual([]);
await expect(resolveLatest(service)).resolves.toEqual(latestOf(V909));
});
it('falls back to the newest published version while the manifest version awaits its release', async () => {
const {service} = createService(incidentObjects());
await expect(resolveLatest(service)).resolves.toEqual(latestOf(V904));
});
it('reads each release state once and one checksum while the manifest version awaits its release', async () => {
const {service, reads, listings} = createService(incidentObjects());
await expect(service.getLatestDesktopVersion({...LATEST_PARAMS})).resolves.toMatchObject({version: V904});
expect(reads).toEqual([
`${PREFIX}/manifest.json`,
`${RELEASES_PREFIX}/${V908}.json`,
`${RELEASES_PREFIX}/${V908}.ready.json`,
`${RELEASES_PREFIX}/${V904}.json`,
`${RELEASES_PREFIX}/${V904}.ready.json`,
`${PREFIX}/${appImageFilename(V904)}.sha256`,
]);
expect(listings).toEqual([`${PREFIX}/`]);
});
it('offers a manifest version that has no release descriptor', async () => {
const objects: StoredObjects = new Map();
uploadBuild(objects, V904);
uploadBuild(objects, V908);
const {service} = createService(objects);
await expect(resolveLatest(service)).resolves.toEqual(latestOf(V908));
});
it('treats a readiness marker that does not match the stored descriptor as unpublished', async () => {
const objects: StoredObjects = new Map();
releaseBuild(objects, V904);
publishDescriptor(objects, V908);
uploadBuild(objects, V908);
publishMarker(objects, V908, 'another descriptor');
const {service} = createService(objects);
await expect(service.resolveGitHubDesktopRelease(`${PREFIX}/${appImageFilename(V908)}`)).resolves.toEqual({
kind: 'awaiting_release',
});
await expect(resolveLatest(service)).resolves.toEqual(latestOf(V904));
});
it('offers a version whose descriptor the parser rejects when its readiness marker matches', async () => {
const objects: StoredObjects = new Map();
releaseBuild(objects, V904);
const descriptor = publishDescriptor(
objects,
V908,
RELEASE_ROUTES.map(([plat, arch, count]) => [plat, arch, plat === 'linux' ? count - 1 : count] as const),
);
uploadBuild(objects, V908);
publishMarker(objects, V908, descriptor);
const {service} = createService(objects);
await expect(resolveLatest(service)).resolves.toEqual(latestOf(V908));
await expect(service.resolveGitHubDesktopRelease(`${PREFIX}/${appImageFilename(V908)}`)).rejects.toThrow(
'Invalid GitHub desktop release descriptor',
);
});
it.each([
['descriptor', `${RELEASES_PREFIX}/${V908}.json`],
['readiness marker', `${RELEASES_PREFIX}/${V908}.ready.json`],
])(
'offers the manifest version when reading its release %s fails with a storage error',
async (_name, failingKey) => {
const {service} = createService(incidentObjects(), (key) => {
if (key === failingKey) {
throw new S3ServiceException({
name: 'SlowDown',
$fault: 'server',
$metadata: {httpStatusCode: 503},
message: 'Please reduce your request rate.',
});
}
});
await expect(resolveLatest(service)).resolves.toEqual(latestOf(V908));
},
);
it('still resolves the unpublished version through versioned routes', async () => {
const objects: StoredObjects = new Map();
releaseBuild(objects, V904);
publishDescriptor(objects, V908);
uploadBuild(objects, V908, {checksum: false});
const {service} = createService(objects);
const params = {...APPIMAGE_PARAMS, version: V908};
const filename = appImageFilename(V908);
await expect(service.resolveVersionedDesktopKey(params)).resolves.toBe(`${PREFIX}/${filename}`);
await expect(service.resolveVersionedDesktopChecksumFile(params)).resolves.toMatchObject({
sha256: sha256Hex(filename),
});
});
it('keeps offering the manifest version on self-hosted instances', async () => {
const config = getConfig();
const originalSelfHosted = config.instance.selfHosted;
config.instance.selfHosted = true;
try {
const {service, reads} = createService(incidentObjects());
await expect(resolveLatest(service)).resolves.toEqual(latestOf(V908));
expect(reads.filter((key) => key.startsWith(RELEASES_PREFIX))).toEqual([]);
} finally {
config.instance.selfHosted = originalSelfHosted;
}
});
it('keeps offering the newest test build', async () => {
const objects: StoredObjects = new Map();
publishDescriptor(objects, V908);
uploadBuild(objects, V908, {prefix: TEST_PREFIX});
const {service, reads} = createService(objects);
await expect(resolveLatest(service, true)).resolves.toEqual(latestOf(V908, TEST_PREFIX));
expect(reads.filter((key) => key.startsWith(RELEASES_PREFIX))).toEqual([]);
});
it('offers 904 while 908 awaits its release, then 909 once its marker lands', async () => {
const objects = incidentObjects();
const {service} = createService(objects);
await expect(resolveLatest(service)).resolves.toEqual(latestOf(V904));
await expect(service.resolveGitHubDesktopRelease(`${PREFIX}/${appImageFilename(V908)}`)).resolves.toEqual({
kind: 'awaiting_release',
});
const descriptor = publishDescriptor(objects, V909);
uploadBuild(objects, V909);
await expect(resolveLatest(service)).resolves.toEqual(latestOf(V904));
publishMarker(objects, V909, descriptor);
await expect(resolveLatest(service)).resolves.toEqual(latestOf(V909));
await expect(service.resolveGitHubDesktopRelease(`${PREFIX}/${appImageFilename(V909)}`)).resolves.toEqual({
kind: 'ready',
location: `https://github.com/fluxerapp/fluxer/releases/download/${encodeURIComponent(`fluxer-desktop-canary@${V909}`)}/${appImageFilename(V909)}`,
});
});
it('offers the newest version when ten unpublished versions hide a published one', async () => {
const objects: StoredObjects = new Map();
releaseBuild(objects, V904);
const newest = '2026.908.170009';
for (let build = 0; build < 10; build++) {
const version = `2026.908.${170000 + build}`;
publishDescriptor(objects, version);
uploadBuild(objects, version);
}
const {service, reads} = createService(objects);
await expect(resolveLatest(service)).resolves.toEqual(latestOf(newest));
expect(reads).not.toContain(`${RELEASES_PREFIX}/${V904}.ready.json`);
});
it('pairs the latest checksum with the filename of the same version when a marker lands mid-request', async () => {
const objects = incidentObjects();
const descriptor = publishDescriptor(objects, V909);
uploadBuild(objects, V909);
let manifestReads = 0;
const {service} = createService(objects, (key) => {
if (key !== `${PREFIX}/manifest.json`) {
return;
}
manifestReads += 1;
if (manifestReads === 2) {
publishMarker(objects, V909, descriptor);
}
});
const checksum = await service.resolveLatestDesktopChecksumFile({...APPIMAGE_PARAMS});
expect(checksum?.body).toBe(latestOf(V904).checksum);
});
it('lists an unpublished version while latest skips it', async () => {
const {service} = createService(incidentObjects());
const listed = await service.listDesktopVersions({...LATEST_PARAMS, limit: 10});
expect(listed.versions.map((entry) => entry.version)).toEqual([V908, V904]);
await expect(resolveLatest(service)).resolves.toEqual(latestOf(V904));
});
});
@@ -1,143 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {Readable} from 'node:stream';
import {DownloadService} from '@app/api/download/DownloadService';
import type {IStorageService} from '@app/api/infrastructure/IStorageService';
import {describe, expect, it} from 'vitest';
const PREFIX = 'desktop/canary/linux/x64';
const BASE_URL = 'https://api.example.test';
const V1 = '2026.901.100000';
const V2 = '2026.902.100000';
const V3 = '2026.903.100000';
const V4 = '2026.904.100000';
const V5 = '2026.905.100000';
const LIST_PARAMS = {channel: 'canary', plat: 'linux', arch: 'x64', baseUrl: BASE_URL} as const;
type StoredObject = {body?: string; lastModified?: Date};
type StoredObjects = Map<string, StoredObject>;
function appImageFilename(version: string): string {
return `Fluxer-Canary-${version}-linux-x86_64.AppImage`;
}
function debFilename(version: string): string {
return `Fluxer-Canary-${version}-linux-amd64.deb`;
}
function addArtifact(objects: StoredObjects, filename: string, options: {sha256?: string; lastModified?: Date} = {}) {
objects.set(`${PREFIX}/${filename}`, {lastModified: options.lastModified});
if (options.sha256 !== undefined) {
objects.set(`${PREFIX}/${filename}.sha256`, {body: `${options.sha256} ${filename}\n`});
}
}
function createService(objects: StoredObjects) {
const reads: Array<string> = [];
const storageService = {
streamObject: async (params: {key: string}) => {
reads.push(params.key);
const object = objects.get(params.key);
if (object?.body == null) {
return null;
}
const buffer = Buffer.from(object.body, 'utf8');
return {body: Readable.from([buffer]), contentLength: buffer.byteLength};
},
listObjects: async (params: {prefix: string}) =>
Array.from(objects.entries())
.filter(([key]) => key.startsWith(params.prefix))
.sort(([left], [right]) => (left < right ? -1 : 1))
.map(([key, object]) => ({key, lastModified: object.lastModified})),
getObjectMetadata: async () => null,
} as unknown as IStorageService;
return {service: new DownloadService(storageService), reads};
}
function versionNumbers(versions: Array<{version: string}>): Array<string> {
return versions.map((entry) => entry.version);
}
describe('desktop version listing', () => {
it('lists versions newest first with the files of each version', async () => {
const objects: StoredObjects = new Map();
addArtifact(objects, appImageFilename(V1), {lastModified: new Date('2026-09-01T10:00:00Z')});
addArtifact(objects, appImageFilename(V3), {lastModified: new Date('2026-09-03T10:00:00Z')});
addArtifact(objects, debFilename(V3), {lastModified: new Date('2026-09-03T12:00:00Z')});
addArtifact(objects, appImageFilename(V5), {lastModified: new Date('2026-09-05T10:00:00Z')});
const {service} = createService(objects);
const listed = await service.listDesktopVersions({...LIST_PARAMS, limit: 10});
expect(versionNumbers(listed.versions)).toEqual([V5, V3, V1]);
expect(listed.hasMore).toBe(false);
expect(Object.keys(listed.versions[1].files).sort()).toEqual(['appimage', 'deb']);
expect(listed.versions[1].pub_date).toBe('2026-09-03T12:00:00.000Z');
expect(listed.versions[0].files.appimage.url).toBe(`${BASE_URL}/dl/desktop/canary/linux/x64/${V5}/appimage`);
});
it('excludes names that are not artefacts for the requested coordinate', async () => {
const objects: StoredObjects = new Map();
addArtifact(objects, appImageFilename(V3), {sha256: 'a'.repeat(64)});
objects.set(`${PREFIX}/nested/${appImageFilename(V5)}`, {});
objects.set(`${PREFIX}/manifest.json`, {body: '{}'});
objects.set(`${PREFIX}/RELEASES.json`, {body: '{}'});
objects.set(`${PREFIX}/releases.json`, {body: '{}'});
objects.set(`${PREFIX}/latest-linux.yml`, {body: 'version: 1'});
objects.set(`${PREFIX}/${appImageFilename(V4)}.blockmap`, {});
objects.set(`${PREFIX}/Fluxer-Canary-${V4}-linux-aarch64.AppImage`, {});
objects.set(`${PREFIX}/Fluxer-Canary-${V4}-mac-universal.dmg`, {});
const {service} = createService(objects);
const listed = await service.listDesktopVersions({...LIST_PARAMS, limit: 10});
expect(versionNumbers(listed.versions)).toEqual([V3]);
expect(Object.keys(listed.versions[0].files)).toEqual(['appimage']);
});
it('pages with limit, before and after and reports whether more remain', async () => {
const objects: StoredObjects = new Map();
for (const version of [V1, V2, V3, V4, V5]) {
addArtifact(objects, appImageFilename(version));
}
const {service} = createService(objects);
const firstPage = await service.listDesktopVersions({...LIST_PARAMS, limit: 2});
expect(versionNumbers(firstPage.versions)).toEqual([V5, V4]);
expect(firstPage.hasMore).toBe(true);
const olderPage = await service.listDesktopVersions({...LIST_PARAMS, limit: 2, before: V3});
expect(versionNumbers(olderPage.versions)).toEqual([V2, V1]);
expect(olderPage.hasMore).toBe(false);
const newerPage = await service.listDesktopVersions({...LIST_PARAMS, limit: 2, after: V3});
expect(versionNumbers(newerPage.versions)).toEqual([V5, V4]);
expect(newerPage.hasMore).toBe(false);
const between = await service.listDesktopVersions({...LIST_PARAMS, limit: 1, before: V5, after: V1});
expect(versionNumbers(between.versions)).toEqual([V4]);
expect(between.hasMore).toBe(true);
});
it('reports the sibling hash and treats a missing or malformed one as absent', async () => {
const hash = 'b'.repeat(64);
const objects: StoredObjects = new Map();
addArtifact(objects, appImageFilename(V3), {sha256: hash});
addArtifact(objects, appImageFilename(V2));
addArtifact(objects, appImageFilename(V1), {sha256: 'C'.repeat(64)});
const {service} = createService(objects);
const listed = await service.listDesktopVersions({...LIST_PARAMS, limit: 10});
expect(listed.versions[0].files.appimage).toEqual({
url: `${BASE_URL}/dl/desktop/canary/linux/x64/${V3}/appimage`,
sha256: hash,
checksum_url: `${BASE_URL}/dl/desktop/canary/linux/x64/${V3}/appimage.sha256`,
});
expect(listed.versions[1].files.appimage.sha256).toBeNull();
expect(listed.versions[1].files.appimage.checksum_url).toBeNull();
expect(listed.versions[2].files.appimage.sha256).toBeNull();
expect(listed.versions[2].files.appimage.checksum_url).toBeNull();
});
it('reads a checksum only for the versions it returns', async () => {
const objects: StoredObjects = new Map();
for (const version of [V1, V2, V3, V4, V5]) {
addArtifact(objects, appImageFilename(version), {sha256: 'd'.repeat(64)});
}
const {service, reads} = createService(objects);
await service.listDesktopVersions({...LIST_PARAMS, limit: 2});
expect(reads).toEqual([`${PREFIX}/${appImageFilename(V5)}.sha256`, `${PREFIX}/${appImageFilename(V4)}.sha256`]);
});
});
@@ -1,62 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {Readable} from 'node:stream';
import {Config} from '@app/api/Config';
import {DownloadService} from '@app/api/download/DownloadService';
import type {IStorageService} from '@app/api/infrastructure/IStorageService';
import {describe, expect, it} from 'vitest';
const PREFIX = 'desktop/stable/darwin/x64';
const MANIFEST_KEY = `${PREFIX}/manifest.json`;
const FILENAME = 'Fluxer-1.3.0-mac-universal.dmg';
const SHA256 = 'a'.repeat(64);
const LATEST_PARAMS = {
channel: 'stable',
plat: 'darwin',
arch: 'x64',
} as const;
const MANIFEST_BODY = JSON.stringify({
channel: 'stable',
platform: 'darwin',
arch: 'x64',
version: '1.3.0',
pub_date: '2026-08-17T00:00:00Z',
files: {dmg: {filename: FILENAME, sha256: SHA256}},
});
function createService() {
const objectKeys = [`${PREFIX}/${FILENAME}`];
const storageService = {
streamObject: async (params: {key: string}) => {
if (params.key !== MANIFEST_KEY) {
return null;
}
const buffer = Buffer.from(MANIFEST_BODY, 'utf8');
return {body: Readable.from([buffer]), contentLength: buffer.byteLength};
},
listObjects: async () => objectKeys.map((key) => ({key})),
getObjectMetadata: async (_bucket: string, key: string) =>
objectKeys.includes(key) ? {contentLength: 1, contentType: 'application/x-apple-diskimage'} : null,
} as unknown as IStorageService;
return new DownloadService(storageService);
}
describe('desktop download base url', () => {
it('builds artifact urls from the configured client API endpoint', async () => {
const version = await createService().getLatestDesktopVersion({...LATEST_PARAMS});
const base = Config.endpoints.apiClient.replace(/\/+$/u, '');
expect(base.length).toBeGreaterThan(0);
expect(version?.files.dmg?.url).toBe(`${base}/dl/desktop/stable/darwin/x64/1.3.0/dmg`);
expect(version?.files.dmg?.checksum_url).toBe(`${base}/dl/desktop/stable/darwin/x64/1.3.0/dmg.sha256`);
});
it('prefers an explicit base url and strips its trailing slashes', async () => {
const version = await createService().getLatestDesktopVersion({
...LATEST_PARAMS,
baseUrl: 'https://chat.example.com/api//',
});
expect(version?.files.dmg?.url).toBe('https://chat.example.com/api/dl/desktop/stable/darwin/x64/1.3.0/dmg');
});
});
@@ -32,11 +32,12 @@ export function ExperimentController(app: HonoApp) {
instanceConfigRepository.getExperimentDeliveryConfig(),
instanceConfigRepository.getVoiceNoiseSuppressionConfig(),
]);
const userId = ctx.get('user').id.toString();
const body: ExperimentAssignmentsResponse = {
poll_interval_seconds: delivery.poll_interval_seconds,
poll_jitter_percent: delivery.poll_jitter_percent,
assignments: {
voice_noise_suppression: resolveVoiceNoiseSuppressionAssignment(voiceConfig, ctx.get('user').id.toString()),
voice_noise_suppression: resolveVoiceNoiseSuppressionAssignment(voiceConfig, userId),
},
};
const etag = `"${createHash('sha256').update(JSON.stringify(body)).digest('hex')}"`;
@@ -49,7 +49,9 @@ describe('GET /experiments', () => {
expect(body).toEqual({
poll_interval_seconds: DEFAULT_EXPERIMENT_POLL_INTERVAL_SECONDS,
poll_jitter_percent: DEFAULT_EXPERIMENT_POLL_JITTER_PERCENT,
assignments: {voice_noise_suppression: INERT_VOICE_NOISE_SUPPRESSION_ASSIGNMENT},
assignments: {
voice_noise_suppression: INERT_VOICE_NOISE_SUPPRESSION_ASSIGNMENT,
},
});
});
@@ -35,7 +35,7 @@ import {
} from '@aws-sdk/client-s3';
import {Upload} from '@aws-sdk/lib-storage';
import {getSignedUrl} from '@aws-sdk/s3-request-presigner';
import type {S3ProviderSettings} from '@fluxer/config/src/S3DownloadsProvider';
import type {S3ProviderSettings} from '@fluxer/config/src/S3ProviderSettings';
import {isSupportedMediaContentType} from '@pkgs/mime_utils/src/ContentTypeUtils';
import {seconds} from 'itty-time';
import {temporaryFile} from 'tempy';
@@ -1,18 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {Config} from '@app/api/Config';
import {createDownloadsStorageService} from '@app/api/infrastructure/StorageServiceFactory';
import {describe, expect, it} from 'vitest';
describe('createDownloadsStorageService', () => {
it('returns null when no downloads override is configured', () => {
expect(Config.s3Downloads.isOverridden).toBe(false);
expect(createDownloadsStorageService()).toBeNull();
});
it('resolves the downloads provider to the shared provider by default', () => {
expect(Config.s3Downloads.settings.endpoint).toBe(Config.s3.endpoint);
expect(Config.s3Downloads.settings.region).toBe(Config.s3.region);
expect(Config.s3Downloads.settings.accessKeyId).toBe(Config.s3.accessKeyId);
});
});
@@ -25,13 +25,6 @@ export function createStorageService(): IStorageService {
return withChangeFeed(new StorageService());
}
export function createDownloadsStorageService(): IStorageService | null {
if (!Config.s3Downloads.isOverridden) {
return null;
}
return withChangeFeed(new StorageService(Config.s3Downloads.settings));
}
export async function shutdownStorageChangeFeed(): Promise<void> {
const feed = changeFeed;
changeFeed = null;
@@ -304,7 +304,6 @@ describe('InstanceConfigRepository', () => {
included_user_ids: ['1400000000000000001'],
excluded_user_ids: ['1400000000000000002'],
guild_overrides: [{guild_id: '2400000000000000001', backend: 'rnnoise'}],
stereo_enabled: true,
suppression_strength: 55,
};
await repository.setVoiceNoiseSuppressionConfig(config);
@@ -9,6 +9,7 @@ import {ipBanCache} from '@app/api/middleware/IpBanMiddleware';
import {getIpInfoService} from '@app/api/middleware/ServiceMiddleware';
import {getKVClient} from '@app/api/middleware/ServiceRegistry';
import {getCacheService} from '@app/api/middleware/ServiceSingletons';
import {isAutoBanExemptAsn} from '@app/api/risk/AutoBanAsnExemptions';
import {isIpBanExempt} from '@app/api/risk/IpBanExemptions';
import type {HonoEnv} from '@app/api/types/HonoEnv';
import {parseJsonRecord} from '@app/api/utils/JsonBoundaryUtils';
@@ -18,7 +19,7 @@ import type {IpInfoLookupResult} from '@pkgs/geoip/src/IpInfoService';
import type {IKVProvider, IKVSubscription} from '@pkgs/kv_client/src/IKVProvider';
import {createMiddleware} from 'hono/factory';
type IpClass = 'datacenter' | 'anonymous' | 'mobile' | 'residential' | 'unknown';
type IpClass = 'datacenter' | 'anonymous' | 'mobile' | 'residential' | 'unknown' | 'exempt';
type TriggerKind = 'score' | 'token_diversity' | 'score_and_token_diversity';
interface AbuseRecord {
@@ -104,7 +105,7 @@ const IP_CLASS_CLAIM_TTL_SECONDS = positiveNumberFromEnv('FLUXER_ABUSE_IP_CLASS_
const DEFAULT_IP_CLASS_PENDING_TTL_MS = positiveNumberFromEnv('FLUXER_ABUSE_IP_CLASS_PENDING_TTL_MS', 20_000);
const DEFAULT_IP_CLASS_NEGATIVE_TTL_MS = positiveNumberFromEnv('FLUXER_ABUSE_IP_CLASS_NEGATIVE_TTL_MS', 300_000);
const DEFAULT_IP_CLASS_HINT_TTL_MS = positiveNumberFromEnv('FLUXER_ABUSE_IP_CLASS_HINT_TTL_MS', 600_000);
const IP_CLASSES = ['datacenter', 'anonymous', 'mobile', 'residential', 'unknown'] as const;
const IP_CLASSES = ['datacenter', 'anonymous', 'mobile', 'residential', 'unknown', 'exempt'] as const;
const POD_ID = process.env.HOSTNAME ?? randomUUID();
type ReplicatedTick = [banKey: string, scoreDelta: number, tokenHashes: Array<string>, lookupIp: string];
@@ -185,6 +186,7 @@ function scoreThresholdFor(ipClass: IpClass): number {
return THRESHOLD_MOBILE;
case 'residential':
case 'unknown':
case 'exempt':
return THRESHOLD_RESIDENTIAL;
}
}
@@ -199,6 +201,7 @@ function tokenDiversityThresholdFor(ipClass: IpClass): number {
return TOKEN_DIVERSITY_MOBILE;
case 'residential':
case 'unknown':
case 'exempt':
return TOKEN_DIVERSITY_RESIDENTIAL;
}
}
@@ -390,6 +393,10 @@ async function claimIpClassLookup(key: string): Promise<boolean> {
async function runIpClassLookup(key: string, lookupIp: string): Promise<void> {
try {
if (await isAutoBanExemptAsn(lookupIp)) {
setOwnIpClass(key, lookupIp, 'exempt', false);
return;
}
if (!(await claimIpClassLookup(key))) return;
const result = await getIpInfoService().lookup(lookupIp, {source: 'AbusiveIpAutoBanner', reason: 'classify'});
setOwnIpClass(key, lookupIp, classifyIpInfo(result), !result.available);
@@ -427,6 +434,14 @@ function maybeFireAutoBan(key: string, rec: AbuseRecord): void {
if (resolved.blocked) {
return;
}
if (ipClass === 'exempt') {
rec.autoBanFired = true;
Logger.warn(
{ip: key, ipClass, score: rec.score, distinctTokens: rec.distinctTokenHashes.size},
'[abuse-auto-ban] Skipping automatic IP ban because the ASN is exempt',
);
return;
}
if (shouldSkipAutoBanForIpClass(ipClass)) {
rec.autoBanFired = true;
Logger.warn(
@@ -77,7 +77,6 @@ import {
getContactChangeLogService,
getDiscriminatorService,
getDonationRepository,
getDownloadService,
getEmailChangeRepository,
getEmailDnsValidationService,
getEmailService,
@@ -535,10 +534,6 @@ class RequestServices implements RequestScopedServices {
return getContactChangeLogService();
}
get downloadService() {
return getDownloadService();
}
get emailService() {
return getEmailService();
}
@@ -19,7 +19,6 @@ import {createNcmecApiConfig, NcmecReporter} from '@app/api/csam/NcmecReporter';
import {NcmecRepository} from '@app/api/csam/NcmecRepository';
import {NcmecSubmissionService} from '@app/api/csam/NcmecSubmissionService';
import {DonationRepository} from '@app/api/donation/DonationRepository';
import {DownloadService} from '@app/api/download/DownloadService';
import {createEmailProvider} from '@app/api/email/EmailProviderFactory';
import {FavoriteMemeRepository} from '@app/api/favorite_meme/FavoriteMemeRepository';
import {GatewayRequestService} from '@app/api/gateway/GatewayRequestService';
@@ -46,7 +45,7 @@ import {KVActivityTracker} from '@app/api/infrastructure/KVActivityTracker';
import {KVBulkMessageDeletionQueueService} from '@app/api/infrastructure/KVBulkMessageDeletionQueueService';
import {NatsUnfurlerService} from '@app/api/infrastructure/NatsUnfurlerService';
import {PremiumStateReconciliationQueueService} from '@app/api/infrastructure/PremiumStateReconciliationQueueService';
import {createDownloadsStorageService, createStorageService} from '@app/api/infrastructure/StorageServiceFactory';
import {createStorageService} from '@app/api/infrastructure/StorageServiceFactory';
import {UserCacheService} from '@app/api/infrastructure/UserCacheService';
import {createUsersServiceClient} from '@app/api/infrastructure/UsersServiceClient';
import {VirusScanService} from '@app/api/infrastructure/VirusScanService';
@@ -217,10 +216,6 @@ export const getStorageService: () => IStorageService = (() => {
const fallback = singleton(() => createStorageService());
return () => _injectedStorageService ?? fallback();
})();
const getDownloadsStorageService: () => IStorageService = (() => {
const override = singleton(() => createDownloadsStorageService());
return () => override() ?? getStorageService();
})();
export const getErrorI18nService = singleton(() => new ErrorI18nService());
let limitConfigServiceInstance: LimitConfigService | null = null;
export const getLimitConfigService = singleton(
@@ -304,7 +299,6 @@ export function getKVAccountDeletionQueue(): KVAccountDeletionQueueService {
return accountDeletionQueue;
}
export const getDownloadService = singleton(() => new DownloadService(getDownloadsStorageService()));
export const getThemeService = singleton(() => new ThemeService(getStorageService()));
const getNcmecReporter = singleton(() => new NcmecReporter({config: createNcmecApiConfig(), fetch}));
const getNcmecRepository = singleton(() => new NcmecRepository());
@@ -13,13 +13,19 @@ import {
} from '@app/api/middleware/AbusiveIpAutoBanner';
import {ipBanCache} from '@app/api/middleware/IpBanMiddleware';
import {setInjectedIpInfoService} from '@app/api/middleware/ServiceMiddleware';
import {resetAutoBanAsnExemptionsForTesting, setInjectedAutoBanAsnLookup} from '@app/api/risk/AutoBanAsnExemptions';
import type {ApiTestHarness} from '@app/api/test/ApiTestHarness';
import {createApiTestHarness} from '@app/api/test/ApiTestHarness';
import type {MockKVProvider} from '@app/api/test/mocks/MockKVProvider';
import {getSameIpDecisionKey} from '@fluxer/ip_utils/src/IpAddress';
import type {GeoipAsnResult} from '@pkgs/geoip/src/GeoipLookup';
import type {IpInfoLookupResult} from '@pkgs/geoip/src/IpInfoService';
import {afterAll, beforeAll, beforeEach, describe, expect, it} from 'vitest';
function asnResult(asn: number | null): GeoipAsnResult {
return {normalizedIp: null, asn, asnOrg: null, available: asn !== null};
}
function ipInfoResult(ip: string, overrides: Partial<IpInfoLookupResult> = {}): IpInfoLookupResult {
return {
ip,
@@ -96,6 +102,7 @@ describe('AbusiveIpAutoBanner', () => {
let harness: ApiTestHarness;
let adminRepository: AdminRepository;
let lookupCount = 0;
let asnLookupCount = 0;
beforeAll(async () => {
harness = await createApiTestHarness();
adminRepository = new AdminRepository();
@@ -104,6 +111,9 @@ describe('AbusiveIpAutoBanner', () => {
await harness.reset();
resetAbuseTrackingForTests();
ipBanCache.resetCaches();
delete process.env.FLUXER_ABUSE_EXEMPT_ASNS;
resetAutoBanAsnExemptionsForTesting();
asnLookupCount = 0;
lookupCount = 0;
setInjectedIpInfoService({
async lookup(ip: string) {
@@ -117,6 +127,8 @@ describe('AbusiveIpAutoBanner', () => {
afterAll(async () => {
await stopAbuseReplicationSubscriber();
setInjectedIpInfoService(undefined);
delete process.env.FLUXER_ABUSE_EXEMPT_ASNS;
resetAutoBanAsnExemptionsForTesting();
await harness.shutdown();
});
it('temporarily bans an IP that tries many distinct invalid tokens', async () => {
@@ -175,6 +187,49 @@ describe('AbusiveIpAutoBanner', () => {
expect(ipBanCache.isBanned(ip)).toBe(false);
await expect(adminRepository.isIpBanned(ip)).resolves.toBe(false);
});
it('does not auto-ban an IP whose ASN is exempt', async () => {
const ip = '9.9.9.9';
process.env.FLUXER_ABUSE_EXEMPT_ASNS = '64501, not-an-asn, 64502';
resetAutoBanAsnExemptionsForTesting();
setInjectedAutoBanAsnLookup(async () => asnResult(64502));
for (let i = 0; i < 100; i += 1) {
recordAbuseSignal(ip, 'auth_failure:session', {tokenHash: hashAuthToken(`exempt-${i}`)});
}
await drainAbuseIpClassLookupsForTests();
await drainAbuseAutoBanTasksForTests();
expect(ipBanCache.isBanned(ip)).toBe(false);
await expect(adminRepository.isIpBanned(ip)).resolves.toBe(false);
expect(lookupCount).toBe(0);
});
it('still auto-bans an IP whose ASN is not on the exempt list', async () => {
const ip = '9.9.9.10';
process.env.FLUXER_ABUSE_EXEMPT_ASNS = '64501';
resetAutoBanAsnExemptionsForTesting();
setInjectedAutoBanAsnLookup(async () => asnResult(64502));
for (let i = 0; i < 10; i += 1) {
recordAbuseSignal(ip, 'auth_failure:session', {tokenHash: hashAuthToken(`not-exempt-${i}`)});
}
await waitForAssertion(() => {
expect(ipBanCache.isBanned(ip)).toBe(true);
});
await drainAbuseAutoBanTasksForTests();
await expect(adminRepository.isIpBanned(ip)).resolves.toBe(true);
});
it('does not resolve an ASN when no exemptions are configured', async () => {
const ip = '9.9.9.11';
setInjectedAutoBanAsnLookup(async () => {
asnLookupCount += 1;
return asnResult(64502);
});
for (let i = 0; i < 10; i += 1) {
recordAbuseSignal(ip, 'auth_failure:session', {tokenHash: hashAuthToken(`no-exempt-list-${i}`)});
}
await waitForAssertion(() => {
expect(ipBanCache.isBanned(ip)).toBe(true);
});
await drainAbuseAutoBanTasksForTests();
expect(asnLookupCount).toBe(0);
});
it('does not auto-ban loopback or private IP addresses', async () => {
for (const ip of ['127.0.0.1', '10.0.0.10', '::ffff:127.0.0.1']) {
for (let i = 0; i < 20; i += 1) {
@@ -28,7 +28,6 @@ const REQUEST_SERVICE_VARIABLES: ReadonlyArray<keyof HonoEnv['Variables']> = [
'contactChangeLogService',
'desktopHandoffService',
'discoveryService',
'downloadService',
'emailChangeService',
'emailService',
'embedService',
-474
View File
@@ -4942,380 +4942,6 @@
]
}
},
"/dl/desktop/{channel}/{plat}/{arch}/latest": {
"get": {
"operationId": "get_latest_desktop_version",
"summary": "Get latest desktop version",
"tags": ["Downloads"],
"responses": {
"200": {
"description": "Success",
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/VersionInfoResponse"}}}
},
"400": {
"description": "Bad Request - The request was malformed or contained invalid data",
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
},
"429": {
"description": "Too Many Requests - You are being rate limited",
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/ThrottledError"}}},
"headers": {
"Retry-After": {
"description": "Number of seconds to wait before retrying (only on 429)",
"schema": {"type": "integer"}
},
"X-RateLimit-Limit": {
"description": "The number of requests that can be made in the current window",
"schema": {"type": "integer"}
},
"X-RateLimit-Remaining": {
"description": "The number of remaining requests that can be made",
"schema": {"type": "integer"}
},
"X-RateLimit-Reset": {
"description": "Unix timestamp when the rate limit resets",
"schema": {"type": "integer"}
}
}
},
"500": {
"description": "Internal Server Error - An unexpected error occurred",
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
}
},
"description": "Returns metadata for the latest desktop version including download URLs and SHA-256 checksums for all available formats. Pass ?test=1 to resolve against unreleased test builds.",
"parameters": [
{
"name": "channel",
"in": "path",
"required": true,
"schema": {"$ref": "#/components/schemas/DesktopChannelEnum"},
"description": "The release channel"
},
{
"name": "plat",
"in": "path",
"required": true,
"schema": {"$ref": "#/components/schemas/DesktopPlatformEnum"},
"description": "The operating system platform"
},
{
"name": "arch",
"in": "path",
"required": true,
"schema": {"$ref": "#/components/schemas/DesktopArchEnum"},
"description": "The CPU architecture"
},
{
"name": "test",
"in": "query",
"required": false,
"schema": {
"description": "When set to 1/true, resolve against the desktop-test/ bucket prefix instead of desktop/.",
"type": "string"
},
"description": "When set to 1/true, resolve against the desktop-test/ bucket prefix instead of desktop/."
}
]
}
},
"/dl/desktop/{channel}/{plat}/{arch}/latest/{format}": {
"get": {
"operationId": "download_latest_desktop_version",
"summary": "Download latest desktop version",
"tags": ["Downloads"],
"responses": {
"200": {
"description": "Success",
"content": {"*/*": {"schema": {"$ref": "#/components/schemas/DownloadFileResponse"}}}
},
"206": {
"description": "Success",
"content": {"*/*": {"schema": {"$ref": "#/components/schemas/DownloadFileResponse"}}}
},
"302": {"description": "Success"},
"400": {
"description": "Bad Request - The request was malformed or contained invalid data",
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
},
"429": {
"description": "Too Many Requests - You are being rate limited",
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/ThrottledError"}}},
"headers": {
"Retry-After": {
"description": "Number of seconds to wait before retrying (only on 429)",
"schema": {"type": "integer"}
},
"X-RateLimit-Limit": {
"description": "The number of requests that can be made in the current window",
"schema": {"type": "integer"}
},
"X-RateLimit-Remaining": {
"description": "The number of remaining requests that can be made",
"schema": {"type": "integer"}
},
"X-RateLimit-Reset": {
"description": "Unix timestamp when the rate limit resets",
"schema": {"type": "integer"}
}
}
},
"500": {
"description": "Internal Server Error - An unexpected error occurred",
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
}
},
"description": "Streams the latest available desktop application version for the specified platform and architecture. Pass ?test=1 to download an unreleased test build.",
"parameters": [
{
"name": "channel",
"in": "path",
"required": true,
"schema": {"$ref": "#/components/schemas/DesktopChannelEnum"},
"description": "The release channel"
},
{
"name": "plat",
"in": "path",
"required": true,
"schema": {"$ref": "#/components/schemas/DesktopPlatformEnum"},
"description": "The operating system platform"
},
{
"name": "arch",
"in": "path",
"required": true,
"schema": {"$ref": "#/components/schemas/DesktopArchEnum"},
"description": "The CPU architecture"
},
{
"name": "format",
"in": "path",
"required": true,
"schema": {"$ref": "#/components/schemas/DesktopFormatEnum"},
"description": "The package format"
},
{
"name": "test",
"in": "query",
"required": false,
"schema": {
"description": "When set to 1/true, resolve against the desktop-test/ bucket prefix instead of desktop/.",
"type": "string"
},
"description": "When set to 1/true, resolve against the desktop-test/ bucket prefix instead of desktop/."
}
]
}
},
"/dl/desktop/{channel}/{plat}/{arch}/versions": {
"get": {
"operationId": "list_desktop_versions",
"summary": "List desktop versions",
"tags": ["Downloads"],
"responses": {
"200": {
"description": "Success",
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/DesktopVersionsResponse"}}}
},
"400": {
"description": "Bad Request - The request was malformed or contained invalid data",
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
},
"429": {
"description": "Too Many Requests - You are being rate limited",
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/ThrottledError"}}},
"headers": {
"Retry-After": {
"description": "Number of seconds to wait before retrying (only on 429)",
"schema": {"type": "integer"}
},
"X-RateLimit-Limit": {
"description": "The number of requests that can be made in the current window",
"schema": {"type": "integer"}
},
"X-RateLimit-Remaining": {
"description": "The number of remaining requests that can be made",
"schema": {"type": "integer"}
},
"X-RateLimit-Reset": {
"description": "Unix timestamp when the rate limit resets",
"schema": {"type": "integer"}
}
}
},
"500": {
"description": "Internal Server Error - An unexpected error occurred",
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
}
},
"description": "Lists available desktop versions with pagination for the specified platform and architecture.",
"parameters": [
{
"name": "channel",
"in": "path",
"required": true,
"schema": {"$ref": "#/components/schemas/DesktopChannelEnum"},
"description": "The release channel"
},
{
"name": "plat",
"in": "path",
"required": true,
"schema": {"$ref": "#/components/schemas/DesktopPlatformEnum"},
"description": "The operating system platform"
},
{
"name": "arch",
"in": "path",
"required": true,
"schema": {"$ref": "#/components/schemas/DesktopArchEnum"},
"description": "The CPU architecture"
},
{
"name": "limit",
"in": "query",
"required": false,
"schema": {
"default": 25,
"description": "Maximum number of versions to return",
"type": "integer",
"minimum": 1,
"maximum": 100
},
"description": "Maximum number of versions to return"
},
{
"name": "before",
"in": "query",
"required": false,
"schema": {
"description": "Return versions before this version",
"type": "string",
"pattern": "^\\d+\\.\\d+\\.\\d+$"
},
"description": "Return versions before this version"
},
{
"name": "after",
"in": "query",
"required": false,
"schema": {
"description": "Return versions after this version",
"type": "string",
"pattern": "^\\d+\\.\\d+\\.\\d+$"
},
"description": "Return versions after this version"
},
{
"name": "test",
"in": "query",
"required": false,
"schema": {
"description": "When set to 1/true, resolve against the desktop-test/ bucket prefix instead of desktop/.",
"type": "string"
},
"description": "When set to 1/true, resolve against the desktop-test/ bucket prefix instead of desktop/."
}
]
}
},
"/dl/desktop/{channel}/{plat}/{arch}/{version}/{format}": {
"get": {
"operationId": "download_desktop_version",
"summary": "Download desktop version",
"tags": ["Downloads"],
"responses": {
"200": {
"description": "Success",
"content": {"*/*": {"schema": {"$ref": "#/components/schemas/DownloadFileResponse"}}}
},
"206": {
"description": "Success",
"content": {"*/*": {"schema": {"$ref": "#/components/schemas/DownloadFileResponse"}}}
},
"302": {"description": "Success"},
"400": {
"description": "Bad Request - The request was malformed or contained invalid data",
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
},
"429": {
"description": "Too Many Requests - You are being rate limited",
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/ThrottledError"}}},
"headers": {
"Retry-After": {
"description": "Number of seconds to wait before retrying (only on 429)",
"schema": {"type": "integer"}
},
"X-RateLimit-Limit": {
"description": "The number of requests that can be made in the current window",
"schema": {"type": "integer"}
},
"X-RateLimit-Remaining": {
"description": "The number of remaining requests that can be made",
"schema": {"type": "integer"}
},
"X-RateLimit-Reset": {
"description": "Unix timestamp when the rate limit resets",
"schema": {"type": "integer"}
}
}
},
"500": {
"description": "Internal Server Error - An unexpected error occurred",
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
}
},
"description": "Streams a specific desktop application version for the given platform and architecture. Pass ?test=1 to download an unreleased test build.",
"parameters": [
{
"name": "channel",
"in": "path",
"required": true,
"schema": {"$ref": "#/components/schemas/DesktopChannelEnum"},
"description": "The release channel"
},
{
"name": "plat",
"in": "path",
"required": true,
"schema": {"$ref": "#/components/schemas/DesktopPlatformEnum"},
"description": "The operating system platform"
},
{
"name": "arch",
"in": "path",
"required": true,
"schema": {"$ref": "#/components/schemas/DesktopArchEnum"},
"description": "The CPU architecture"
},
{
"name": "format",
"in": "path",
"required": true,
"schema": {"$ref": "#/components/schemas/DesktopFormatEnum"},
"description": "The package format"
},
{
"name": "version",
"in": "path",
"required": true,
"schema": {"type": "string", "pattern": "^\\d+\\.\\d+\\.\\d+$", "description": "Semantic version string"},
"description": "Semantic version string"
},
{
"name": "test",
"in": "query",
"required": false,
"schema": {
"description": "When set to 1/true, resolve against the desktop-test/ bucket prefix instead of desktop/.",
"type": "string"
},
"description": "When set to 1/true, resolve against the desktop-test/ bucket prefix instead of desktop/."
}
]
}
},
"/donations/checkout": {
"post": {
"operationId": "create_donation_checkout",
@@ -27514,103 +27140,6 @@
"required": ["url"],
"additionalProperties": false
},
"DesktopFormatEnum": {
"description": "The package format",
"x-enumNames": ["Setup", "DMG", "ZIP", "AppImage", "DEB", "RPM", "TAR.GZ", "Portable"],
"x-enumDescriptions": [
"Windows installer executable",
"macOS disk image",
"Compressed archive",
"Linux portable application",
"Debian/Ubuntu package",
"Red Hat/Fedora package",
"Compressed tarball archive",
"Windows portable ZIP archive (no installer, stores data next to the executable)"
],
"enum": ["setup", "dmg", "zip", "appimage", "deb", "rpm", "tar_gz", "portable"],
"type": "string"
},
"DesktopArchEnum": {
"description": "The CPU architecture",
"x-enumNames": ["x64", "ARM64"],
"x-enumDescriptions": [
"64-bit x86 architecture (Intel/AMD)",
"64-bit ARM architecture (Apple Silicon, ARM processors)"
],
"enum": ["x64", "arm64"],
"type": "string"
},
"DesktopPlatformEnum": {
"description": "The operating system platform",
"x-enumNames": ["Windows", "macOS", "Linux"],
"x-enumDescriptions": [
"Microsoft Windows operating system",
"Apple macOS operating system",
"Linux operating system"
],
"enum": ["win32", "darwin", "linux"],
"type": "string"
},
"DesktopChannelEnum": {
"description": "The release channel",
"x-enumNames": ["Stable", "Canary"],
"x-enumDescriptions": [
"The stable release channel for production use",
"The canary release channel for early access to new features"
],
"enum": ["stable", "canary"],
"type": "string"
},
"DownloadFileResponse": {
"type": "string",
"format": "binary",
"contentEncoding": "binary",
"description": "The downloadable release file"
},
"DesktopVersionsResponse": {
"type": "object",
"properties": {
"versions": {
"type": "array",
"items": {"$ref": "#/components/schemas/VersionInfoResponse"},
"description": "Array of available versions"
},
"has_more": {"type": "boolean", "description": "Whether more versions are available to fetch"}
},
"required": ["versions", "has_more"],
"additionalProperties": false
},
"VersionInfoResponse": {
"type": "object",
"properties": {
"version": {"type": "string", "description": "Semantic version string (e.g., 1.0.0)"},
"pub_date": {"type": "string", "description": "ISO 8601 date when this version was published"},
"minimum_system_version": {
"description": "Minimum operating system version required by this release, when applicable",
"type": ["string", "null"]
},
"files": {
"type": "object",
"propertyNames": {"$ref": "#/components/schemas/DesktopFormatEnum"},
"additionalProperties": {
"type": "object",
"properties": {
"url": {"type": "string", "description": "Download URL for this file"},
"sha256": {"description": "SHA-256 hash of the file for verification", "type": ["string", "null"]},
"checksum_url": {
"description": "Plain text .sha256 checksum file URL for this file",
"type": ["string", "null"]
}
},
"required": ["url", "sha256", "checksum_url"],
"additionalProperties": false
},
"description": "Map of package format to download files"
}
},
"required": ["version", "pub_date", "files"],
"additionalProperties": false
},
"DiscoveryGuildListResponse": {
"type": "object",
"properties": {
@@ -31054,7 +30583,6 @@
"items": {"$ref": "#/components/schemas/VoiceNoiseSuppressionBackendSchema"}
},
"allow_user_override": {"type": "boolean"},
"stereo_enabled": {"type": "boolean"},
"suppression_strength": {"type": "integer", "minimum": 0, "maximum": 100}
},
"required": [
@@ -31066,7 +30594,6 @@
"guild_overrides",
"enabled_backends",
"allow_user_override",
"stereo_enabled",
"suppression_strength"
],
"additionalProperties": false
@@ -34305,7 +33832,6 @@
{"name": "Read States", "description": "Message read state tracking"},
{"name": "Saved Media", "description": "User saved media management"},
{"name": "Themes", "description": "User interface themes"},
{"name": "Downloads", "description": "App downloads"},
{"name": "Reports", "description": "Content reporting"},
{"name": "Instance", "description": "Instance configuration and info"},
{"name": "Billing", "description": "Subscription and payment management via Stripe"},
@@ -0,0 +1,52 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {Config} from '@app/api/Config';
import {type GeoipAsnResult, lookupAsnByIp} from '@pkgs/geoip/src/GeoipLookup';
const ASN_ENTRY_REGEX = /^\d+$/u;
type AsnLookup = (ip: string) => Promise<GeoipAsnResult>;
let exemptAsns: ReadonlySet<number> | null = null;
let injectedAsnLookup: AsnLookup | undefined;
function getExemptAsns(): ReadonlySet<number> {
if (exemptAsns) {
return exemptAsns;
}
const asns = new Set<number>();
const rawValue = process.env.FLUXER_ABUSE_EXEMPT_ASNS;
if (rawValue) {
for (const entry of rawValue.split(',')) {
const trimmed = entry.trim();
if (!ASN_ENTRY_REGEX.test(trimmed)) continue;
const asn = Number.parseInt(trimmed, 10);
if (Number.isSafeInteger(asn) && asn > 0) asns.add(asn);
}
}
exemptAsns = asns;
return asns;
}
function resolveAsn(ip: string): Promise<GeoipAsnResult> {
if (injectedAsnLookup) {
return injectedAsnLookup(ip);
}
return lookupAsnByIp(ip, Config.geoip.maxmindAsnDbPath);
}
export async function isAutoBanExemptAsn(ip: string): Promise<boolean> {
const asns = getExemptAsns();
if (asns.size === 0) return false;
const result = await resolveAsn(ip);
return result.asn !== null && asns.has(result.asn);
}
export function setInjectedAutoBanAsnLookup(lookup: AsnLookup | undefined): void {
injectedAsnLookup = lookup;
}
export function resetAutoBanAsnExemptionsForTesting(): void {
exemptAsns = null;
injectedAsnLookup = undefined;
}
@@ -14,6 +14,7 @@ import {resetServiceSingletonsForTesting} from '@app/api/middleware/ServiceSingl
import {torExitListCache} from '@app/api/middleware/TorExitListCache';
import {urlBlocklistCache} from '@app/api/middleware/UrlBlocklistCache';
import {resetAdminSecretHashForTesting} from '@app/api/oauth/repositories/ApplicationRepository';
import {resetAutoBanAsnExemptionsForTesting} from '@app/api/risk/AutoBanAsnExemptions';
import {resetIpBanExemptionsForTesting} from '@app/api/risk/IpBanExemptions';
import {setThemeCssMaxBytesForTesting} from '@app/api/theme/ThemeService';
import {resetGeoipReadersForTesting} from '@pkgs/geoip/src/GeoipLookup';
@@ -23,6 +24,7 @@ export async function resetServiceStateForTesting(): Promise<void> {
resetServiceSingletonsForTesting();
resetServiceMiddlewareForTesting();
resetIpBanExemptionsForTesting();
resetAutoBanAsnExemptionsForTesting();
resetGlobalLimitConfigServiceForTesting();
resetSudoModeServiceForTesting();
resetSsoRequestUrlPolicyForTesting();
-2
View File
@@ -19,7 +19,6 @@ import type {ConnectionRequestService} from '@app/api/connection/ConnectionReque
import type {ConnectionService} from '@app/api/connection/ConnectionService';
import type {NcmecSubmissionService} from '@app/api/csam/NcmecSubmissionService';
import type {DonationService} from '@app/api/donation/DonationService';
import type {DownloadService} from '@app/api/download/DownloadService';
import type {FavoriteMemeRequestService} from '@app/api/favorite_meme/FavoriteMemeRequestService';
import type {FavoriteMemeService} from '@app/api/favorite_meme/FavoriteMemeService';
import type {GatewayRequestService} from '@app/api/gateway/GatewayRequestService';
@@ -128,7 +127,6 @@ export interface HonoEnv {
connectionRequestService: ConnectionRequestService;
blueskyOAuthService: IBlueskyOAuthService;
donationService: DonationService;
downloadService: DownloadService;
streamPreviewService: StreamPreviewService;
streamService: StreamService;
emailService: IEmailService;
@@ -0,0 +1,44 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {GuildID, UserID} from '@app/api/BrandedTypes';
import {mapGuildMemberToResponse} from '@app/api/guild/GuildModel';
import type {IGuildRepositoryAggregate} from '@app/api/guild/repositories/IGuildRepositoryAggregate';
import type {IGatewayService} from '@app/api/infrastructure/IGatewayService';
import type {UserCacheService} from '@app/api/infrastructure/UserCacheService';
import {createRequestCache} from '@app/api/middleware/RequestCacheMiddleware';
import type {User} from '@app/api/models/User';
import {updateUserCache} from '@app/api/user/UserCacheHelpers';
interface UserGuildIdReader {
getUserGuildIds(userId: UserID): Promise<Array<GuildID>>;
}
export interface PartialUserChangePropagationDeps {
userCacheService: UserCacheService;
gatewayService: IGatewayService;
userRepository: UserGuildIdReader;
guildRepository: Pick<IGuildRepositoryAggregate, 'getMember'>;
}
export async function propagatePartialUserChange(deps: PartialUserChangePropagationDeps, user: User): Promise<void> {
const {userCacheService, gatewayService, userRepository, guildRepository} = deps;
await updateUserCache({user, userCacheService});
const guildIds = await userRepository.getUserGuildIds(user.id);
if (guildIds.length === 0) {
return;
}
const requestCache = createRequestCache();
for (const guildId of guildIds) {
const member = await guildRepository.getMember(guildId, user.id);
if (!member) {
continue;
}
const memberResponse = await mapGuildMemberToResponse(member, userCacheService, requestCache);
await gatewayService.dispatchGuild({
guildId,
event: 'GUILD_MEMBER_UPDATE',
data: memberResponse,
});
}
requestCache.clear();
}
@@ -84,7 +84,7 @@ export class UserAccountLifecycleService {
if (updatedUser) {
await this.deps.updatePropagator.dispatchUserUpdate(updatedUser);
if (hasPartialUserFieldsChanged(user, updatedUser)) {
await this.deps.updatePropagator.updateUserCache(updatedUser);
await this.deps.updatePropagator.propagatePartialUserChange(updatedUser);
}
}
}
@@ -125,7 +125,7 @@ export class UserAccountLifecycleService {
if (updatedUser) {
await this.deps.updatePropagator.dispatchUserUpdate(updatedUser);
if (hasPartialUserFieldsChanged(user, updatedUser)) {
await this.deps.updatePropagator.updateUserCache(updatedUser);
await this.deps.updatePropagator.propagatePartialUserChange(updatedUser);
}
}
}
@@ -87,6 +87,7 @@ export class UserAccountService {
gatewayService,
mediaService,
userRepository: userAccountRepository,
guildRepository,
});
this.lookupService = new UserAccountLookupService({
userAccountRepository,
@@ -193,7 +194,7 @@ export class UserAccountService {
() => this.updatePropagator.dispatchUserUpdate(updatedUser),
async () => {
if (hasPartialUserFieldsChanged(user, updatedUser)) {
await this.updatePropagator.updateUserCache(updatedUser);
await this.updatePropagator.propagatePartialUserChange(updatedUser);
}
},
async () => {
@@ -240,7 +241,7 @@ export class UserAccountService {
const updatedUser = await this.userAccountRepository.patchUpsert(user.id, updates, user.toRow());
await this.updatePropagator.dispatchUserUpdate(updatedUser);
if (hasPartialUserFieldsChanged(user, updatedUser)) {
await this.updatePropagator.updateUserCache(updatedUser);
await this.updatePropagator.propagatePartialUserChange(updatedUser);
}
}
}
@@ -1,13 +1,16 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {UserID} from '@app/api/BrandedTypes';
import type {IGuildRepositoryAggregate} from '@app/api/guild/repositories/IGuildRepositoryAggregate';
import type {IGatewayService} from '@app/api/infrastructure/IGatewayService';
import type {IMediaService} from '@app/api/infrastructure/IMediaService';
import type {UserCacheService} from '@app/api/infrastructure/UserCacheService';
import type {User} from '@app/api/models/User';
import type {UserGuildSettings} from '@app/api/models/UserGuildSettings';
import type {UserSettings} from '@app/api/models/UserSettings';
import type {IUserAccountRepository} from '@app/api/user/repositories/IUserAccountRepository';
import {BaseUserUpdatePropagator} from '@app/api/user/services/BaseUserUpdatePropagator';
import {propagatePartialUserChange} from '@app/api/user/services/PartialUserChangePropagation';
import {mapUserGuildSettingsToResponse, mapUserSettingsToResponse} from '@app/api/user/UserMappers';
interface UserAccountUpdatePropagatorDeps {
@@ -15,6 +18,7 @@ interface UserAccountUpdatePropagatorDeps {
gatewayService: IGatewayService;
mediaService: IMediaService;
userRepository: IUserAccountRepository;
guildRepository: IGuildRepositoryAggregate;
}
export class UserAccountUpdatePropagator extends BaseUserUpdatePropagator {
@@ -25,6 +29,10 @@ export class UserAccountUpdatePropagator extends BaseUserUpdatePropagator {
});
}
async propagatePartialUserChange(user: User): Promise<void> {
await propagatePartialUserChange(this.deps, user);
}
async dispatchUserSettingsUpdate({userId, settings}: {userId: UserID; settings: UserSettings}): Promise<void> {
await this.deps.gatewayService.dispatchPresence({
userId,
@@ -0,0 +1,99 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {createGuildID, createUserID} from '@app/api/BrandedTypes';
import type {IGuildRepositoryAggregate} from '@app/api/guild/repositories/IGuildRepositoryAggregate';
import type {IGatewayService} from '@app/api/infrastructure/IGatewayService';
import type {UserCacheService} from '@app/api/infrastructure/UserCacheService';
import type {GuildMember} from '@app/api/models/GuildMember';
import type {User} from '@app/api/models/User';
import {
type PartialUserChangePropagationDeps,
propagatePartialUserChange,
} from '@app/api/user/services/PartialUserChangePropagation';
import type {UserPartialResponse} from '@fluxer/schema/src/domains/user/UserResponseSchemas';
import {describe, expect, test, vi} from 'vitest';
const USER_ID = createUserID(1n);
const GUILD_IDS = [createGuildID(10n), createGuildID(11n)];
const USER = {id: USER_ID} as unknown as User;
const USER_PARTIAL: UserPartialResponse = {
id: USER_ID.toString(),
username: 'ada',
discriminator: '0001',
global_name: null,
avatar: 'newhash',
avatar_color: null,
flags: 0,
};
function createMember(): GuildMember {
return {
userId: USER_ID,
nickname: null,
avatarHash: null,
bannerHash: null,
accentColor: null,
roleIds: new Set(),
joinedAt: new Date(0),
isMute: false,
isDeaf: false,
communicationDisabledUntil: null,
profileFlags: 0,
mentionFlags: 0,
isPremiumSanitized: false,
} as unknown as GuildMember;
}
function createDeps(guildIds: Array<(typeof GUILD_IDS)[number]> = GUILD_IDS) {
const dispatchGuild = vi.fn().mockResolvedValue(undefined);
const setUserPartialResponseFromUser = vi.fn().mockResolvedValue(USER_PARTIAL);
const getUserGuildIds = vi.fn().mockResolvedValue(guildIds);
const getMember = vi.fn().mockResolvedValue(createMember());
const deps: PartialUserChangePropagationDeps = {
userCacheService: {
setUserPartialResponseFromUser,
getUserPartialResponse: async () => USER_PARTIAL,
} as unknown as UserCacheService,
gatewayService: {dispatchGuild} as unknown as IGatewayService,
userRepository: {getUserGuildIds},
guildRepository: {getMember} as unknown as IGuildRepositoryAggregate,
};
return {deps, dispatchGuild, setUserPartialResponseFromUser, getUserGuildIds, getMember};
}
describe('propagatePartialUserChange', () => {
test('invalidates the users service cache and pushes the new partial to every guild', async () => {
const {deps, dispatchGuild, setUserPartialResponseFromUser} = createDeps();
await propagatePartialUserChange(deps, USER);
expect(setUserPartialResponseFromUser).toHaveBeenCalledWith(USER);
expect(dispatchGuild).toHaveBeenCalledTimes(2);
for (const [index, guildId] of GUILD_IDS.entries()) {
const call = dispatchGuild.mock.calls[index]![0];
expect(call.guildId).toBe(guildId);
expect(call.event).toBe('GUILD_MEMBER_UPDATE');
expect((call.data as {user: UserPartialResponse}).user).toEqual(USER_PARTIAL);
}
});
test('dispatches nothing when the user is in no guilds', async () => {
const {deps, dispatchGuild, getMember} = createDeps([]);
await propagatePartialUserChange(deps, USER);
expect(getMember).not.toHaveBeenCalled();
expect(dispatchGuild).not.toHaveBeenCalled();
});
test('skips guilds the user is no longer a member of', async () => {
const {deps, dispatchGuild, getMember} = createDeps();
getMember.mockResolvedValueOnce(null);
await propagatePartialUserChange(deps, USER);
expect(dispatchGuild).toHaveBeenCalledTimes(1);
expect(dispatchGuild.mock.calls[0]![0].guildId).toBe(GUILD_IDS[1]);
});
});
@@ -0,0 +1,63 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {createTestAccount} from '@app/api/auth/tests/AuthTestUtils';
import {createTestGuild, getPngDataUrl} from '@app/api/emoji/tests/EmojiTestUtils';
import {type ApiTestHarness, createApiTestHarness} from '@app/api/test/ApiTestHarness';
import {NoopGatewayService} from '@app/api/test/NoopGatewayService';
import {updateAvatar} from '@app/api/user/tests/UserTestUtils';
import type {GuildMemberResponse} from '@fluxer/schema/src/domains/guild/GuildMemberSchemas';
import {afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, type MockInstance, vi} from 'vitest';
type DispatchGuildSpy = MockInstance<NoopGatewayService['dispatchGuild']>;
interface MemberUpdateDispatch {
guildId: {toString(): string};
event: string;
data: GuildMemberResponse;
}
function memberUpdatesFor(dispatchGuild: DispatchGuildSpy, guildId: string): Array<MemberUpdateDispatch> {
return dispatchGuild.mock.calls
.map(([params]) => params as unknown as MemberUpdateDispatch)
.filter((params) => params.event === 'GUILD_MEMBER_UPDATE' && params.guildId.toString() === guildId);
}
describe('User Profile Guild Propagation', () => {
let harness: ApiTestHarness;
beforeAll(async () => {
harness = await createApiTestHarness();
});
beforeEach(async () => {
await harness.reset();
});
afterEach(() => {
vi.restoreAllMocks();
});
afterAll(async () => {
await harness?.shutdown();
});
it('pushes the new avatar to every guild the user is in', async () => {
const account = await createTestAccount(harness);
const guild = await createTestGuild(harness, account.token);
const dispatchGuild = vi.spyOn(NoopGatewayService.prototype, 'dispatchGuild');
const updated = await updateAvatar(harness, account.token, getPngDataUrl());
expect(updated.avatar).toBeTruthy();
const updates = memberUpdatesFor(dispatchGuild, guild.id);
expect(updates).toHaveLength(1);
expect(updates[0]!.data.user.id).toBe(account.userId);
expect(updates[0]!.data.user.avatar).toBe(updated.avatar);
});
it('does not push a member update when no partial field changed', async () => {
const account = await createTestAccount(harness);
const guild = await createTestGuild(harness, account.token);
const dispatchGuild = vi.spyOn(NoopGatewayService.prototype, 'dispatchGuild');
await updateAvatar(harness, account.token, null);
expect(memberUpdatesFor(dispatchGuild, guild.id)).toHaveLength(0);
});
});
@@ -2,9 +2,19 @@
//
// SPDX-License-Identifier: Apache-2.0
import type {MediaDescription} from 'sdp-transform';
import {parse} from 'sdp-transform';
import {describe, expect, it} from 'vitest';
import type {TrackBitrateInfo} from './PCTransport.ts';
import {applyVideoStartBitrate, collectStereoMids, ensureAudioNackAndStereo, ensureOpusFmtp} from './PCTransport.ts';
import {
applyVideoStartBitrate,
collectStereoMids,
ensureAudioNackAndStereo,
ensureOpusFmtp,
ensureVideoDDExtension,
placeholderMidsFromTransceivers,
videoSectionCanReceiveAV1,
} from './PCTransport.ts';
import {ddExtensionURI} from './utils.ts';
function videoMedia(
trackId: string,
@@ -166,3 +176,89 @@ describe('collectStereoMids', () => {
expect(collectStereoMids([audioBitrateInfo(null, 'mic-track', false)], media)).toEqual([]);
});
});
const singlePcOffer = `v=0
o=- 0 0 IN IP4 127.0.0.1
s=-
t=0 0
a=group:BUNDLE 0 1 2
m=video 9 UDP/TLS/RTP/SAVPF 96 45
c=IN IP4 0.0.0.0
a=mid:0
a=sendonly
a=msid:s cam
a=rtpmap:96 VP8/90000
a=rtpmap:45 AV1/90000
a=extmap:2 http://www.webrtc.org/experiments/rtp-hdrext/abs-send-time
a=extmap:11 urn:ietf:params:rtp-hdrext:sdes:repaired-rtp-stream-id
m=video 9 UDP/TLS/RTP/SAVPF 96 45
c=IN IP4 0.0.0.0
a=mid:1
a=recvonly
a=rtpmap:96 VP8/90000
a=rtpmap:45 AV1/90000
a=extmap:2 http://www.webrtc.org/experiments/rtp-hdrext/abs-send-time
m=video 9 UDP/TLS/RTP/SAVPF 96
c=IN IP4 0.0.0.0
a=mid:2
a=recvonly
a=rtpmap:96 VP8/90000
a=extmap:2 http://www.webrtc.org/experiments/rtp-hdrext/abs-send-time`;
const sectionOf = (sdp: ReturnType<typeof parse>, mid: string) => sdp.media.find((media) => `${media.mid}` === mid)!;
const ddOf = (sdp: ReturnType<typeof parse>, mid: string) =>
sectionOf(sdp, mid).ext?.find((ext) => ext.uri === ddExtensionURI)?.value;
describe('videoSectionCanReceiveAV1', () => {
it('is true for a section we receive on that kept AV1, false otherwise', () => {
const sdp = parse(singlePcOffer);
expect(videoSectionCanReceiveAV1(sectionOf(sdp, '1'))).toBe(true);
expect(videoSectionCanReceiveAV1(sectionOf(sdp, '0'))).toBe(false);
expect(videoSectionCanReceiveAV1(sectionOf(sdp, '2'))).toBe(false);
});
});
describe('ensureVideoDDExtension', () => {
it('assigns an id above every extension in the bundle and reuses it', () => {
const sdp = parse(singlePcOffer);
expect(ensureVideoDDExtension(sectionOf(sdp, '1'), sdp, 0)).toBe(12);
expect(ddOf(sdp, '1')).toBe(12);
expect(ensureVideoDDExtension(sectionOf(sdp, '2'), sdp, 12)).toBe(12);
expect(ddOf(sdp, '2')).toBe(12);
});
it('adopts an id the bundle already maps the extension to', () => {
const sdp = parse(singlePcOffer);
sectionOf(sdp, '0').ext!.push({value: 13, uri: ddExtensionURI});
expect(ensureVideoDDExtension(sectionOf(sdp, '1'), sdp, 7)).toBe(13);
expect(ddOf(sdp, '1')).toBe(13);
});
it('leaves a section that already carries the extension alone', () => {
const sdp = parse(`${singlePcOffer}\na=extmap:3 ${ddExtensionURI}`);
expect(ensureVideoDDExtension(sectionOf(sdp, '2'), sdp, 0)).toBe(3);
expect(sectionOf(sdp, '2').ext).toHaveLength(2);
});
});
describe('placeholderMidsFromTransceivers', () => {
function transceiver(
mid: string | null,
track: MediaStreamTrack | null,
currentDirection: RTCRtpTransceiverDirection | null,
): RTCRtpTransceiver {
return {mid, currentDirection, sender: {track}} as unknown as RTCRtpTransceiver;
}
it('keeps the trackless recvonly sections that still hold an m-line', () => {
const mids = placeholderMidsFromTransceivers([
transceiver('3', null, 'recvonly'),
transceiver('7', {} as MediaStreamTrack, 'sendonly'),
]);
expect(mids).toEqual(new Set(['3']));
});
it('drops a transceiver that unpublish stopped so its recycled m-section is not fmtp-conformed', () => {
expect(placeholderMidsFromTransceivers([transceiver('7', null, 'stopped')])).toEqual(new Set());
});
});
@@ -10,7 +10,7 @@ import log, {getLogger, LoggerNames} from '../logger.ts';
import {debounce} from './debounce.ts';
import {NegotiationError, UnexpectedConnectionState} from './errors.ts';
import type {LoggerOptions} from './types.ts';
import {ddExtensionURI, isFireFox, isSafari, isSVCCodec} from './utils.ts';
import {ddExtensionURI, isChromiumBased, isFireFox, isSafari, isSVCCodec} from './utils.ts';
export interface TrackBitrateInfo {
cid?: string;
@@ -372,6 +372,9 @@ export default class PCTransport extends (EventEmitter as new () => TypedEmitter
if (media.type === 'audio') {
ensureAudioNackAndStereo(media, stereoMids, []);
} else if (media.type === 'video') {
if (isChromiumBased() && videoSectionCanReceiveAV1(media)) {
this.ddExtID = ensureVideoDDExtension(media, sdpParsed, this.ddExtID);
}
this.trackBitrates.some((trackbr): boolean => {
if (!trackbr.cid) {
return false;
@@ -677,6 +680,11 @@ export function ensureVideoDDExtension(
return id;
}
export function videoSectionCanReceiveAV1(media: MediaDescription): boolean {
if (media.direction !== 'recvonly' && media.direction !== 'sendrecv') return false;
return media.rtp.some((rtp) => rtp.codec.toLowerCase() === 'av1');
}
function ddExtensionIDFor(sdp: SessionDescription, cachedID: number): number | undefined {
const mapped = mappedExtensionID(sdp, ddExtensionURI);
if (mapped !== undefined) {
@@ -841,6 +849,9 @@ export function collectStereoMids(
export function placeholderMidsFromTransceivers(transceivers: ReadonlyArray<RTCRtpTransceiver>): Set<string> {
const mids = new Set<string>();
for (const transceiver of transceivers) {
if (transceiver.currentDirection === 'stopped') {
continue;
}
if (transceiver.mid && !transceiver.sender.track) {
mids.add(transceiver.mid);
}
@@ -106,11 +106,6 @@ export class PCTransportManager {
this.isPublisherConnectionRequired = mode !== 'subscriber-primary';
this.isSubscriberConnectionRequired = mode === 'subscriber-primary';
this.publisher = new PCTransport(rtcConfig, loggerOptions);
if (subscriberVideoCodecExclusions?.length) {
for (const codec of subscriberVideoCodecExclusions) {
this.publisher.excludedVideoDecoderMimeTypes.add(`video/${codec}`);
}
}
this._mode = mode;
if (mode !== 'publisher-only') {
this.subscriber = new PCTransport(rtcConfig, loggerOptions);
@@ -2,7 +2,8 @@
//
// SPDX-License-Identifier: Apache-2.0
import {describe, expect, it} from 'vitest';
import {selectPublisherCodecPreferences} from './RTCEngine.ts';
import type {InternalRoomOptions} from '../options.ts';
import RTCEngine, {selectPublisherCodecPreferences} from './RTCEngine.ts';
function codec(
mimeType: string,
@@ -18,19 +19,41 @@ function codec(
}
describe('selectPublisherCodecPreferences', () => {
it('prefers H.264 profiles that use Chromium external encoders before OpenH264', () => {
const openH264 = codec('video/H264', 'level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=42e01f');
const externalBaseline = codec(
it('offers the profiles a hardware encoder accepts before Constrained Baseline, which is always software on Windows', () => {
const constrainedBaseline = codec(
'video/H264',
'level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=42001f',
'level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=42e01f',
);
const baseline = codec('video/H264', 'level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=42001f');
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, openH264, highProfile, rtx]);
const preferences = selectPublisherCodecPreferences('h264', [constrainedBaseline, rtx, baseline, highProfile]);
expect(preferences).toEqual([highProfile, baseline, constrainedBaseline, rtx]);
});
it('ranks Constrained Baseline above Main, High and Constrained High', () => {
it('offers High first out of the capabilities Chromium reports, so the only hardware profile this server registers wins', () => {
const capabilities = [
codec('video/H264', 'level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=42001f'),
codec('video/H264', 'level-asymmetry-allowed=1;packetization-mode=0;profile-level-id=42001f'),
codec('video/H264', 'level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=42e01f'),
codec('video/H264', 'level-asymmetry-allowed=1;packetization-mode=0;profile-level-id=42e01f'),
codec('video/H264', 'level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=4d001f'),
codec('video/H264', 'level-asymmetry-allowed=1;packetization-mode=0;profile-level-id=4d001f'),
codec('video/H264', 'level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=640034'),
];
const preferences = selectPublisherCodecPreferences('h264', capabilities);
expect(preferences.map((entry) => entry.sdpFmtpLine)).toEqual([
'level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=640034',
'level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=4d001f',
'level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=42001f',
'level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=42e01f',
'level-asymmetry-allowed=1;packetization-mode=0;profile-level-id=4d001f',
'level-asymmetry-allowed=1;packetization-mode=0;profile-level-id=42001f',
'level-asymmetry-allowed=1;packetization-mode=0;profile-level-id=42e01f',
]);
});
it('ranks High, Constrained High, Main and Baseline above Constrained Baseline, whatever level each one carries', () => {
const constrainedBaseline = codec(
'video/H264',
'level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=42e01f',
@@ -56,15 +79,15 @@ describe('selectPublisherCodecPreferences', () => {
constrainedBaseline,
]);
expect(preferences).toEqual([
constrainedBaseline,
mainProfile,
highProfileLevel31,
highProfileLevel51,
constrainedHigh,
mainProfile,
constrainedBaseline,
]);
});
it('keeps Constrained Baseline packetization-mode=1 ahead of Constrained Baseline packetization-mode=0 and High', () => {
it('ranks packetization-mode=1 above packetization-mode=0, which no hardware encoder takes', () => {
const constrainedBaselineMode0 = codec(
'video/H264',
'level-asymmetry-allowed=1;packetization-mode=0;profile-level-id=42e01f',
@@ -73,19 +96,94 @@ describe('selectPublisherCodecPreferences', () => {
'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 highProfileMode0 = codec(
'video/H264',
'level-asymmetry-allowed=1;packetization-mode=0;profile-level-id=640033',
);
const highProfileMode1 = codec(
'video/H264',
'level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=640033',
);
const preferences = selectPublisherCodecPreferences('h264', [
highProfile,
highProfileMode0,
constrainedBaselineMode0,
constrainedBaselineMode1,
highProfileMode1,
]);
expect(preferences).toEqual([
highProfileMode1,
constrainedBaselineMode1,
highProfileMode0,
constrainedBaselineMode0,
]);
expect(preferences).toEqual([constrainedBaselineMode1, constrainedBaselineMode0, highProfile]);
});
it('keeps non-H.264 codecs in browser capability order and appends RTX', () => {
it('puts the chosen codec first and keeps every other codec in browser capability order', () => {
const vp9 = codec('video/VP9');
const vp8 = codec('video/VP8');
const rtx = codec('video/rtx');
expect(selectPublisherCodecPreferences('vp9', [vp8, rtx, vp9])).toEqual([vp9, rtx]);
expect(selectPublisherCodecPreferences('vp9', [vp8, rtx, vp9])).toEqual([vp9, vp8, rtx]);
});
it('keeps the other codecs so a later publication on the same connection can negotiate them', () => {
const vp9 = codec('video/VP9');
const av1 = codec('video/AV1');
const h264 = codec('video/H264', 'level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=42e01f');
const preferences = selectPublisherCodecPreferences('vp9', [av1, h264, vp9]);
expect(preferences.map((entry) => entry.mimeType)).toEqual(['video/VP9', 'video/AV1', 'video/H264']);
});
it('ranks the H.264 profiles it leaves behind the chosen codec', () => {
const vp8 = codec('video/VP8');
const highProfile = codec('video/H264', 'level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=640033');
const constrainedBaseline = codec(
'video/H264',
'level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=42e01f',
);
const preferences = selectPublisherCodecPreferences('vp8', [vp8, highProfile, constrainedBaseline]);
expect(preferences).toEqual([vp8, highProfile, constrainedBaseline]);
});
it('returns nothing when the sender cannot encode the chosen codec', () => {
expect(selectPublisherCodecPreferences('av1', [codec('video/VP8'), codec('video/rtx')])).toEqual([]);
});
});
describe('publisher data channels before negotiation', () => {
function engineWithPublisherChannels(hasPublisherChannels: boolean) {
const engine = new RTCEngine({} as InternalRoomOptions);
const created: Array<string> = [];
const internals = engine as unknown as {
_isClosed: boolean;
pcManager: unknown;
dataChannels: {hasPublisherChannels: boolean; createPublisherChannels: () => void};
};
internals._isClosed = false;
internals.dataChannels = {
hasPublisherChannels,
createPublisherChannels: () => created.push('publisher'),
};
internals.pcManager = {
requirePublisher: () => {},
negotiate: async () => {},
publisher: {
off: () => {},
once: () => {},
getTransceivers: () => [{} as RTCRtpTransceiver],
},
};
return {engine, created};
}
it('creates them on a renegotiation that already carries transceivers', async () => {
const {engine, created} = engineWithPublisherChannels(false);
await engine.negotiate();
expect(created).toEqual(['publisher']);
});
it('leaves the existing channels alone', async () => {
const {engine, created} = engineWithPublisherChannels(true);
await engine.negotiate();
expect(created).toEqual([]);
});
});
@@ -122,8 +122,16 @@ const videoCodecMimeTypes: Record<VideoCodec, Array<string>> = {
vp9: ['video/vp9'],
vp8: ['video/vp8'],
};
const h264OpenH264ProfileLevelId = '42e01f';
const h264PreferredHardwareProfileLevelIds = new Set(['42001f']);
const h264ProfileRanks = new Map([
['6400', 0],
['640c', 1],
['4d00', 2],
['4200', 3],
['42e0', 4],
]);
const h264UnrankedProfileScore = 5;
const h264MissingProfileScore = 6;
const h264PacketizationMode0Score = 10;
type RtpCodecCapability = RTCRtpCapabilities['codecs'][number] & {sdpFmtpLine?: string};
enum PCState {
@@ -1034,7 +1042,13 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit
const capabilities = RTCRtpSender.getCapabilities('video');
if (!capabilities) return;
const preferences = selectPublisherCodecPreferences(codec, capabilities.codecs);
if (preferences.length === 0) return;
if (preferences.length === 0) {
this.log.warn('sender cannot encode the requested codec, leaving the browser order in place', {
...this.logContext,
codec,
});
return;
}
try {
transceiver.setCodecPreferences(preferences);
} catch (error) {
@@ -1553,7 +1567,7 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit
}
this.pcManager.requirePublisher();
if (this.pcManager.publisher.getTransceivers().length === 0 && !this.dataChannels.hasPublisherChannels) {
if (!this.dataChannels.hasPublisherChannels) {
this.createDataChannels();
}
@@ -1806,11 +1820,9 @@ function getFmtpParameter(sdpFmtpLine: string | undefined, key: string): string
function getH264PublisherCodecScore(codec: RtpCodecCapability): number {
const profileLevelId = getFmtpParameter(codec.sdpFmtpLine, 'profile-level-id');
const packetizationMode = getFmtpParameter(codec.sdpFmtpLine, 'packetization-mode');
const packetizationScore = packetizationMode === '1' ? 0 : 1;
if (profileLevelId && h264PreferredHardwareProfileLevelIds.has(profileLevelId)) return packetizationScore;
if (profileLevelId === h264OpenH264ProfileLevelId) return 10 + packetizationScore;
if (profileLevelId) return 20 + packetizationScore;
return 30 + packetizationScore;
const packetizationScore = packetizationMode === '1' ? 0 : h264PacketizationMode0Score;
if (!profileLevelId) return packetizationScore + h264MissingProfileScore;
return packetizationScore + (h264ProfileRanks.get(profileLevelId.slice(0, 4)) ?? h264UnrankedProfileScore);
}
function preferHardwareH264Codecs(codecs: ReadonlyArray<RtpCodecCapability>): Array<RtpCodecCapability> {
@@ -1828,8 +1840,12 @@ export function selectPublisherCodecPreferences(
const selected = codecs.filter((entry) => mimeTypes.has(entry.mimeType.toLowerCase()));
if (selected.length === 0) return [];
const preferred = codec === 'h264' ? preferHardwareH264Codecs(selected) : selected;
const rtx = codecs.filter((entry) => entry.mimeType.toLowerCase() === 'video/rtx');
return [...preferred, ...rtx];
const isH264 = (entry: RtpCodecCapability): boolean => entry.mimeType.toLowerCase() === 'video/h264';
const remaining = codecs.filter((entry) => !mimeTypes.has(entry.mimeType.toLowerCase()));
const rankedH264 = preferHardwareH264Codecs(remaining.filter(isH264));
let nextH264 = 0;
const rest = remaining.map((entry) => (isH264(entry) ? rankedH264[nextH264++] : entry));
return [...preferred, ...rest];
}
export type EngineEventCallbacks = {
@@ -12,7 +12,6 @@ export const publishDefaults: TrackPublishDefaults = {
audioPreset: AudioPresets.music,
dtx: false,
red: true,
forceStereo: false,
simulcast: true,
screenShareEncoding: ScreenSharePresets.original.encoding,
stopMicTrackOnMute: false,
@@ -108,6 +108,7 @@ import {
isWeb,
selectPreferredVideoCodec,
sleep,
stopTransceiversForSender,
supportsVideoCodec,
usesLegacySVCEncodings,
} from '../utils.ts';
@@ -436,8 +437,9 @@ export default class LocalParticipant extends Participant {
enabled: boolean,
options?: ScreenShareCaptureOptions,
publishOptions?: TrackPublishOptions,
audioPublishOptions?: TrackPublishOptions,
): Promise<LocalTrackPublication | undefined> {
return this.setTrackEnabled(Track.Source.ScreenShare, enabled, options, publishOptions);
return this.setTrackEnabled(Track.Source.ScreenShare, enabled, options, publishOptions, audioPublishOptions);
}
async setE2EEEnabled(enabled: boolean) {
@@ -474,12 +476,14 @@ export default class LocalParticipant extends Participant {
enabled: boolean,
options?: ScreenShareCaptureOptions,
publishOptions?: TrackPublishOptions,
audioPublishOptions?: TrackPublishOptions,
): Promise<LocalTrackPublication | undefined>;
private async setTrackEnabled(
source: Track.Source,
enabled: true,
options?: VideoCaptureOptions | AudioCaptureOptions | ScreenShareCaptureOptions,
publishOptions?: TrackPublishOptions,
audioPublishOptions?: TrackPublishOptions,
) {
this.log.debug('setTrackEnabled', {source, enabled});
if (this.republishPromise) {
@@ -548,7 +552,12 @@ export default class LocalParticipant extends Participant {
for (const localTrack of localTracks) {
this.log.info('publishing track', getLogContextFromTrack(localTrack));
publishPromises.push(this.publishTrack(localTrack, publishOptions));
publishPromises.push(
this.publishTrack(
localTrack,
audioPublishOptions && isAudioTrack(localTrack) ? audioPublishOptions : publishOptions,
),
);
}
const publishedTracks = await Promise.all(publishPromises);
@@ -1421,29 +1430,30 @@ export default class LocalParticipant extends Participant {
const trackSender = track.sender;
track.sender = undefined;
if (this.engine.pcManager && this.engine.pcManager.currentState < PCTransportState.FAILED && trackSender) {
const publisher = this.engine.pcManager.publisher;
try {
for (const transceiver of this.engine.pcManager.publisher.getTransceivers()) {
if (transceiver.sender === trackSender) {
transceiver.direction = 'inactive';
negotiationNeeded = true;
}
}
try {
negotiationNeeded = this.engine.removeTrack(trackSender);
} catch (e) {
this.log.warn(e);
negotiationNeeded = true;
}
if (stopTransceiversForSender(publisher.getTransceivers(), trackSender)) {
negotiationNeeded = true;
}
if (isLocalVideoTrack(track)) {
for (const [, trackInfo] of track.simulcastCodecs) {
if (trackInfo.sender) {
try {
negotiationNeeded = this.engine.removeTrack(trackInfo.sender);
negotiationNeeded = this.engine.removeTrack(trackInfo.sender) || negotiationNeeded;
} catch (e) {
this.log.warn(e);
negotiationNeeded = true;
}
if (stopTransceiversForSender(publisher.getTransceivers(), trackInfo.sender)) {
negotiationNeeded = true;
}
trackInfo.sender = undefined;
}
}
@@ -3,7 +3,14 @@
// SPDX-License-Identifier: Apache-2.0
import {afterEach, beforeEach, describe, expect, it} from 'vitest';
import {screenCaptureToDisplayMediaStreamOptions} from './track/utils.ts';
import {selectPreferredVideoCodec, supportsAV1, supportsH265, supportsVideoCodec, supportsVP9} from './utils.ts';
import {
selectPreferredVideoCodec,
stopTransceiversForSender,
supportsAV1,
supportsH265,
supportsVideoCodec,
supportsVP9,
} from './utils.ts';
const originalNavigator = globalThis.navigator;
const originalSender = globalThis.RTCRtpSender;
@@ -91,3 +98,46 @@ describe('screenCaptureToDisplayMediaStreamOptions', () => {
expect(options.systemAudio).toBe('exclude');
});
});
describe('stopTransceiversForSender', () => {
type FakeTransceiver = {sender: RTCRtpSender; direction: string; stopCalls: number; stop?: () => void};
function fakeTransceiver(sender: RTCRtpSender, canStop = true): FakeTransceiver {
const transceiver: FakeTransceiver = {sender, direction: 'sendonly', stopCalls: 0};
if (canStop) {
transceiver.stop = () => {
transceiver.stopCalls += 1;
transceiver.direction = 'stopped';
};
}
return transceiver;
}
function asTransceivers(list: Array<FakeTransceiver>): ReadonlyArray<RTCRtpTransceiver> {
return list as unknown as ReadonlyArray<RTCRtpTransceiver>;
}
it('stops the transceiver holding the sender so the m-section can be recycled', () => {
const sender = {} as RTCRtpSender;
const mine = fakeTransceiver(sender);
const theirs = fakeTransceiver({} as RTCRtpSender);
expect(stopTransceiversForSender(asTransceivers([theirs, mine]), sender)).toBe(true);
expect(mine.stopCalls).toBe(1);
expect(mine.direction).toBe('stopped');
expect(theirs.stopCalls).toBe(0);
expect(theirs.direction).toBe('sendonly');
});
it('reports no match when the sender is not on the connection', () => {
const mine = fakeTransceiver({} as RTCRtpSender);
expect(stopTransceiversForSender(asTransceivers([mine]), {} as RTCRtpSender)).toBe(false);
expect(mine.stopCalls).toBe(0);
});
it('falls back to inactive where stop is unavailable', () => {
const sender = {} as RTCRtpSender;
const mine = fakeTransceiver(sender, false);
expect(stopTransceiversForSender(asTransceivers([mine]), sender)).toBe(true);
expect(mine.direction).toBe('inactive');
});
});
@@ -158,6 +158,25 @@ export function negotiateDependencyDescriptor(transceiver: RTCRtpTransceiver): b
}
}
export function stopTransceiversForSender(
transceivers: ReadonlyArray<RTCRtpTransceiver>,
sender: RTCRtpSender,
): boolean {
let matched = false;
for (const transceiver of transceivers) {
if (transceiver.sender !== sender) {
continue;
}
matched = true;
if (typeof transceiver.stop === 'function') {
transceiver.stop();
} else {
transceiver.direction = 'inactive';
}
}
return matched;
}
export function isSVCSimulcast(codec?: string, options?: {simulcast?: boolean; scalabilityMode?: string}): boolean {
return isSVCCodec(codec) && !!options?.simulcast && !!options.scalabilityMode?.startsWith('L1T');
}
+12 -3
View File
@@ -414,7 +414,6 @@ class Updater {
channel: this.channel ?? Config.PUBLIC_RELEASE_CHANNEL,
arch: this.desktopArch,
version: event.version ?? null,
apiEndpoint: Config.PUBLIC_BOOTSTRAP_API_PUBLIC_ENDPOINT,
knownOptions: options,
});
}
@@ -696,10 +695,14 @@ class Updater {
} finally {
this.transition({type: 'manualDownload.finished'});
}
await this.downloadManualNativeUpdateOrOpen(currentOption.url, currentOption.suggestedName);
await this.downloadManualNativeUpdateOrOpen(currentOption.url, currentOption.suggestedName, currentOption.sha256);
}
private async downloadManualNativeUpdateOrOpen(url: string, suggestedName?: string): Promise<void> {
private async downloadManualNativeUpdateOrOpen(
url: string,
suggestedName?: string,
sha256?: string | null,
): Promise<void> {
if (this.manualNativeDownloadInFlight) {
return;
}
@@ -708,10 +711,16 @@ class Updater {
const outcome = await downloadWithNative({
url,
suggestedName: suggestedName ?? this.getManualUpdateSuggestedName(url),
sha256,
});
if (outcome === 'success' || outcome === 'canceled') {
return;
}
if (outcome === 'checksum-mismatch') {
logger.error('Native manual update download did not match its published checksum', {url});
pushDesktopUpdateDownloadFailedModal();
return;
}
logger.warn('Native manual update download unavailable; opening update URL externally', {outcome});
await openExternalUrl(url);
} finally {
@@ -5,5 +5,5 @@ export function shouldShowNativeDesktopUpdateInApp(platform: string | null | und
}
export function shouldShowNativeDesktopUpdateDownloadProgress(platform: string | null | undefined): boolean {
return platform === 'win32';
return platform === 'win32' || platform === 'linux';
}
@@ -37,7 +37,6 @@ const CANARY_ASSIGNMENT: VoiceNoiseSuppressionAssignmentResponse = {
guild_overrides: [{guild_id: GUILD_ID, backend: 'speex'}],
enabled_backends: ['none', 'speex', 'rnnoise', 'gtcrn'],
allow_user_override: true,
stereo_enabled: false,
suppression_strength: 80,
};
File diff suppressed because it is too large Load Diff
@@ -139,6 +139,9 @@
{
"msgid": "[email protected]"
},
{
"msgid": "{frameRate} FPS"
},
{
"msgid": "{kilobits} kbps"
},
@@ -1164,6 +1167,9 @@
{
"msgid": "{fps} FPS"
},
{
"msgid": "{frameRate} FPS"
},
{
"msgid": "{kilobits} kbps"
},
@@ -1401,6 +1407,9 @@
{
"msgid": "A lower price is available. Switching takes effect on <0>{listPriceEffectiveDate}</0> and changes nothing else."
},
{
"msgid": "A viewer cannot play {pinnedCodec}, so your stream uses {codec}."
},
{
"msgid": "AV1"
},
@@ -1569,6 +1578,9 @@
{
"msgid": "Capture card"
},
{
"msgid": "Capture devices use the Gaming preset."
},
{
"msgid": "Capture entire system audio"
},
@@ -1821,6 +1833,12 @@
{
"msgid": "Desktop audio isn't available right now on macOS."
},
{
"msgid": "Device audio ({deviceLabel})"
},
{
"msgid": "Device audio only"
},
{
"msgid": "Device volume"
},
@@ -2136,9 +2154,15 @@
{
"msgid": "No application windows found"
},
{
"msgid": "No audio from this device"
},
{
"msgid": "No displays found"
},
{
"msgid": "No hardware encoder was found on this device, so Prefer hardware works like Automatic."
},
{
"msgid": "No optional services are available on this instance. You can skip this step."
},
@@ -2526,6 +2550,12 @@
{
"msgid": "Send messages in this channel."
},
{
"msgid": "Sending {resolution} at {frameRate} FPS with {codec}"
},
{
"msgid": "Sends both channels of a stereo microphone. Needs all processing off and a channel at 128 kbps or higher."
},
{
"msgid": "Sends both channels of a stereo microphone. Works only with no suppression or standard suppression."
},
@@ -2559,6 +2589,9 @@
{
"msgid": "Set a member list position for the role."
},
{
"msgid": "Set by the {preset} preset. Picking a value here switches to Custom."
},
{
"msgid": "Set mature content to follow the community setting."
},
@@ -2631,6 +2664,9 @@
{
"msgid": "Set this check aside?"
},
{
"msgid": "Set to send {resolution} at {frameRate} FPS with {codec}"
},
{
"msgid": "Set up {productName}"
},
@@ -2652,6 +2688,12 @@
{
"msgid": "Shared window audio"
},
{
"msgid": "Sharp text at 720p, up to 30 FPS"
},
{
"msgid": "Sharp text at up to 4K, up to 15 FPS"
},
{
"msgid": "Sharper text at 720p, 30 FPS"
},
@@ -2688,6 +2730,9 @@
{
"msgid": "Sign-in details"
},
{
"msgid": "Silent when the capture device has no audio input of its own"
},
{
"msgid": "Single community"
},
@@ -2709,9 +2754,18 @@
{
"msgid": "Slowmode is set to {durationLabel}. Wait before sending another message."
},
{
"msgid": "Smooth 30 FPS at up to 720p"
},
{
"msgid": "Smooth 60 FPS at up to 1440p"
},
{
"msgid": "Smoother video"
},
{
"msgid": "Some stream settings could not be applied to your live stream."
},
{
"msgid": "Something is limiting this stream, so viewers are getting fewer frames than you asked for."
},
@@ -2775,6 +2829,9 @@
{
"msgid": "Stream quality"
},
{
"msgid": "Stream settings were adjusted"
},
{
"msgid": "Strongest neural filter for speech, limits audio to the speech range."
},
@@ -2814,6 +2871,9 @@
{
"msgid": "The ban was set to expire on {date}."
},
{
"msgid": "The browser is sending {deliveredResolution} instead of {resolution}."
},
{
"msgid": "The camera preview is still stopping. Try streaming again in a moment."
},
@@ -2886,6 +2946,9 @@
{
"msgid": "These options change how people use the instance. Some can only be chosen during setup."
},
{
"msgid": "This device could not keep up with {targetResolution} at {targetFrameRate} FPS last time, so your stream started at {resolution} at {frameRate} FPS."
},
{
"msgid": "This emoji is from"
},
@@ -2940,6 +3003,9 @@
{
"msgid": "Transferred community ownership to {user}."
},
{
"msgid": "Try full quality again"
},
{
"msgid": "Turn off audio sharing for this source or try again in a moment."
},
@@ -3003,6 +3069,9 @@
{
"msgid": "Use your operating system's spellchecker when available. Otherwise, use {productName}'s in-app dictionaries."
},
{
"msgid": "Used in Custom mode. The Gaming and Screen share presets set their own hint."
},
{
"msgid": "Virtual microphone"
},
@@ -3039,6 +3108,9 @@
{
"msgid": "Wordmark"
},
{
"msgid": "You asked for {resolution} at {frameRate} FPS"
},
{
"msgid": "You can still email {supportEmail} for a human review at any time."
},
@@ -3054,12 +3126,39 @@
{
"msgid": "Your account works normally again straight away and nothing is removed. We can ask for this check again later."
},
{
"msgid": "Your capture device is sending {deliveredResolution} instead of {resolution}."
},
{
"msgid": "Your carrier says this number isn't in service. Check it and try again, or contact support if it's correct."
},
{
"msgid": "Your connection cannot keep up. Viewers get about {deliveredFrameRate} of {frameRate} FPS at {resolution}."
},
{
"msgid": "Your connection cannot keep up. Your stream is set to {frameRate} FPS at {resolution} and viewers are getting about {deliveredFrameRate} FPS."
},
{
"msgid": "Your device cannot keep up. Viewers get about {deliveredFrameRate} of {frameRate} FPS at {resolution}."
},
{
"msgid": "Your device cannot keep up. Your stream is set to {frameRate} FPS at {resolution} and viewers are getting about {deliveredFrameRate} FPS."
},
{
"msgid": "Your device could not keep up, so your stream now sends {frameRate} FPS to keep {resolution} sharp."
},
{
"msgid": "Your device could not keep up, so your stream now sends {resolution} to keep {frameRate} FPS smooth."
},
{
"msgid": "Your new nickname, or leave blank to reset it."
},
{
"msgid": "Your saved {savedResolution} at {savedFrameRate} FPS needs {premiumProductName}, so shares use {resolution} at {frameRate} FPS."
},
{
"msgid": "Your source is producing about {sourceFrameRate} FPS"
},
{
"msgid": "Your subscription can't move to the new price right now."
},
@@ -3069,6 +3168,9 @@
{
"msgid": "Your subscription still ends on <0>{cancelDate}</0>. The current price is now {listPriceNewLabel}. If you reactivate, you can switch to it from here."
},
{
"msgid": "Your video encoder stopped, so your stream switched to {codec}."
},
{
"msgid": "deleted-category"
},
@@ -3564,6 +3666,9 @@
{
"msgid": "{channelHeading}, {mentionCount, plural, one {# mention} other {# mentions}}"
},
{
"msgid": "{codec} cannot keep up on this device. Set the codec to Automatic in Advanced settings to allow a faster one."
},
{
"msgid": "{count, plural, =0 {∞ No limit} one {# participant} other {# participants}}"
},
@@ -3594,12 +3699,18 @@
{
"msgid": "{expressionName} is a custom sticker from this community. Members can use it here."
},
{
"msgid": "{frameRate} FPS"
},
{
"msgid": "{imageCount, plural, one {# image} other {# images}}, {videoCount, plural, one {# video} other {# videos}}"
},
{
"msgid": "{name} copy"
},
{
"msgid": "{pinnedCodec} is not available on this device, so your stream uses {codec}."
},
{
"msgid": "{premiumProductName} costs less now. Switch from {currentPrice} to {newPrice} a month starting {effectiveDate}, and nothing is charged today."
},
@@ -3936,6 +4047,9 @@
{
"msgid": "{fps} FPS"
},
{
"msgid": "{frameRate} FPS"
},
{
"msgid": "{kilobits} kbps"
},
@@ -4144,6 +4258,9 @@
{
"msgid": "{fps} FPS"
},
{
"msgid": "{frameRate} FPS"
},
{
"msgid": "{kilobits} kbps"
},
@@ -4314,6 +4431,9 @@
{
"msgid": "{fps} FPS"
},
{
"msgid": "{frameRate} FPS"
},
{
"msgid": "{kilobits} kbps"
},
@@ -4915,6 +5035,9 @@
{
"msgid": "[email protected]"
},
{
"msgid": "{frameRate} FPS"
},
{
"msgid": "{messageGroupSpacing}px"
},
@@ -5165,6 +5288,9 @@
{
"msgid": "{fps} FPS"
},
{
"msgid": "{frameRate} FPS"
},
{
"msgid": "{kilobits} kbps"
},
@@ -5652,6 +5778,9 @@
{
"msgid": "{fps} FPS"
},
{
"msgid": "{frameRate} FPS"
},
{
"msgid": "{kilobits} kbps"
},
@@ -6000,6 +6129,9 @@
{
"msgid": "{fps} FPS"
},
{
"msgid": "{frameRate} FPS"
},
{
"msgid": "{kilobits} kbps"
},
@@ -6158,6 +6290,9 @@
{
"msgid": "{fps} FPS"
},
{
"msgid": "{frameRate} FPS"
},
{
"msgid": "{kilobits} kbps"
},
@@ -6250,6 +6385,9 @@
{
"msgid": "{fps} FPS"
},
{
"msgid": "{frameRate} FPS"
},
{
"msgid": "{messageGroupSpacing}px"
},
@@ -6324,6 +6462,9 @@
{
"msgid": "{fps} FPS"
},
{
"msgid": "{frameRate} FPS"
},
{
"msgid": "{kilobits} kbps"
},
@@ -7604,6 +7745,9 @@
{
"msgid": "{fps} FPS"
},
{
"msgid": "{frameRate} FPS"
},
{
"msgid": "{kilobits} kbps"
},
@@ -7967,6 +8111,9 @@
{
"msgid": "{fps} FPS"
},
{
"msgid": "{frameRate} FPS"
},
{
"msgid": "{kilobits} kbps"
},
@@ -8659,6 +8806,9 @@
{
"msgid": "{fps} FPS"
},
{
"msgid": "{frameRate} FPS"
},
{
"msgid": "{kilobits} kbps"
},
@@ -8953,6 +9103,9 @@
{
"msgid": "{fps} FPS"
},
{
"msgid": "{frameRate} FPS"
},
{
"msgid": "{kilobits} kbps"
},
@@ -9162,6 +9315,9 @@
{
"msgid": "{fps} FPS"
},
{
"msgid": "{frameRate} FPS"
},
{
"msgid": "{kilobits} kbps"
},
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

Some files were not shown because too many files have changed in this diff Show More