mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-17 04:55:42 +09:00
Compare commits
30
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
70fd9e6331 | ||
|
|
c14b7d4cb3 | ||
|
|
0f0ff5f3df | ||
|
|
b4c614d21a | ||
|
|
bef5e33eec | ||
|
|
e5a568acca | ||
|
|
a208b56354 | ||
|
|
71be6c1de4 | ||
|
|
eae905b1da | ||
|
|
10cd4f96a4 | ||
|
|
9d74daf8ad | ||
|
|
6f344def93 | ||
|
|
72f9f9c396 | ||
|
|
c8755885d4 | ||
|
|
12d503d4c5 | ||
|
|
2f6201bcbd | ||
|
|
ec681e6061 | ||
|
|
0fc6fad11d | ||
|
|
eb329f2cd0 | ||
|
|
9a20821332 | ||
|
|
4749eb7f86 | ||
|
|
2d83fd2a93 | ||
|
|
d180af6ba2 | ||
|
|
4810eb3e24 | ||
|
|
48a19dedd8 | ||
|
|
cadf7ea532 | ||
|
|
fc60d05f17 | ||
|
|
5072488f1e | ||
|
|
7a5b9a0ded | ||
|
|
4ce4d4e09a |
No files matched your search
@@ -551,6 +551,16 @@ jobs:
|
||||
cargo run --locked --quiet --manifest-path ${{ github.workspace }}/_ci/tools/ci/Cargo.toml -- build-desktop
|
||||
--step verify_windows_unpacked_signatures
|
||||
|
||||
- name: Verify the packaged shell starts and boots its bundled renderer (Windows)
|
||||
if: matrix.platform == 'windows' && matrix.arch == 'x64'
|
||||
working-directory: ${{ env.WORKDIR }}/fluxer_desktop
|
||||
env:
|
||||
BUILD_CHANNEL: ${{ env.BUILD_CHANNEL }}
|
||||
BUILD_VERSION: ${{ env.VERSION }}
|
||||
run: >-
|
||||
cargo run --locked --quiet --manifest-path ${{ github.workspace }}/_ci/tools/ci/Cargo.toml -- build-desktop
|
||||
--step verify_bundled_renderer_windows
|
||||
|
||||
- name: Create portable ZIP (Windows)
|
||||
if: matrix.platform == 'windows'
|
||||
working-directory: ${{ env.WORKDIR }}/fluxer_desktop
|
||||
@@ -591,6 +601,16 @@ jobs:
|
||||
cargo run --locked --quiet --manifest-path ${{ github.workspace }}/_ci/tools/ci/Cargo.toml -- build-desktop
|
||||
--step build_app_linux
|
||||
|
||||
- name: Verify the packaged shell boots its bundled renderer (Linux)
|
||||
if: matrix.platform == 'linux' && matrix.arch == 'x64'
|
||||
working-directory: ${{ env.WORKDIR }}/fluxer_desktop
|
||||
env:
|
||||
BUILD_CHANNEL: ${{ env.BUILD_CHANNEL }}
|
||||
BUILD_VERSION: ${{ env.VERSION }}
|
||||
run: >-
|
||||
cargo run --locked --quiet --manifest-path ${{ github.workspace }}/_ci/tools/ci/Cargo.toml -- build-desktop
|
||||
--step verify_bundled_renderer_linux
|
||||
|
||||
- name: Verify signed Windows artifacts
|
||||
if: matrix.platform == 'windows'
|
||||
working-directory: ${{ env.WORKDIR }}/fluxer_desktop
|
||||
@@ -651,13 +671,59 @@ jobs:
|
||||
retention-days: 1
|
||||
compression-level: 0
|
||||
|
||||
verify_windows_arm64:
|
||||
name: Verify windows (arm64)
|
||||
if: ${{ !cancelled() && needs.build.result == 'success' }}
|
||||
needs:
|
||||
- meta
|
||||
- build
|
||||
runs-on: windows-11-arm
|
||||
timeout-minutes: 30
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
env:
|
||||
CHANNEL: ${{ needs.meta.outputs.build_channel }}
|
||||
VERSION: ${{ needs.meta.outputs.version }}
|
||||
steps:
|
||||
- name: Checkout source
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
|
||||
with:
|
||||
ref: ${{ needs.meta.outputs.source_sha }}
|
||||
sparse-checkout: fluxer_desktop/scripts
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020
|
||||
with:
|
||||
node-version: 24
|
||||
|
||||
- name: Download windows arm64 build artifacts
|
||||
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093
|
||||
with:
|
||||
path: artifacts
|
||||
pattern: fluxer-desktop-${{ needs.meta.outputs.build_channel }}-windows-arm64*
|
||||
|
||||
- name: Verify the packaged shell starts and boots its bundled renderer
|
||||
shell: pwsh
|
||||
run: |
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$zip = Get-ChildItem -Path artifacts -Recurse -Filter "*-$env:VERSION-portable-win-arm64.zip" | Select-Object -First 1
|
||||
if ($null -eq $zip) { Write-Host 'This build has no windows arm64 portable zip'; exit 0 }
|
||||
$app = Join-Path $env:RUNNER_TEMP 'app'
|
||||
Expand-Archive -Path $zip.FullName -DestinationPath $app
|
||||
$exe = Get-ChildItem -Path $app -Recurse -Filter 'Fluxer*.exe' | Where-Object { $_.Name -notlike '*_ExecutionStub.exe' } | Select-Object -First 1
|
||||
if ($null -eq $exe) { throw "$($zip.Name) holds no Fluxer executable" }
|
||||
node fluxer_desktop/scripts/release-check.mjs --app $exe.DirectoryName --channel $env:CHANNEL --expect-renderer $env:VERSION --expect-source bundled --package-origin http://127.0.0.1:9 --app-arg=--disable-gpu --workdir (Join-Path $env:RUNNER_TEMP 'runs') --timeout-seconds 240
|
||||
if ($LASTEXITCODE -ne 0) { throw "The windows arm64 build fails its release check" }
|
||||
|
||||
upload:
|
||||
name: Assemble desktop release assets
|
||||
if: ${{ !cancelled() && needs.build.result == 'success' }}
|
||||
if: ${{ !cancelled() && needs.build.result == 'success' && needs.verify_windows_arm64.result == 'success' }}
|
||||
needs:
|
||||
- meta
|
||||
- shared_assets
|
||||
- build
|
||||
- verify_windows_arm64
|
||||
runs-on: ubuntu-24.04-arm
|
||||
environment: desktop-releases
|
||||
timeout-minutes: 180
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
name: desktop windows release check
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
channel:
|
||||
description: Release channel
|
||||
type: choice
|
||||
options:
|
||||
- canary
|
||||
- stable
|
||||
default: canary
|
||||
version:
|
||||
description: Published version to check
|
||||
required: true
|
||||
type: string
|
||||
expect_failure:
|
||||
description: Pass only when the published build fails to start
|
||||
type: boolean
|
||||
default: false
|
||||
update_from:
|
||||
description: Comma-separated published versions to install with Setup and update to the version above from the live feed
|
||||
required: false
|
||||
type: string
|
||||
default: ""
|
||||
stale_apply_record:
|
||||
description: Before each update, leave behind an hour-old record of an update that never installed (passes only when the installed versions retry it)
|
||||
type: boolean
|
||||
default: false
|
||||
pull_request:
|
||||
paths:
|
||||
- .github/workflows/desktop-windows-release-check.yaml
|
||||
- fluxer_desktop/scripts/release-check.mjs
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: desktop-windows-release-check-${{ github.ref }}-${{ inputs.version || 'pr' }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
check:
|
||||
name: Check windows (${{ matrix.arch }})
|
||||
runs-on: ${{ matrix.os }}
|
||||
timeout-minutes: 45
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- arch: x64
|
||||
os: windows-2025
|
||||
- arch: arm64
|
||||
os: windows-11-arm
|
||||
env:
|
||||
CHANNEL: ${{ inputs.channel || 'canary' }}
|
||||
VERSION: ${{ inputs.version || '2026.1009.20411' }}
|
||||
EXPECT_FAILURE: ${{ github.event_name == 'pull_request' && 'true' || inputs.expect_failure }}
|
||||
UPDATE_FROM: ${{ inputs.update_from || '' }}
|
||||
STALE_APPLY_RECORD: ${{ inputs.stale_apply_record && 'true' || 'false' }}
|
||||
ARCH: ${{ matrix.arch }}
|
||||
steps:
|
||||
- name: Checkout source
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
|
||||
with:
|
||||
sparse-checkout: fluxer_desktop/scripts
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020
|
||||
with:
|
||||
node-version: 24
|
||||
|
||||
- name: Check the portable build
|
||||
shell: pwsh
|
||||
run: |
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$work = Join-Path $env:RUNNER_TEMP 'portable'
|
||||
New-Item -ItemType Directory -Force -Path $work | Out-Null
|
||||
$zip = Join-Path $work 'portable.zip'
|
||||
Invoke-WebRequest -Uri "https://pkgs.fluxer.com/desktop/$env:CHANNEL/win32/$env:ARCH/$env:VERSION/portable" -OutFile $zip
|
||||
Expand-Archive -Path $zip -DestinationPath (Join-Path $work 'app')
|
||||
$exe = Get-ChildItem -Path (Join-Path $work 'app') -Recurse -Filter 'Fluxer*.exe' | Where-Object { $_.Name -notlike '*_ExecutionStub.exe' } | Select-Object -First 1
|
||||
if ($null -eq $exe) { throw "The portable build holds no Fluxer executable" }
|
||||
$output = & node fluxer_desktop/scripts/release-check.mjs --app $exe.DirectoryName --channel $env:CHANNEL --expect-renderer $env:VERSION --expect-source bundled --app-arg=--disable-gpu --workdir (Join-Path $work 'runs') --timeout-seconds 240 2>&1
|
||||
$code = $LASTEXITCODE
|
||||
$output | ForEach-Object { Write-Host $_ }
|
||||
if ($env:EXPECT_FAILURE -eq 'true') {
|
||||
if ($code -eq 0) { throw "Expected $env:VERSION to fail its release check, it passed" }
|
||||
Write-Host "$env:VERSION fails its release check on windows $env:ARCH as expected"
|
||||
exit 0
|
||||
}
|
||||
if ($code -ne 0) { throw "The release check failed for $env:VERSION on windows $env:ARCH" }
|
||||
|
||||
- name: Update installed builds from the live feed
|
||||
if: env.UPDATE_FROM != ''
|
||||
shell: pwsh
|
||||
run: |
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$channelDirs = @{ canary = 'fluxer_desktop_canary'; stable = 'fluxer_desktop' }
|
||||
$dataDirs = @{ canary = 'fluxercanary'; stable = 'fluxer' }
|
||||
$installRoot = Join-Path $env:LOCALAPPDATA $channelDirs[$env:CHANNEL]
|
||||
$logPath = Join-Path (Join-Path $env:APPDATA $dataDirs[$env:CHANNEL]) 'logs\main.log'
|
||||
$failures = @()
|
||||
foreach ($from in ($env:UPDATE_FROM -split ',' | ForEach-Object { $_.Trim() } | Where-Object { $_ })) {
|
||||
Write-Host "== install $from, then update to $env:VERSION"
|
||||
Get-Process | Where-Object { $_.Path -like "$installRoot*" } | Stop-Process -Force -ErrorAction SilentlyContinue
|
||||
if (Test-Path $installRoot) { Remove-Item -Recurse -Force $installRoot }
|
||||
Remove-Item -Recurse -Force (Split-Path (Split-Path $logPath -Parent) -Parent) -ErrorAction SilentlyContinue
|
||||
$setup = Join-Path $env:RUNNER_TEMP "setup-$from.exe"
|
||||
Invoke-WebRequest -Uri "https://pkgs.fluxer.com/desktop/$env:CHANNEL/win32/$env:ARCH/$from/setup" -OutFile $setup
|
||||
Start-Process -FilePath $setup -ArgumentList '--silent' -Wait
|
||||
Start-Sleep -Seconds 5
|
||||
Get-Process | Where-Object { $_.Path -like "$installRoot*" } | Stop-Process -Force -ErrorAction SilentlyContinue
|
||||
$exe = Get-ChildItem -Path (Join-Path $installRoot 'current') -Filter 'Fluxer*.exe' | Where-Object { $_.Name -notlike '*_ExecutionStub.exe' } | Select-Object -First 1
|
||||
if ($null -eq $exe) { $failures += "$from did not install"; continue }
|
||||
if ($env:STALE_APPLY_RECORD -eq 'true') {
|
||||
$dataRoot = Split-Path (Split-Path $logPath -Parent) -Parent
|
||||
New-Item -ItemType Directory -Force -Path $dataRoot | Out-Null
|
||||
$attemptedAt = [DateTimeOffset]::UtcNow.AddHours(-1).ToUnixTimeMilliseconds()
|
||||
Set-Content -Path (Join-Path $dataRoot 'update-apply-state.json') -Value "{`"version`":`"$env:VERSION`",`"attemptedAt`":$attemptedAt}" -NoNewline
|
||||
Write-Host "left a stale apply record for $env:VERSION"
|
||||
}
|
||||
Start-Process -FilePath $exe.FullName -ArgumentList '--disable-gpu'
|
||||
$deadline = (Get-Date).AddMinutes(8)
|
||||
$reported = $null
|
||||
while ((Get-Date) -lt $deadline) {
|
||||
Start-Sleep -Seconds 10
|
||||
if (Test-Path $logPath) {
|
||||
$line = Select-String -Path $logPath -Pattern "The renderer reported its build: .*Desktop $([regex]::Escape($env:VERSION)), Web $([regex]::Escape($env:VERSION))" | Select-Object -Last 1
|
||||
if ($null -ne $line) { $reported = $line.Line; break }
|
||||
}
|
||||
}
|
||||
$installed = Get-Content (Join-Path $installRoot 'current\sq.version') -Raw -ErrorAction SilentlyContinue
|
||||
Write-Host "installed package after update: $installed"
|
||||
$logs = Join-Path $env:RUNNER_TEMP "logs\$from"
|
||||
New-Item -ItemType Directory -Force -Path $logs | Out-Null
|
||||
if (Test-Path $logPath) { Copy-Item $logPath $logs }
|
||||
Get-ChildItem -Path $installRoot -Filter '*.log' -ErrorAction SilentlyContinue | Copy-Item -Destination $logs
|
||||
if (Test-Path $logPath) { Get-Content $logPath | Select-String -Pattern 'Bootstrap|ShellSelfUpdate|Velopack|velopack|NativeModulePreflight|reported its build' | Select-Object -Last 40 | ForEach-Object { Write-Host $_.Line } }
|
||||
if ($null -eq $reported) { $failures += "$from did not reach Desktop and Web $env:VERSION" } else { Write-Host "$from updated: $reported" }
|
||||
Get-Process | Where-Object { $_.Path -like "$installRoot*" } | Stop-Process -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
if ($failures.Count -gt 0) { throw ($failures -join "`n") }
|
||||
|
||||
- name: Upload update logs
|
||||
if: always() && env.UPDATE_FROM != ''
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02
|
||||
with:
|
||||
name: update-logs-${{ matrix.arch }}
|
||||
path: ${{ runner.temp }}/logs
|
||||
if-no-files-found: ignore
|
||||
retention-days: 7
|
||||
@@ -297,6 +297,11 @@ LIVEKIT_API_SECRET=CHANGE_ME
|
||||
#FLUXER_LIVEKIT_DEFAULT_REGION={"id":"default","name":"Default","emoji":"🌍","latitude":0,"longitude":0}
|
||||
#FLUXER_LIVEKIT_LOG_LEVEL=info
|
||||
|
||||
# STUN servers for peer-to-peer calls, which need the voice_p2p experiment turned
|
||||
# on in the admin panel. STUN finds each peer's public address. The value below
|
||||
# is an example.
|
||||
#FLUXER_VOICE_P2P_STUN_URLS=stun:stun.example.com:3478
|
||||
|
||||
FLUXER_KLIPY_API_KEY=
|
||||
#FLUXER_YOUTUBE_API_KEY=
|
||||
# Hosts the api never unfurls, comma separated.
|
||||
|
||||
@@ -104,6 +104,7 @@ x-fluxer-env: &fluxer-env
|
||||
FLUXER_LIVEKIT_INTERNAL_URL: ${FLUXER_LIVEKIT_INTERNAL_URL:-http://livekit:7880}
|
||||
FLUXER_LIVEKIT_DEFAULT_REGION: '${FLUXER_LIVEKIT_DEFAULT_REGION:-{"id":"default","name":"Default","emoji":"🌍","latitude":0,"longitude":0}}'
|
||||
FLUXER_LIVEKIT_URL: ${FLUXER_LIVEKIT_URL:-${FLUXER_PUBLIC_ORIGIN:-${FLUXER_PUBLIC_SCHEME:-https}://${FLUXER_DOMAIN}:${FLUXER_PUBLIC_PORT:-443}}/livekit}
|
||||
FLUXER_VOICE_P2P_STUN_URLS: ${FLUXER_VOICE_P2P_STUN_URLS:-}
|
||||
|
||||
FLUXER_KLIPY_API_KEY: ${FLUXER_KLIPY_API_KEY:-}
|
||||
FLUXER_YOUTUBE_API_KEY: ${FLUXER_YOUTUBE_API_KEY:-}
|
||||
|
||||
@@ -11268,6 +11268,7 @@
|
||||
"gateway_rollout": {"$ref": "#/components/schemas/GatewayRolloutConfigResponse"},
|
||||
"push_relay": {"$ref": "#/components/schemas/PushRelayConfigResponse"},
|
||||
"domain_migration": {"$ref": "#/components/schemas/DomainMigrationConfigResponse"},
|
||||
"voice_p2p": {"$ref": "#/components/schemas/VoiceP2pConfigResponse"},
|
||||
"captcha": {"$ref": "#/components/schemas/CaptchaConfigResponse"},
|
||||
"experiment_delivery": {"$ref": "#/components/schemas/ExperimentDeliveryConfigResponse"},
|
||||
"registration": {
|
||||
@@ -11682,6 +11683,7 @@
|
||||
"gateway_rollout",
|
||||
"push_relay",
|
||||
"domain_migration",
|
||||
"voice_p2p",
|
||||
"captcha",
|
||||
"experiment_delivery",
|
||||
"registration",
|
||||
@@ -11820,6 +11822,7 @@
|
||||
"nullable": true,
|
||||
"allOf": [{"$ref": "#/components/schemas/DomainMigrationConfigUpdateRequest"}]
|
||||
},
|
||||
"voice_p2p": {"nullable": true, "allOf": [{"$ref": "#/components/schemas/VoiceP2pConfigUpdateRequest"}]},
|
||||
"captcha": {"nullable": true, "allOf": [{"$ref": "#/components/schemas/CaptchaConfigUpdateRequest"}]},
|
||||
"experiment_delivery": {
|
||||
"nullable": true,
|
||||
@@ -14679,6 +14682,10 @@
|
||||
"nullable": true,
|
||||
"type": "string"
|
||||
},
|
||||
"rtc_p2p": {
|
||||
"description": "Whether calls in the voice channel connect participants directly instead of through a voice server",
|
||||
"type": "boolean"
|
||||
},
|
||||
"last_message_id": {
|
||||
"description": "The ID of the last message sent in this channel",
|
||||
"nullable": true,
|
||||
@@ -16314,11 +16321,38 @@
|
||||
"max_counter": {"type": "integer", "minimum": 100, "maximum": 20000}
|
||||
}
|
||||
},
|
||||
"VoiceP2pConfigUpdateRequest": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"enabled": {"type": "boolean"},
|
||||
"rollout_basis_points": {"type": "integer", "minimum": 0, "maximum": 10000},
|
||||
"rollout_country_codes": {"allOf": [{"$ref": "#/components/schemas/ExperimentRolloutCountryCodesSchema"}]},
|
||||
"rollout_salt": {"type": "string", "minLength": 1, "maxLength": 64, "pattern": "^[\\x20-\\x7e]+$"},
|
||||
"included_user_ids": {
|
||||
"maxItems": 1000,
|
||||
"type": "array",
|
||||
"items": {"type": "string", "pattern": "^\\d{1,20}$"}
|
||||
},
|
||||
"included_guild_ids": {
|
||||
"maxItems": 1000,
|
||||
"type": "array",
|
||||
"items": {"type": "string", "pattern": "^\\d{1,20}$"}
|
||||
},
|
||||
"include_premium_users": {"type": "boolean"},
|
||||
"excluded_user_ids": {
|
||||
"maxItems": 1000,
|
||||
"type": "array",
|
||||
"items": {"type": "string", "pattern": "^\\d{1,20}$"}
|
||||
},
|
||||
"max_participants": {"type": "integer", "minimum": 2, "maximum": 4}
|
||||
}
|
||||
},
|
||||
"DomainMigrationConfigUpdateRequest": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"enabled": {"type": "boolean"},
|
||||
"rollout_basis_points": {"type": "integer", "minimum": 0, "maximum": 10000},
|
||||
"rollout_country_codes": {"allOf": [{"$ref": "#/components/schemas/ExperimentRolloutCountryCodesSchema"}]},
|
||||
"rollout_salt": {"type": "string", "minLength": 1, "maxLength": 64, "pattern": "^[\\x20-\\x7e]+$"},
|
||||
"included_user_ids": {
|
||||
"maxItems": 1000,
|
||||
@@ -16355,6 +16389,11 @@
|
||||
"voice_e2ee_scope": {"type": "string", "enum": ["guild_feature_only", "platform_wide"]}
|
||||
}
|
||||
},
|
||||
"ExperimentRolloutCountryCodesSchema": {
|
||||
"maxItems": 250,
|
||||
"type": "array",
|
||||
"items": {"type": "string", "pattern": "^[A-Z]{2}$"}
|
||||
},
|
||||
"BillingLegacyPricesSchema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
@@ -16470,12 +16509,68 @@
|
||||
"required": ["enabled", "cost", "max_counter"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"VoiceP2pConfigResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"enabled": {"default": false, "type": "boolean"},
|
||||
"config_version": {"default": 0, "type": "integer", "minimum": 0, "maximum": 9007199254740991},
|
||||
"rollout_basis_points": {"default": 0, "type": "integer", "minimum": 0, "maximum": 10000},
|
||||
"rollout_country_codes": {
|
||||
"default": [],
|
||||
"allOf": [{"$ref": "#/components/schemas/ExperimentRolloutCountryCodesSchema"}]
|
||||
},
|
||||
"rollout_salt": {
|
||||
"default": "voice-p2p-v1",
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 64,
|
||||
"pattern": "^[\\x20-\\x7e]+$"
|
||||
},
|
||||
"included_user_ids": {
|
||||
"default": [],
|
||||
"maxItems": 1000,
|
||||
"type": "array",
|
||||
"items": {"type": "string", "pattern": "^\\d{1,20}$"}
|
||||
},
|
||||
"included_guild_ids": {
|
||||
"default": [],
|
||||
"maxItems": 1000,
|
||||
"type": "array",
|
||||
"items": {"type": "string", "pattern": "^\\d{1,20}$"}
|
||||
},
|
||||
"include_premium_users": {"default": false, "type": "boolean"},
|
||||
"excluded_user_ids": {
|
||||
"default": [],
|
||||
"maxItems": 1000,
|
||||
"type": "array",
|
||||
"items": {"type": "string", "pattern": "^\\d{1,20}$"}
|
||||
},
|
||||
"max_participants": {"default": 2, "type": "integer", "minimum": 2, "maximum": 4}
|
||||
},
|
||||
"required": [
|
||||
"enabled",
|
||||
"config_version",
|
||||
"rollout_basis_points",
|
||||
"rollout_country_codes",
|
||||
"rollout_salt",
|
||||
"included_user_ids",
|
||||
"included_guild_ids",
|
||||
"include_premium_users",
|
||||
"excluded_user_ids",
|
||||
"max_participants"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"DomainMigrationConfigResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"enabled": {"default": false, "type": "boolean"},
|
||||
"config_version": {"default": 0, "type": "integer", "minimum": 0, "maximum": 9007199254740991},
|
||||
"rollout_basis_points": {"default": 0, "type": "integer", "minimum": 0, "maximum": 10000},
|
||||
"rollout_country_codes": {
|
||||
"default": [],
|
||||
"allOf": [{"$ref": "#/components/schemas/ExperimentRolloutCountryCodesSchema"}]
|
||||
},
|
||||
"rollout_salt": {
|
||||
"default": "domain-migration-v1",
|
||||
"type": "string",
|
||||
@@ -16509,6 +16604,7 @@
|
||||
"enabled",
|
||||
"config_version",
|
||||
"rollout_basis_points",
|
||||
"rollout_country_codes",
|
||||
"rollout_salt",
|
||||
"included_user_ids",
|
||||
"included_guild_ids",
|
||||
@@ -17262,6 +17358,7 @@
|
||||
"MAX_GROUP_DM_RECIPIENTS",
|
||||
"MAX_GROUP_DMS",
|
||||
"GROUP_DM_RECIPIENTS_NOT_ADDABLE",
|
||||
"GROUP_DM_MATURE_CONTENT_INELIGIBLE",
|
||||
"MAX_GUILD_CHANNELS",
|
||||
"MAX_GUILD_MEMBERS",
|
||||
"MAX_GUILD_ROLES",
|
||||
@@ -17558,6 +17655,7 @@
|
||||
"You've reached the maximum of {count, plural, one {# group DM recipient} other {# group DM recipients}}.",
|
||||
"You've reached the maximum of {count, plural, one {# group DM} other {# group DMs}}.",
|
||||
"One or more selected users can't be added to this group DM.",
|
||||
"Not everyone in this group is eligible for mature content.",
|
||||
"You've reached the maximum of {count, plural, one {# community channel} other {# community channels}}.",
|
||||
"You've reached the maximum of {count, plural, one {# community member} other {# community members}}.",
|
||||
"You've reached the maximum of {count, plural, one {# community role} other {# community roles}}.",
|
||||
|
||||
@@ -27,6 +27,8 @@ pub struct InstanceConfigResponse {
|
||||
#[serde(default)]
|
||||
pub domain_migration: DomainMigrationConfigResponse,
|
||||
#[serde(default)]
|
||||
pub voice_p2p: VoiceP2pConfigResponse,
|
||||
#[serde(default)]
|
||||
pub captcha: CaptchaConfigResponse,
|
||||
#[serde(default)]
|
||||
pub experiment_delivery: ExperimentDeliveryConfigResponse,
|
||||
@@ -506,7 +508,10 @@ impl VoiceE2eeScope {
|
||||
}
|
||||
|
||||
pub const EXPERIMENT_MAX_TARGETED_USERS: usize = 1_000;
|
||||
pub const EXPERIMENT_MAX_ROLLOUT_COUNTRY_CODES: usize = 250;
|
||||
pub const DOMAIN_MIGRATION_DEFAULT_SALT: &str = "domain-migration-v1";
|
||||
pub const VOICE_P2P_DEFAULT_SALT: &str = "voice-p2p-v1";
|
||||
pub const VOICE_P2P_MAX_PARTICIPANTS_RANGE: std::ops::RangeInclusive<u32> = 2..=4;
|
||||
pub const CAPTCHA_COST_RANGE: std::ops::RangeInclusive<u32> = 1_000..=20_000;
|
||||
pub const CAPTCHA_MAX_COUNTER_RANGE: std::ops::RangeInclusive<u32> = 100..=20_000;
|
||||
|
||||
@@ -530,6 +535,7 @@ pub struct DomainMigrationConfigResponse {
|
||||
pub enabled: bool,
|
||||
pub config_version: u64,
|
||||
pub rollout_basis_points: u32,
|
||||
pub rollout_country_codes: Vec<String>,
|
||||
pub rollout_salt: String,
|
||||
pub included_user_ids: Vec<String>,
|
||||
pub included_guild_ids: Vec<String>,
|
||||
@@ -545,6 +551,7 @@ impl Default for DomainMigrationConfigResponse {
|
||||
enabled: false,
|
||||
config_version: 0,
|
||||
rollout_basis_points: 0,
|
||||
rollout_country_codes: Vec::new(),
|
||||
rollout_salt: DOMAIN_MIGRATION_DEFAULT_SALT.to_owned(),
|
||||
included_user_ids: Vec::new(),
|
||||
included_guild_ids: Vec::new(),
|
||||
@@ -563,6 +570,8 @@ pub struct DomainMigrationConfigUpdateRequest {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub rollout_basis_points: Option<u32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub rollout_country_codes: Option<Vec<String>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub rollout_salt: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub included_user_ids: Option<Vec<String>>,
|
||||
@@ -578,6 +587,60 @@ pub struct DomainMigrationConfigUpdateRequest {
|
||||
pub standalone_forwarding: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
#[serde(default)]
|
||||
pub struct VoiceP2pConfigResponse {
|
||||
pub enabled: bool,
|
||||
pub config_version: u64,
|
||||
pub rollout_basis_points: u32,
|
||||
pub rollout_country_codes: Vec<String>,
|
||||
pub rollout_salt: String,
|
||||
pub included_user_ids: Vec<String>,
|
||||
pub included_guild_ids: Vec<String>,
|
||||
pub include_premium_users: bool,
|
||||
pub excluded_user_ids: Vec<String>,
|
||||
pub max_participants: u32,
|
||||
}
|
||||
|
||||
impl Default for VoiceP2pConfigResponse {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: false,
|
||||
config_version: 0,
|
||||
rollout_basis_points: 0,
|
||||
rollout_country_codes: Vec::new(),
|
||||
rollout_salt: VOICE_P2P_DEFAULT_SALT.to_owned(),
|
||||
included_user_ids: Vec::new(),
|
||||
included_guild_ids: Vec::new(),
|
||||
include_premium_users: false,
|
||||
excluded_user_ids: Vec::new(),
|
||||
max_participants: *VOICE_P2P_MAX_PARTICIPANTS_RANGE.start(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Serialize)]
|
||||
pub struct VoiceP2pConfigUpdateRequest {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub enabled: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub rollout_basis_points: Option<u32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub rollout_country_codes: Option<Vec<String>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub rollout_salt: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub included_user_ids: Option<Vec<String>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub included_guild_ids: Option<Vec<String>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub include_premium_users: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub excluded_user_ids: Option<Vec<String>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub max_participants: Option<u32>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
#[serde(default)]
|
||||
pub struct CaptchaConfigResponse {
|
||||
@@ -724,6 +787,8 @@ pub struct InstanceConfigUpdateRequest {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub domain_migration: Option<DomainMigrationConfigUpdateRequest>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub voice_p2p: Option<VoiceP2pConfigUpdateRequest>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub captcha: Option<CaptchaConfigUpdateRequest>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub experiment_delivery: Option<ExperimentDeliveryConfigUpdateRequest>,
|
||||
@@ -1028,17 +1093,22 @@ mod tests {
|
||||
.expect("admin schema");
|
||||
let domain_migration = serde_json::from_value::<DomainMigrationConfigResponse>(json!({}))
|
||||
.expect("default domain migration config");
|
||||
let voice_p2p = serde_json::from_value::<VoiceP2pConfigResponse>(json!({}))
|
||||
.expect("default voice p2p config");
|
||||
let captcha = serde_json::from_value::<CaptchaConfigResponse>(json!({}))
|
||||
.expect("default captcha config");
|
||||
let delivery = serde_json::from_value::<ExperimentDeliveryConfigResponse>(json!({}))
|
||||
.expect("default delivery config");
|
||||
let domain_migration =
|
||||
serde_json::to_value(domain_migration).expect("serializable domain migration config");
|
||||
let voice_p2p = serde_json::to_value(voice_p2p).expect("serializable voice p2p config");
|
||||
let captcha = serde_json::to_value(captcha).expect("serializable captcha config");
|
||||
let delivery = serde_json::to_value(delivery).expect("serializable delivery config");
|
||||
let generated_domain_migration: generated_types::DomainMigrationConfigResponse =
|
||||
serde_json::from_value(domain_migration.clone())
|
||||
.expect("generated domain migration config contract");
|
||||
let generated_voice_p2p: generated_types::VoiceP2pConfigResponse =
|
||||
serde_json::from_value(voice_p2p.clone()).expect("generated voice p2p config contract");
|
||||
let generated_captcha: generated_types::CaptchaConfigResponse =
|
||||
serde_json::from_value(captcha.clone()).expect("generated captcha config contract");
|
||||
let generated_delivery: generated_types::ExperimentDeliveryConfigResponse =
|
||||
@@ -1048,6 +1118,11 @@ mod tests {
|
||||
.expect("serializable generated domain migration config"),
|
||||
domain_migration
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::to_value(generated_voice_p2p)
|
||||
.expect("serializable generated voice p2p config"),
|
||||
voice_p2p
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::to_value(generated_captcha).expect("serializable generated captcha config"),
|
||||
captcha
|
||||
@@ -1059,6 +1134,7 @@ mod tests {
|
||||
);
|
||||
for (name, value) in [
|
||||
("DomainMigrationConfigResponse", domain_migration),
|
||||
("VoiceP2pConfigResponse", voice_p2p),
|
||||
("CaptchaConfigResponse", captcha),
|
||||
("ExperimentDeliveryConfigResponse", delivery),
|
||||
] {
|
||||
@@ -1093,4 +1169,25 @@ mod tests {
|
||||
json!({})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn voice_p2p_update_preserves_empty_lists_and_omitted_fields() {
|
||||
let update = VoiceP2pConfigUpdateRequest {
|
||||
included_user_ids: Some(Vec::new()),
|
||||
excluded_user_ids: Some(Vec::new()),
|
||||
..Default::default()
|
||||
};
|
||||
let value = serde_json::to_value(update).expect("serializable update");
|
||||
serde_json::from_value::<generated_types::VoiceP2pConfigUpdateRequest>(value.clone())
|
||||
.expect("generated update contract");
|
||||
assert_eq!(
|
||||
value,
|
||||
json!({"included_user_ids": [], "excluded_user_ids": []})
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::to_value(VoiceP2pConfigUpdateRequest::default())
|
||||
.expect("serializable update"),
|
||||
json!({})
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -8,18 +8,19 @@ use crate::{
|
||||
AppPublicConfigUpdateRequest, AppRegistrationConfigUpdateRequest,
|
||||
AppSetupConfigUpdateRequest, CAPTCHA_COST_RANGE, CAPTCHA_MAX_COUNTER_RANGE,
|
||||
CaptchaConfigUpdateRequest, CreateRegistrationUrlRequest,
|
||||
DomainMigrationConfigUpdateRequest, EXPERIMENT_MAX_TARGETED_USERS,
|
||||
ExperimentDeliveryConfigUpdateRequest, GatewayRolloutConfigUpdateRequest,
|
||||
GatewayRolloutMode, InstanceAttachmentDecayUpdateRequest,
|
||||
InstanceBlueskyIntegrationUpdateRequest, InstanceBlueskyKeyIntegrationUpdateRequest,
|
||||
InstanceConfigUpdateRequest, InstanceEmailIntegrationUpdateRequest,
|
||||
InstanceEmailSmtpIntegrationUpdateRequest, InstanceEmailSmtpTestRequest,
|
||||
InstanceGifIntegrationUpdateRequest, InstanceIntegrationsUpdateRequest,
|
||||
InstanceMediaUpdateRequest, InstancePolicyUpdateRequest,
|
||||
InstanceRegistrationConfigUpdateRequest, InstanceServicesUpdateRequest,
|
||||
InstanceYoutubeIntegrationUpdateRequest, LimitConfigUpdateRequest, LimitRule,
|
||||
LimitRuleFilters, PremiumMode, PushRelayConfigUpdateRequest, RegistrationMode,
|
||||
SsoConfigUpdateRequest, VoiceE2eeScope,
|
||||
DomainMigrationConfigUpdateRequest, EXPERIMENT_MAX_ROLLOUT_COUNTRY_CODES,
|
||||
EXPERIMENT_MAX_TARGETED_USERS, ExperimentDeliveryConfigUpdateRequest,
|
||||
GatewayRolloutConfigUpdateRequest, GatewayRolloutMode,
|
||||
InstanceAttachmentDecayUpdateRequest, InstanceBlueskyIntegrationUpdateRequest,
|
||||
InstanceBlueskyKeyIntegrationUpdateRequest, InstanceConfigUpdateRequest,
|
||||
InstanceEmailIntegrationUpdateRequest, InstanceEmailSmtpIntegrationUpdateRequest,
|
||||
InstanceEmailSmtpTestRequest, InstanceGifIntegrationUpdateRequest,
|
||||
InstanceIntegrationsUpdateRequest, InstanceMediaUpdateRequest,
|
||||
InstancePolicyUpdateRequest, InstanceRegistrationConfigUpdateRequest,
|
||||
InstanceServicesUpdateRequest, InstanceYoutubeIntegrationUpdateRequest,
|
||||
LimitConfigUpdateRequest, LimitRule, LimitRuleFilters, PremiumMode,
|
||||
PushRelayConfigUpdateRequest, RegistrationMode, SsoConfigUpdateRequest,
|
||||
VOICE_P2P_MAX_PARTICIPANTS_RANGE, VoiceE2eeScope, VoiceP2pConfigUpdateRequest,
|
||||
},
|
||||
},
|
||||
config::AdminConfig,
|
||||
@@ -220,6 +221,10 @@ pub async fn instance_config_post(
|
||||
Ok(update) => instance_config_result(client.update_instance_config(&update).await),
|
||||
Err(message) => FlashData::error(message),
|
||||
},
|
||||
"update_voice_p2p" => match build_voice_p2p_update(&form) {
|
||||
Ok(update) => instance_config_result(client.update_instance_config(&update).await),
|
||||
Err(message) => FlashData::error(message),
|
||||
},
|
||||
"update_captcha" => match build_captcha_update(&form) {
|
||||
Ok(update) => instance_config_result(client.update_instance_config(&update).await),
|
||||
Err(message) => FlashData::error(message),
|
||||
@@ -557,6 +562,32 @@ fn parse_experiment_user_ids(value: &str, label: &str) -> Result<Vec<String>, St
|
||||
Ok(ids)
|
||||
}
|
||||
|
||||
fn parse_experiment_country_codes(value: &str) -> Result<Vec<String>, String> {
|
||||
let mut codes: Vec<String> = Vec::new();
|
||||
let candidates = value
|
||||
.split(|character: char| character == ',' || character.is_whitespace())
|
||||
.filter(|candidate| !candidate.is_empty());
|
||||
for (index, candidate) in candidates.enumerate() {
|
||||
if candidate.len() != 2 || !candidate.bytes().all(|byte| byte.is_ascii_alphabetic()) {
|
||||
return Err(format!(
|
||||
"Rollout countries entry {} must be a two-letter country code",
|
||||
index + 1
|
||||
));
|
||||
}
|
||||
let code = candidate.to_ascii_uppercase();
|
||||
if codes.contains(&code) {
|
||||
continue;
|
||||
}
|
||||
if codes.len() == EXPERIMENT_MAX_ROLLOUT_COUNTRY_CODES {
|
||||
return Err(format!(
|
||||
"Rollout countries must contain at most {EXPERIMENT_MAX_ROLLOUT_COUNTRY_CODES} unique codes"
|
||||
));
|
||||
}
|
||||
codes.push(code);
|
||||
}
|
||||
Ok(codes)
|
||||
}
|
||||
|
||||
fn build_push_relay_update(form: &MultiValueForm) -> InstanceConfigUpdateRequest {
|
||||
InstanceConfigUpdateRequest {
|
||||
push_relay: Some(PushRelayConfigUpdateRequest {
|
||||
@@ -579,6 +610,10 @@ fn build_domain_migration_update(
|
||||
0,
|
||||
EXPERIMENT_ROLLOUT_BASIS_POINTS_MAX,
|
||||
)?,
|
||||
rollout_country_codes: Some(parse_experiment_country_codes(
|
||||
form.first("domain_migration_rollout_country_codes")
|
||||
.unwrap_or_default(),
|
||||
)?),
|
||||
rollout_salt: parse_experiment_rollout_salt(form, "domain_migration_rollout_salt")?,
|
||||
included_user_ids: Some(parse_experiment_user_ids(
|
||||
form.first("domain_migration_included_user_ids")
|
||||
@@ -609,6 +644,50 @@ fn build_domain_migration_update(
|
||||
})
|
||||
}
|
||||
|
||||
fn build_voice_p2p_update(form: &MultiValueForm) -> Result<InstanceConfigUpdateRequest, String> {
|
||||
Ok(InstanceConfigUpdateRequest {
|
||||
voice_p2p: Some(VoiceP2pConfigUpdateRequest {
|
||||
enabled: Some(form.bool_value("voice_p2p_enabled")),
|
||||
rollout_basis_points: parse_form_number(
|
||||
form,
|
||||
"voice_p2p_rollout_basis_points",
|
||||
"Rollout basis points",
|
||||
0,
|
||||
EXPERIMENT_ROLLOUT_BASIS_POINTS_MAX,
|
||||
)?,
|
||||
rollout_country_codes: Some(parse_experiment_country_codes(
|
||||
form.first("voice_p2p_rollout_country_codes")
|
||||
.unwrap_or_default(),
|
||||
)?),
|
||||
rollout_salt: parse_experiment_rollout_salt(form, "voice_p2p_rollout_salt")?,
|
||||
included_user_ids: Some(parse_experiment_user_ids(
|
||||
form.first("voice_p2p_included_user_ids")
|
||||
.unwrap_or_default(),
|
||||
"Included user IDs",
|
||||
)?),
|
||||
included_guild_ids: Some(parse_experiment_user_ids(
|
||||
form.first("voice_p2p_included_guild_ids")
|
||||
.unwrap_or_default(),
|
||||
"Included guild IDs",
|
||||
)?),
|
||||
include_premium_users: Some(form.bool_value("voice_p2p_include_premium_users")),
|
||||
excluded_user_ids: Some(parse_experiment_user_ids(
|
||||
form.first("voice_p2p_excluded_user_ids")
|
||||
.unwrap_or_default(),
|
||||
"Excluded user IDs",
|
||||
)?),
|
||||
max_participants: parse_form_number(
|
||||
form,
|
||||
"voice_p2p_max_participants",
|
||||
"Maximum participants",
|
||||
*VOICE_P2P_MAX_PARTICIPANTS_RANGE.start(),
|
||||
*VOICE_P2P_MAX_PARTICIPANTS_RANGE.end(),
|
||||
)?,
|
||||
}),
|
||||
..Default::default()
|
||||
})
|
||||
}
|
||||
|
||||
fn build_captcha_update(form: &MultiValueForm) -> Result<InstanceConfigUpdateRequest, String> {
|
||||
Ok(InstanceConfigUpdateRequest {
|
||||
captcha: Some(CaptchaConfigUpdateRequest {
|
||||
@@ -1340,10 +1419,62 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_experiment_country_codes_splits_uppercases_and_dedupes() {
|
||||
assert_eq!(
|
||||
parse_experiment_country_codes(" se ,NO\nbr\r\n fi\tSE ,, no ").expect("valid codes"),
|
||||
vec![
|
||||
"SE".to_owned(),
|
||||
"NO".to_owned(),
|
||||
"BR".to_owned(),
|
||||
"FI".to_owned()
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
parse_experiment_country_codes(" \n ").expect("valid codes"),
|
||||
Vec::<String>::new()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_experiment_country_codes_rejects_malformed_codes() {
|
||||
for value in ["S", "SWE", "S1", "5E", "<>", "ÅL"] {
|
||||
assert_eq!(
|
||||
parse_experiment_country_codes(&format!("SE,{value}")).expect_err("invalid code"),
|
||||
"Rollout countries entry 2 must be a two-letter country code",
|
||||
"{value}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_experiment_country_codes_rejects_exceeding_the_cap() {
|
||||
let codes = (b'A'..=b'Z')
|
||||
.flat_map(|first| {
|
||||
(b'A'..=b'Z').map(move |second| format!("{}{}", first as char, second as char))
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let at_cap = codes[..EXPERIMENT_MAX_ROLLOUT_COUNTRY_CODES].join(" ");
|
||||
assert_eq!(
|
||||
parse_experiment_country_codes(&format!("{at_cap} aa"))
|
||||
.expect("valid codes at cap")
|
||||
.len(),
|
||||
EXPERIMENT_MAX_ROLLOUT_COUNTRY_CODES
|
||||
);
|
||||
assert_eq!(
|
||||
parse_experiment_country_codes(&format!(
|
||||
"{at_cap} {}",
|
||||
codes[EXPERIMENT_MAX_ROLLOUT_COUNTRY_CODES]
|
||||
))
|
||||
.expect_err("too many codes"),
|
||||
"Rollout countries must contain at most 250 unique codes"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_domain_migration_update_reads_the_rollout_fields() {
|
||||
let form = MultiValueForm::parse(
|
||||
b"domain_migration_enabled=true&domain_migration_rollout_basis_points=%20250%20&domain_migration_rollout_salt=%20domain-migration-v2%20&domain_migration_included_user_ids=1500000000000000001%0A1500000000000000002&domain_migration_excluded_user_ids=1500000000000000003%2C%201500000000000000004&domain_migration_anonymous_rollout_basis_points=%20100%20&domain_migration_standalone_forwarding=true&domain_migration_included_guild_ids=1500000000000000005%0A1500000000000000006%2C1500000000000000005&domain_migration_include_premium_users=true",
|
||||
b"domain_migration_enabled=true&domain_migration_rollout_basis_points=%20250%20&domain_migration_rollout_salt=%20domain-migration-v2%20&domain_migration_included_user_ids=1500000000000000001%0A1500000000000000002&domain_migration_excluded_user_ids=1500000000000000003%2C%201500000000000000004&domain_migration_anonymous_rollout_basis_points=%20100%20&domain_migration_standalone_forwarding=true&domain_migration_included_guild_ids=1500000000000000005%0A1500000000000000006%2C1500000000000000005&domain_migration_include_premium_users=true&domain_migration_rollout_country_codes=se%2C%20NO%0Ase",
|
||||
);
|
||||
let update = build_domain_migration_update(&form)
|
||||
.expect("valid form")
|
||||
@@ -1369,6 +1500,10 @@ mod tests {
|
||||
assert_eq!(update.anonymous_rollout_basis_points, Some(100));
|
||||
assert_eq!(update.standalone_forwarding, Some(true));
|
||||
assert_eq!(update.include_premium_users, Some(true));
|
||||
assert_eq!(
|
||||
update.rollout_country_codes,
|
||||
Some(vec!["SE".to_owned(), "NO".to_owned()])
|
||||
);
|
||||
assert_eq!(
|
||||
update.included_guild_ids,
|
||||
Some(vec![
|
||||
@@ -1386,6 +1521,7 @@ mod tests {
|
||||
serde_json::to_value(request).expect("serializable update"),
|
||||
serde_json::json!({"domain_migration": {
|
||||
"enabled": false,
|
||||
"rollout_country_codes": [],
|
||||
"included_user_ids": [],
|
||||
"included_guild_ids": [],
|
||||
"include_premium_users": false,
|
||||
@@ -1430,6 +1566,10 @@ mod tests {
|
||||
"domain_migration_excluded_user_ids=123%2Cinvalid",
|
||||
"Excluded user IDs entry 2 must contain 1 to 20 decimal digits",
|
||||
),
|
||||
(
|
||||
"domain_migration_rollout_country_codes=SE%2CSWE",
|
||||
"Rollout countries entry 2 must be a two-letter country code",
|
||||
),
|
||||
] {
|
||||
let form = MultiValueForm::parse(form.as_bytes());
|
||||
assert_eq!(
|
||||
@@ -1519,6 +1659,90 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_voice_p2p_update_reads_the_rollout_fields() {
|
||||
let form = MultiValueForm::parse(
|
||||
b"voice_p2p_enabled=true&voice_p2p_rollout_basis_points=%20500%20&voice_p2p_rollout_salt=%20voice-p2p-v2%20&voice_p2p_included_user_ids=1500000000000000001&voice_p2p_excluded_user_ids=1500000000000000002&voice_p2p_included_guild_ids=1500000000000000005%0A1500000000000000006%2C1500000000000000005&voice_p2p_include_premium_users=true&voice_p2p_rollout_country_codes=se%2C%20NO%0Ase&voice_p2p_max_participants=%203%20",
|
||||
);
|
||||
let update = build_voice_p2p_update(&form)
|
||||
.expect("valid form")
|
||||
.voice_p2p
|
||||
.expect("voice p2p update");
|
||||
assert_eq!(update.enabled, Some(true));
|
||||
assert_eq!(update.rollout_basis_points, Some(500));
|
||||
assert_eq!(update.rollout_salt, Some("voice-p2p-v2".to_owned()));
|
||||
assert_eq!(update.include_premium_users, Some(true));
|
||||
assert_eq!(update.max_participants, Some(3));
|
||||
assert_eq!(
|
||||
update.rollout_country_codes,
|
||||
Some(vec!["SE".to_owned(), "NO".to_owned()])
|
||||
);
|
||||
assert_eq!(
|
||||
update.included_guild_ids,
|
||||
Some(vec![
|
||||
"1500000000000000005".to_owned(),
|
||||
"1500000000000000006".to_owned()
|
||||
])
|
||||
);
|
||||
assert_eq!(
|
||||
update.included_user_ids,
|
||||
Some(vec!["1500000000000000001".to_owned()])
|
||||
);
|
||||
assert_eq!(
|
||||
update.excluded_user_ids,
|
||||
Some(vec!["1500000000000000002".to_owned()])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_voice_p2p_update_leaves_the_feature_inert_when_nothing_is_submitted() {
|
||||
let form = MultiValueForm::parse(b"_csrf=token");
|
||||
let request = build_voice_p2p_update(&form).expect("valid form");
|
||||
assert_eq!(
|
||||
serde_json::to_value(request).expect("serializable update"),
|
||||
serde_json::json!({"voice_p2p": {
|
||||
"enabled": false,
|
||||
"rollout_country_codes": [],
|
||||
"included_user_ids": [],
|
||||
"included_guild_ids": [],
|
||||
"include_premium_users": false,
|
||||
"excluded_user_ids": [],
|
||||
}})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_voice_p2p_update_rejects_invalid_rollout_fields() {
|
||||
for (form, message) in [
|
||||
(
|
||||
"voice_p2p_rollout_basis_points=10001",
|
||||
"Rollout basis points must be a whole number between 0 and 10000",
|
||||
),
|
||||
(
|
||||
"voice_p2p_included_guild_ids=1500000000000000005%0Anot-a-guild",
|
||||
"Included guild IDs entry 2 must contain 1 to 20 decimal digits",
|
||||
),
|
||||
(
|
||||
"voice_p2p_rollout_country_codes=SE%2CSWE",
|
||||
"Rollout countries entry 2 must be a two-letter country code",
|
||||
),
|
||||
(
|
||||
"voice_p2p_max_participants=5",
|
||||
"Maximum participants must be a whole number between 2 and 4",
|
||||
),
|
||||
(
|
||||
"voice_p2p_max_participants=1",
|
||||
"Maximum participants must be a whole number between 2 and 4",
|
||||
),
|
||||
] {
|
||||
let form = MultiValueForm::parse(form.as_bytes());
|
||||
assert_eq!(
|
||||
build_voice_p2p_update(&form).expect_err("invalid field"),
|
||||
message
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_experiment_delivery_update_leaves_both_fields_unchanged_when_absent() {
|
||||
let form = MultiValueForm::parse(b"_csrf=token");
|
||||
|
||||
@@ -5,11 +5,12 @@ use crate::{
|
||||
AccountIdentityConfigResponse, AccountIdentityMode, AppPublicConfigResponse,
|
||||
CAPTCHA_COST_RANGE, CAPTCHA_MAX_COUNTER_RANGE, CaptchaConfigResponse,
|
||||
DOMAIN_MIGRATION_DEFAULT_SALT, DomainMigrationConfigResponse,
|
||||
EXPERIMENT_MAX_TARGETED_USERS, ExperimentDeliveryConfigResponse,
|
||||
GatewayRolloutConfigResponse, InstanceConfigResponse, InstanceIntegrationsResponse,
|
||||
InstanceMediaResponse, InstancePolicyResponse, InstanceRegistrationResponse,
|
||||
LimitConfigResponse, PendingRegistrationResponse, PushRelayConfigResponse,
|
||||
RegistrationUrlResponse, SsoConfigResponse, TagStyle,
|
||||
EXPERIMENT_MAX_ROLLOUT_COUNTRY_CODES, EXPERIMENT_MAX_TARGETED_USERS,
|
||||
ExperimentDeliveryConfigResponse, GatewayRolloutConfigResponse, InstanceConfigResponse,
|
||||
InstanceIntegrationsResponse, InstanceMediaResponse, InstancePolicyResponse,
|
||||
InstanceRegistrationResponse, LimitConfigResponse, PendingRegistrationResponse,
|
||||
PushRelayConfigResponse, RegistrationUrlResponse, SsoConfigResponse, TagStyle,
|
||||
VOICE_P2P_DEFAULT_SALT, VOICE_P2P_MAX_PARTICIPANTS_RANGE, VoiceP2pConfigResponse,
|
||||
},
|
||||
config::AdminConfig,
|
||||
middleware::auth::AuthContext,
|
||||
@@ -58,6 +59,22 @@ fn entry_count_hint(count: usize, cap: usize) -> Markup {
|
||||
}
|
||||
}
|
||||
|
||||
fn experiment_rollout_country_codes_field(name: &str, country_codes: &[String]) -> Markup {
|
||||
html! {
|
||||
div class="flex flex-col gap-2" {
|
||||
(text_input(name, "Rollout Countries", &country_codes.join(", "), "SE, NO, FI"))
|
||||
(entry_count_hint(country_codes.len(), EXPERIMENT_MAX_ROLLOUT_COUNTRY_CODES))
|
||||
p class="text-xs text-neutral-500" {
|
||||
"Two-letter ISO 3166-1 country codes, separated by commas or spaces. When set, \
|
||||
the user rollout percentage only applies to requests from these countries, and \
|
||||
a request with an unknown country is left out. Leave empty to apply the \
|
||||
percentage everywhere. The always-on lists, the premium switch and the \
|
||||
never-on list ignore the country. Invalid entries prevent the save."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn number_field(
|
||||
name: &str,
|
||||
label: &str,
|
||||
@@ -193,6 +210,7 @@ pub fn instance_config_page(
|
||||
@if !instance_config.self_hosted {
|
||||
(domain_migration_section(base, csrf_token, &instance_config.domain_migration))
|
||||
}
|
||||
(voice_p2p_section(base, csrf_token, &instance_config.voice_p2p))
|
||||
(experiment_delivery_section(base, csrf_token, &instance_config.experiment_delivery))
|
||||
@if let Some(limit_config) = limit_config {
|
||||
(limit_config_section(base, limit_config))
|
||||
@@ -1193,6 +1211,10 @@ fn domain_migration_section(
|
||||
Some(0), Some(10000), "1",
|
||||
Some("Share of logged-out devices sent to the new origin, in basis points. Each device is bucketed on its own random ID."),
|
||||
))
|
||||
(experiment_rollout_country_codes_field(
|
||||
"domain_migration_rollout_country_codes",
|
||||
&domain_migration.rollout_country_codes,
|
||||
))
|
||||
div class="flex flex-col gap-2" {
|
||||
(text_input(
|
||||
"domain_migration_rollout_salt",
|
||||
@@ -1287,6 +1309,162 @@ fn domain_migration_section(
|
||||
)
|
||||
}
|
||||
|
||||
fn voice_p2p_section(base: &str, csrf_token: &str, voice_p2p: &VoiceP2pConfigResponse) -> Markup {
|
||||
let status = if voice_p2p.enabled {
|
||||
("Live", BadgeVariant::Success)
|
||||
} else {
|
||||
("Inert", BadgeVariant::Default)
|
||||
};
|
||||
let included_user_ids = voice_p2p.included_user_ids.join("\n");
|
||||
let excluded_user_ids = voice_p2p.excluded_user_ids.join("\n");
|
||||
section_card_with_description(
|
||||
"Peer-to-peer voice",
|
||||
"Lets selected users start small voice calls that connect the participants directly \
|
||||
instead of through the voice servers. A peer-to-peer call only moves to the voice \
|
||||
servers when a participant switches it or its region changes.",
|
||||
html! {
|
||||
form method="post" action={(base) "/instance-config?action=update_voice_p2p"} {
|
||||
(csrf_input(csrf_token))
|
||||
div class="space-y-6" {
|
||||
div class="flex flex-wrap items-center gap-2" {
|
||||
h3 class="text-sm font-semibold text-neutral-900" { "Master switch" }
|
||||
(badge(status.0, status.1))
|
||||
span class="text-xs text-neutral-500" {
|
||||
"Config version " (voice_p2p.config_version)
|
||||
}
|
||||
}
|
||||
(checkbox(
|
||||
"voice_p2p_enabled",
|
||||
"true",
|
||||
"Let the selected users start peer-to-peer calls",
|
||||
voice_p2p.enabled,
|
||||
true,
|
||||
))
|
||||
p class="text-xs text-neutral-500" {
|
||||
"Off is the safe state and the kill switch. With this unchecked every new \
|
||||
call uses the voice servers and a running peer-to-peer call accepts no \
|
||||
new joins, so the rollout and targeting fields below have no effect at \
|
||||
all. While it is on, the fields below select who can start a \
|
||||
peer-to-peer call, and anyone who agrees to a direct connection can \
|
||||
join one."
|
||||
}
|
||||
|
||||
h3 class="text-sm font-semibold text-neutral-900" { "Call size" }
|
||||
(number_field(
|
||||
"voice_p2p_max_participants",
|
||||
"Maximum participants",
|
||||
&voice_p2p.max_participants.to_string(),
|
||||
Some(*VOICE_P2P_MAX_PARTICIPANTS_RANGE.start()),
|
||||
Some(*VOICE_P2P_MAX_PARTICIPANTS_RANGE.end()),
|
||||
"1",
|
||||
Some("Most participants a peer-to-peer call can hold, 2 through 4. Clients only offer a peer-to-peer call in a direct message or group with at most this many members, and a join past it is refused as full. Lowering it never removes anyone from a running call."),
|
||||
))
|
||||
|
||||
h3 class="text-sm font-semibold text-neutral-900" { "Rollout" }
|
||||
(number_field(
|
||||
"voice_p2p_rollout_basis_points",
|
||||
"Rollout (basis points)",
|
||||
&voice_p2p.rollout_basis_points.to_string(),
|
||||
Some(0), Some(10000), "1",
|
||||
Some("Share of users who can start peer-to-peer calls, in basis points: 0 is nobody, 100 is 1%, 10000 is everybody."),
|
||||
))
|
||||
(experiment_rollout_country_codes_field(
|
||||
"voice_p2p_rollout_country_codes",
|
||||
&voice_p2p.rollout_country_codes,
|
||||
))
|
||||
div class="flex flex-col gap-2" {
|
||||
(text_input(
|
||||
"voice_p2p_rollout_salt",
|
||||
"Rollout Salt",
|
||||
&voice_p2p.rollout_salt,
|
||||
VOICE_P2P_DEFAULT_SALT,
|
||||
))
|
||||
p class="text-xs text-neutral-500" {
|
||||
"Seeds the bucketing hash. Changing it reshuffles which users fall \
|
||||
inside the percentage above. Leave it alone to keep the current \
|
||||
cohort stable."
|
||||
}
|
||||
}
|
||||
div class="flex flex-col gap-2" {
|
||||
(textarea_input(
|
||||
"voice_p2p_included_user_ids",
|
||||
"Always-on User IDs",
|
||||
"1500000000000000001\n1500000000000000002",
|
||||
&included_user_ids,
|
||||
4,
|
||||
false,
|
||||
))
|
||||
(entry_count_hint(
|
||||
voice_p2p.included_user_ids.len(),
|
||||
EXPERIMENT_MAX_TARGETED_USERS,
|
||||
))
|
||||
p class="text-xs text-neutral-500" {
|
||||
"One snowflake per line, or comma separated. These users are targeted \
|
||||
regardless of the percentage above. IDs must contain 1 to 20 decimal \
|
||||
digits. Invalid entries prevent the save. Blank entries and duplicate \
|
||||
IDs are ignored."
|
||||
}
|
||||
}
|
||||
div class="flex flex-col gap-2" {
|
||||
(checkbox(
|
||||
"voice_p2p_include_premium_users",
|
||||
"true",
|
||||
"Include premium users",
|
||||
voice_p2p.include_premium_users,
|
||||
true,
|
||||
))
|
||||
p class="text-xs text-neutral-500" {
|
||||
"Includes every account with active premium perks, regardless of the \
|
||||
percentage above. The never-on list still wins."
|
||||
}
|
||||
}
|
||||
div class="flex flex-col gap-2" {
|
||||
(textarea_input(
|
||||
"voice_p2p_included_guild_ids",
|
||||
"Always-on Guild IDs",
|
||||
"1500000000000000005\n1500000000000000006",
|
||||
&voice_p2p.included_guild_ids.join("\n"),
|
||||
4,
|
||||
false,
|
||||
))
|
||||
(entry_count_hint(
|
||||
voice_p2p.included_guild_ids.len(),
|
||||
EXPERIMENT_MAX_TARGETED_USERS,
|
||||
))
|
||||
p class="text-xs text-neutral-500" {
|
||||
"Same format, with guild IDs. Every member of a listed guild is \
|
||||
included regardless of the percentage above, unless the user is \
|
||||
in the never-on list."
|
||||
}
|
||||
}
|
||||
div class="flex flex-col gap-2" {
|
||||
(textarea_input(
|
||||
"voice_p2p_excluded_user_ids",
|
||||
"Never-on User IDs",
|
||||
"1500000000000000003\n1500000000000000004",
|
||||
&excluded_user_ids,
|
||||
4,
|
||||
false,
|
||||
))
|
||||
(entry_count_hint(
|
||||
voice_p2p.excluded_user_ids.len(),
|
||||
EXPERIMENT_MAX_TARGETED_USERS,
|
||||
))
|
||||
p class="text-xs text-neutral-500" {
|
||||
"Same format. Exclusion wins over both the always-on list and the \
|
||||
percentage."
|
||||
}
|
||||
}
|
||||
|
||||
(form_actions(html! {
|
||||
(submit_button("Save Peer-to-Peer Voice Configuration"))
|
||||
}))
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
fn estimate_low_end_solve_seconds(cost: u32, max_counter: u32) -> f64 {
|
||||
0.75 * f64::from(cost) * f64::from(max_counter) / 1_050_000.0
|
||||
}
|
||||
@@ -2094,11 +2272,49 @@ mod tests {
|
||||
assert!(markup.contains("name=\"domain_migration_anonymous_rollout_basis_points\""));
|
||||
assert!(markup.contains("value=\"250\""));
|
||||
assert!(markup.contains("name=\"domain_migration_standalone_forwarding\""));
|
||||
assert!(markup.contains("name=\"domain_migration_rollout_country_codes\""));
|
||||
assert!(markup.contains("1 of 1000 stored"));
|
||||
assert!(markup.contains("2 of 1000 stored"));
|
||||
assert!(!markup.contains("at the cap"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn voice_p2p_section_shows_the_rollout_and_list_counts() {
|
||||
let voice_p2p = VoiceP2pConfigResponse {
|
||||
enabled: true,
|
||||
config_version: 3,
|
||||
rollout_basis_points: 250,
|
||||
rollout_country_codes: vec!["SE".to_owned(), "NO".to_owned()],
|
||||
max_participants: 3,
|
||||
included_user_ids: vec!["1500000000000000001".to_owned()],
|
||||
excluded_user_ids: vec![
|
||||
"1500000000000000002".to_owned(),
|
||||
"1500000000000000003".to_owned(),
|
||||
],
|
||||
..VoiceP2pConfigResponse::default()
|
||||
};
|
||||
let markup = voice_p2p_section("/admin", "csrf", &voice_p2p).into_string();
|
||||
assert!(markup.contains("Peer-to-peer voice"));
|
||||
assert!(markup.contains("action=update_voice_p2p"));
|
||||
assert!(markup.contains("name=\"voice_p2p_enabled\""));
|
||||
assert!(markup.contains("name=\"voice_p2p_rollout_basis_points\""));
|
||||
assert!(markup.contains("value=\"250\""));
|
||||
assert!(markup.contains("name=\"voice_p2p_max_participants\""));
|
||||
assert!(markup.contains("value=\"3\""));
|
||||
assert!(markup.contains("min=\"2\""));
|
||||
assert!(markup.contains("max=\"4\""));
|
||||
assert!(markup.contains("name=\"voice_p2p_include_premium_users\""));
|
||||
assert!(markup.contains("name=\"voice_p2p_included_guild_ids\""));
|
||||
assert!(markup.contains("name=\"voice_p2p_rollout_country_codes\""));
|
||||
assert!(markup.contains("value=\"SE, NO\""));
|
||||
assert!(markup.contains("2 of 250 stored"));
|
||||
assert!(markup.contains("Config version 3"));
|
||||
assert!(markup.contains("1 of 1000 stored"));
|
||||
assert!(markup.contains("2 of 1000 stored"));
|
||||
assert!(!markup.contains("anonymous_rollout_basis_points"));
|
||||
assert!(!markup.contains("standalone_forwarding"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn push_relay_section_shows_the_consent_toggle() {
|
||||
let accepted = PushRelayConfigResponse {
|
||||
|
||||
@@ -403,6 +403,7 @@ fn deserialize_instance_config_response_with_unknown_keys() {
|
||||
"enabled": true,
|
||||
"config_version": 2,
|
||||
"rollout_basis_points": 2500,
|
||||
"rollout_country_codes": ["SE"],
|
||||
"rollout_salt": "domain-migration-v1",
|
||||
"included_user_ids": ["1500000000000000001"],
|
||||
"excluded_user_ids": [],
|
||||
@@ -416,6 +417,19 @@ fn deserialize_instance_config_response_with_unknown_keys() {
|
||||
"anonymous_rollout_basis_points": 100,
|
||||
"standalone_forwarding": true
|
||||
},
|
||||
"voice_p2p": {
|
||||
"enabled": true,
|
||||
"config_version": 4,
|
||||
"rollout_basis_points": 750,
|
||||
"rollout_country_codes": ["SE", "NO"],
|
||||
"rollout_salt": "voice-p2p-v1",
|
||||
"included_user_ids": ["1500000000000000001"],
|
||||
"excluded_user_ids": ["1500000000000000002"],
|
||||
"included_guild_ids": ["1500000000000000005"],
|
||||
"include_premium_users": true,
|
||||
"max_participants": 3,
|
||||
"future_voice_p2p_knob": true
|
||||
},
|
||||
"captcha": {
|
||||
"enabled": true,
|
||||
"cost": 5000,
|
||||
@@ -569,10 +583,21 @@ fn deserialize_instance_config_response_with_unknown_keys() {
|
||||
assert!(resp.domain_migration.enabled);
|
||||
assert_eq!(resp.domain_migration.config_version, 2);
|
||||
assert_eq!(resp.domain_migration.rollout_basis_points, 2500);
|
||||
assert_eq!(resp.domain_migration.rollout_country_codes.len(), 1);
|
||||
assert_eq!(*resp.domain_migration.rollout_salt, "domain-migration-v1");
|
||||
assert_eq!(resp.domain_migration.included_user_ids.len(), 1);
|
||||
assert_eq!(resp.domain_migration.anonymous_rollout_basis_points, 100);
|
||||
assert!(resp.domain_migration.standalone_forwarding);
|
||||
assert!(resp.voice_p2p.enabled);
|
||||
assert_eq!(resp.voice_p2p.config_version, 4);
|
||||
assert_eq!(resp.voice_p2p.rollout_basis_points, 750);
|
||||
assert_eq!(resp.voice_p2p.rollout_country_codes.len(), 2);
|
||||
assert_eq!(*resp.voice_p2p.rollout_salt, "voice-p2p-v1");
|
||||
assert_eq!(resp.voice_p2p.included_user_ids.len(), 1);
|
||||
assert_eq!(resp.voice_p2p.excluded_user_ids.len(), 1);
|
||||
assert_eq!(resp.voice_p2p.included_guild_ids.len(), 1);
|
||||
assert!(resp.voice_p2p.include_premium_users);
|
||||
assert_eq!(resp.voice_p2p.max_participants, 3);
|
||||
assert!(resp.push_relay.relay_consent_accepted);
|
||||
assert!(resp.captcha.enabled);
|
||||
assert_eq!(resp.captcha.max_counter, 1000);
|
||||
|
||||
@@ -1391,6 +1391,7 @@ async fn mutating_admin_pages_render_usable_csrf_tokens() {
|
||||
"/instance-config?action=update_gateway_rollout",
|
||||
"/instance-config?action=update_sso",
|
||||
"/instance-config?action=update_domain_migration",
|
||||
"/instance-config?action=update_voice_p2p",
|
||||
"/instance-config?action=update_experiment_delivery",
|
||||
][..],
|
||||
),
|
||||
@@ -2424,6 +2425,16 @@ fn instance_config() -> Value {
|
||||
"anonymous_rollout_basis_points": 0,
|
||||
"standalone_forwarding": false
|
||||
},
|
||||
"voice_p2p": {
|
||||
"enabled": false,
|
||||
"config_version": 0,
|
||||
"rollout_basis_points": 0,
|
||||
"rollout_country_codes": [],
|
||||
"rollout_salt": "voice-p2p-v1",
|
||||
"included_user_ids": [],
|
||||
"excluded_user_ids": [],
|
||||
"max_participants": 2
|
||||
},
|
||||
"experiment_delivery": {
|
||||
"poll_interval_seconds": 300,
|
||||
"poll_jitter_percent": 15
|
||||
|
||||
@@ -318,6 +318,7 @@ export function buildAPIConfigFromMaster(master: MasterConfig): APIConfig {
|
||||
url: master.integrations.voice.url,
|
||||
internalUrl: master.integrations.voice.internal_url,
|
||||
defaultRegion: master.integrations.voice.default_region,
|
||||
p2pStunUrls: master.integrations.voice.p2p_stun_urls,
|
||||
},
|
||||
stripe: {
|
||||
enabled: master.integrations.stripe.enabled,
|
||||
|
||||
@@ -338,6 +338,10 @@ import {
|
||||
type UserSettingsRow,
|
||||
type UsersPendingDeletionRow,
|
||||
} from '@app/api/database/types/UserTypes';
|
||||
import {
|
||||
VOICE_P2P_CONNECTION_REPORT_COLUMNS,
|
||||
type VoiceP2pConnectionReportRow,
|
||||
} from '@app/api/database/types/VoiceTypes';
|
||||
import {ATTACHMENT_DECAY_COLUMNS, type AttachmentDecayRow} from '@app/api/types/AttachmentDecayTypes';
|
||||
import {seconds} from 'itty-time';
|
||||
|
||||
@@ -1474,3 +1478,9 @@ export const BillingActionIntents = defineTable<BillingActionIntentRow, 'intent_
|
||||
columns: BILLING_ACTION_INTENT_COLUMNS,
|
||||
primaryKey: ['intent_id'],
|
||||
});
|
||||
export const VoiceP2pConnectionReports = defineTable<VoiceP2pConnectionReportRow, 'user_id' | 'report_id'>({
|
||||
name: 'voice_p2p_connection_reports',
|
||||
columns: VOICE_P2P_CONNECTION_REPORT_COLUMNS,
|
||||
primaryKey: ['user_id', 'report_id'],
|
||||
defaultTtlSeconds: seconds('180 days'),
|
||||
});
|
||||
@@ -38,6 +38,7 @@ import {
|
||||
import {DomainMigrationConfigSchema} from '@fluxer/schema/src/domains/admin/DomainMigrationSchemas';
|
||||
import {GatewayRolloutConfigSchema} from '@fluxer/schema/src/domains/admin/GatewayRolloutSchemas';
|
||||
import type {PushRelayConfig, PushRelayConfigUpdateRequest} from '@fluxer/schema/src/domains/admin/PushRelaySchemas';
|
||||
import {VoiceP2pConfigSchema} from '@fluxer/schema/src/domains/admin/VoiceP2pSchemas';
|
||||
import {UserIdParam} from '@fluxer/schema/src/domains/common/CommonParamSchemas';
|
||||
import {ExperimentDeliveryConfigSchema} from '@fluxer/schema/src/domains/experiment/ExperimentSchemas';
|
||||
import type {InstanceBranding} from '@fluxer/schema/src/domains/instance/InstanceSchemas';
|
||||
@@ -66,6 +67,7 @@ async function buildInstanceConfigResponse(): Promise<InstanceConfigResponse> {
|
||||
gatewayRollout,
|
||||
pushRelay,
|
||||
domainMigration,
|
||||
voiceP2p,
|
||||
captcha,
|
||||
experimentDelivery,
|
||||
registrationConfig,
|
||||
@@ -76,6 +78,7 @@ async function buildInstanceConfigResponse(): Promise<InstanceConfigResponse> {
|
||||
instanceConfigRepository.getGatewayRolloutConfig(),
|
||||
instanceConfigRepository.getPushRelayConfig(),
|
||||
instanceConfigRepository.getDomainMigrationConfig(),
|
||||
instanceConfigRepository.getVoiceP2pConfig(),
|
||||
instanceConfigRepository.getCaptchaConfig(),
|
||||
instanceConfigRepository.getExperimentDeliveryConfig(),
|
||||
instanceConfigRepository.getRegistrationConfig(),
|
||||
@@ -113,6 +116,7 @@ async function buildInstanceConfigResponse(): Promise<InstanceConfigResponse> {
|
||||
gateway_rollout: gatewayRollout,
|
||||
push_relay: pushRelay,
|
||||
domain_migration: domainMigration,
|
||||
voice_p2p: voiceP2p,
|
||||
captcha,
|
||||
experiment_delivery: experimentDelivery,
|
||||
registration: {
|
||||
@@ -396,6 +400,18 @@ export function InstanceConfigAdminController(app: HonoApp) {
|
||||
);
|
||||
}
|
||||
}
|
||||
if (data.voice_p2p) {
|
||||
const patch = omitUndefinedFields(data.voice_p2p);
|
||||
if (Object.keys(patch).length > 0) {
|
||||
await instanceConfigRepository.updateVoiceP2pConfig((current) =>
|
||||
VoiceP2pConfigSchema.parse({
|
||||
...current,
|
||||
...patch,
|
||||
config_version: current.config_version + 1,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
if (data.captcha) {
|
||||
const patch = omitUndefinedFields(data.captcha);
|
||||
if (Object.keys(patch).length > 0) {
|
||||
|
||||
@@ -130,6 +130,9 @@ describe('DELETE /admin/reports/:report_id', () => {
|
||||
const profileKey = `reports/${reportId}/profile/user_avatar/abc${id}`;
|
||||
await harness.storageService.uploadObject({bucket: REPORTS_BUCKET, key: attachmentKey, body: new Uint8Array([1])});
|
||||
await harness.storageService.uploadObject({bucket: REPORTS_BUCKET, key: profileKey, body: new Uint8Array([2])});
|
||||
const uploadedAt = new Date(Date.now() - ms('1 hour'));
|
||||
harness.storageService.setObjectLastModified(REPORTS_BUCKET, attachmentKey, uploadedAt);
|
||||
harness.storageService.setObjectLastModified(REPORTS_BUCKET, profileKey, uploadedAt);
|
||||
const report = await repository.createReport({
|
||||
...Object.fromEntries(IAR_SUBMISSION_COLUMNS.map((column) => [column, null])),
|
||||
report_id: id,
|
||||
|
||||
@@ -35,6 +35,7 @@ import {ThemeController} from '@app/api/theme/ThemeController';
|
||||
import type {HonoApp} from '@app/api/types/HonoEnv';
|
||||
import {UnfurlController} from '@app/api/unfurl/UnfurlController';
|
||||
import {UserController} from '@app/api/user/controllers/UserController';
|
||||
import {VoiceP2pConnectionReportController} from '@app/api/voice/VoiceP2pConnectionReportController';
|
||||
import {WebhookController} from '@app/api/webhook/WebhookController';
|
||||
|
||||
export function registerControllers(routes: HonoApp, config: APIConfig): void {
|
||||
@@ -67,6 +68,7 @@ export function registerControllers(routes: HonoApp, config: APIConfig): void {
|
||||
TestHarnessController(routes);
|
||||
}
|
||||
UserController(routes);
|
||||
VoiceP2pConnectionReportController(routes);
|
||||
StoreBillingController(routes);
|
||||
WebhookController(routes);
|
||||
OAuth2Controller(routes);
|
||||
|
||||
@@ -120,6 +120,7 @@ function serializeGuildVoiceChannel(channel: Channel, ctx: ContentWarningCtx): C
|
||||
user_limit: channel.userLimit,
|
||||
voice_connection_limit: channel.voiceConnectionLimit,
|
||||
rtc_region: channel.rtcRegion,
|
||||
rtc_p2p: channel.rtcP2p,
|
||||
...serializeContentWarningFields(channel, ctx),
|
||||
rate_limit_per_user: channel.rateLimitPerUser,
|
||||
};
|
||||
@@ -165,6 +166,7 @@ function serializeGroupDMChannel(channel: Channel): ChannelResponse {
|
||||
icon: channel.iconHash ?? null,
|
||||
owner_id: channel.ownerId ? channel.ownerId.toString() : null,
|
||||
nicks: nicknameMap.size > 0 ? nicks : undefined,
|
||||
nsfw: channel.isNsfw,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -40,6 +40,7 @@ function gdmRow(overrides: Partial<ChannelRow> = {}): ChannelRow {
|
||||
user_limit: null,
|
||||
voice_connection_limit: null,
|
||||
rtc_region: null,
|
||||
rtc_p2p: null,
|
||||
last_message_id: LAST_MESSAGE,
|
||||
last_pin_timestamp: null,
|
||||
permission_overwrites: null,
|
||||
|
||||
@@ -100,6 +100,7 @@ export function channelRowFromPrivateChannelSnapshot(row: PrivateChannelRow): Ch
|
||||
user_limit: null,
|
||||
voice_connection_limit: null,
|
||||
rtc_region: null,
|
||||
rtc_p2p: null,
|
||||
last_message_id: row.channel_last_message_id ?? null,
|
||||
last_pin_timestamp: row.channel_last_pin_timestamp ?? null,
|
||||
permission_overwrites: null,
|
||||
|
||||
@@ -11,6 +11,7 @@ import {OpenAPI} from '@app/api/middleware/ResponseTypeMiddleware';
|
||||
import {SudoModeMiddleware} from '@app/api/middleware/SudoModeMiddleware';
|
||||
import {RateLimitConfigs} from '@app/api/RateLimitConfig';
|
||||
import type {HonoApp, HonoEnv} from '@app/api/types/HonoEnv';
|
||||
import {lookupGeoip} from '@app/api/utils/IpUtils';
|
||||
import {Validator} from '@app/api/Validator';
|
||||
import {ANNOUNCEMENT_CONVERTIBLE_CHANNEL_TYPES} from '@fluxer/constants/src/ChannelConstants';
|
||||
import {TEXT_THREAD_PARENT_CHANNEL_TYPES, THREAD_ONLY_CHANNEL_TYPES} from '@fluxer/constants/src/ThreadConstants';
|
||||
@@ -192,6 +193,7 @@ export function ChannelController(app: HonoApp) {
|
||||
const requestCache = ctx.get('requestCache');
|
||||
const auditLogReason = ctx.get('auditLogReason') ?? null;
|
||||
const channelRequestService = ctx.get('channelRequestService');
|
||||
const {countryCode} = await lookupGeoip(ctx.req.raw);
|
||||
return ctx.json(
|
||||
await channelRequestService.updateChannel({
|
||||
viewer: viewerFromCtx(ctx),
|
||||
@@ -202,6 +204,7 @@ export function ChannelController(app: HonoApp) {
|
||||
requestCache,
|
||||
auditLogReason,
|
||||
typeConversion,
|
||||
countryCode,
|
||||
}),
|
||||
);
|
||||
},
|
||||
|
||||
@@ -33,7 +33,6 @@ import {
|
||||
StartThreadFromMessageRequest,
|
||||
StartThreadRequest,
|
||||
ThreadChannelResponse,
|
||||
ThreadLocationQuery,
|
||||
ThreadMemberGetQuery,
|
||||
ThreadMemberListResponse,
|
||||
ThreadMembersListQuery,
|
||||
@@ -389,7 +388,6 @@ export function ThreadController(app: HonoApp) {
|
||||
RateLimitMiddleware(RateLimitConfigs.CHANNEL_THREAD_MEMBER_PUT),
|
||||
LoginRequired,
|
||||
Validator('param', ChannelIdParam),
|
||||
Validator('query', ThreadLocationQuery),
|
||||
OpenAPI({
|
||||
operationId: 'join_thread',
|
||||
summary: 'Join a thread',
|
||||
@@ -415,7 +413,6 @@ export function ThreadController(app: HonoApp) {
|
||||
RateLimitMiddleware(RateLimitConfigs.CHANNEL_THREAD_MEMBER_DELETE),
|
||||
LoginRequired,
|
||||
Validator('param', ChannelIdParam),
|
||||
Validator('query', ThreadLocationQuery),
|
||||
OpenAPI({
|
||||
operationId: 'leave_thread',
|
||||
summary: 'Leave a thread',
|
||||
@@ -474,7 +471,6 @@ export function ThreadController(app: HonoApp) {
|
||||
LoginRequired,
|
||||
SelfThreadMemberAlias('join'),
|
||||
Validator('param', ChannelIdUserIdParam),
|
||||
Validator('query', ThreadLocationQuery),
|
||||
OpenAPI({
|
||||
operationId: 'add_thread_member',
|
||||
summary: 'Add a thread member',
|
||||
@@ -504,7 +500,6 @@ export function ThreadController(app: HonoApp) {
|
||||
LoginRequired,
|
||||
SelfThreadMemberAlias('leave'),
|
||||
Validator('param', ChannelIdUserIdParam),
|
||||
Validator('query', ThreadLocationQuery),
|
||||
OpenAPI({
|
||||
operationId: 'remove_thread_member',
|
||||
summary: 'Remove a thread member',
|
||||
|
||||
@@ -61,6 +61,7 @@ function makeChannelRow(channelId: ChannelID): ChannelRow {
|
||||
user_limit: null,
|
||||
voice_connection_limit: null,
|
||||
rtc_region: null,
|
||||
rtc_p2p: null,
|
||||
last_message_id: null,
|
||||
last_pin_timestamp: null,
|
||||
permission_overwrites: null,
|
||||
|
||||
@@ -128,6 +128,7 @@ function channelRow(channelId: ChannelID, type: number, overrides: Partial<Chann
|
||||
user_limit: null,
|
||||
voice_connection_limit: null,
|
||||
rtc_region: null,
|
||||
rtc_p2p: null,
|
||||
last_message_id: null,
|
||||
last_pin_timestamp: null,
|
||||
permission_overwrites: null,
|
||||
@@ -860,6 +861,7 @@ describe('thread storage leaves control storage untouched', () => {
|
||||
'user_limit',
|
||||
'voice_connection_limit',
|
||||
'rtc_region',
|
||||
'rtc_p2p',
|
||||
'last_message_id',
|
||||
'last_pin_timestamp',
|
||||
'permission_overwrites',
|
||||
|
||||
@@ -149,6 +149,7 @@ export class ChannelDataService {
|
||||
requestCache,
|
||||
auditLogReason,
|
||||
typeConversion,
|
||||
countryCode,
|
||||
}: {
|
||||
userId: UserID;
|
||||
viewer: ThreadViewer;
|
||||
@@ -158,6 +159,7 @@ export class ChannelDataService {
|
||||
requestCache: RequestCache;
|
||||
auditLogReason: string | null;
|
||||
typeConversion?: ChannelTypeConversion | null;
|
||||
countryCode?: string | null;
|
||||
}): Promise<Channel> {
|
||||
const authChannel = await this.auth.getChannelAuthenticated({userId, channelId, viewer, skipNsfwValidation: true});
|
||||
const {channel} = authChannel;
|
||||
@@ -179,6 +181,7 @@ export class ChannelDataService {
|
||||
icon: groupDmData.icon !== undefined ? groupDmData.icon : undefined,
|
||||
ownerId: groupDmData.owner_id ? createUserID(groupDmData.owner_id) : undefined,
|
||||
nicks: groupDmData.nicks,
|
||||
nsfw: groupDmData.nsfw ?? undefined,
|
||||
requestCache,
|
||||
});
|
||||
}
|
||||
@@ -226,6 +229,9 @@ export class ChannelDataService {
|
||||
if (guildChannelData.rtc_region !== undefined) {
|
||||
channelUpdateData.rtc_region = guildChannelData.rtc_region ?? null;
|
||||
}
|
||||
if (guildChannelData.rtc_p2p !== undefined) {
|
||||
channelUpdateData.rtc_p2p = guildChannelData.rtc_p2p;
|
||||
}
|
||||
if (guildChannelData.icon !== undefined) {
|
||||
channelUpdateData.icon = guildChannelData.icon ?? null;
|
||||
}
|
||||
@@ -245,6 +251,7 @@ export class ChannelDataService {
|
||||
requestCache,
|
||||
auditLogReason,
|
||||
typeConversion,
|
||||
countryCode,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -122,6 +122,7 @@ export class ChannelRequestService {
|
||||
requestCache: RequestCache;
|
||||
auditLogReason: string | null;
|
||||
typeConversion?: ChannelTypeConversion | null;
|
||||
countryCode?: string | null;
|
||||
}): Promise<ChannelResponse> {
|
||||
if (!('available_tags' in params.data) || params.data.available_tags === undefined) {
|
||||
return this.applyChannelUpdate(params);
|
||||
@@ -138,6 +139,7 @@ export class ChannelRequestService {
|
||||
requestCache: RequestCache;
|
||||
auditLogReason: string | null;
|
||||
typeConversion?: ChannelTypeConversion | null;
|
||||
countryCode?: string | null;
|
||||
}): Promise<ChannelResponse> {
|
||||
const channel = await this.channelService.channelData.editChannel({
|
||||
userId: params.userId,
|
||||
@@ -148,6 +150,7 @@ export class ChannelRequestService {
|
||||
requestCache: params.requestCache,
|
||||
auditLogReason: params.auditLogReason,
|
||||
typeConversion: params.typeConversion,
|
||||
countryCode: params.countryCode,
|
||||
});
|
||||
return this.maskedChannelResponse(channel, params);
|
||||
}
|
||||
|
||||
@@ -36,6 +36,7 @@ function buildPersonalNotesChannelRow(userId: UserID): ChannelRow {
|
||||
user_limit: null,
|
||||
voice_connection_limit: null,
|
||||
rtc_region: null,
|
||||
rtc_p2p: null,
|
||||
last_message_id: null,
|
||||
last_pin_timestamp: null,
|
||||
permission_overwrites: null,
|
||||
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
type ThreadViewer,
|
||||
viewerActive,
|
||||
} from '@app/api/experiment/ChannelThreadsGate';
|
||||
import {resolveExperimentTargeting} from '@app/api/experiment/ExperimentTargeting';
|
||||
import type {GuildAuditLogService} from '@app/api/guild/GuildAuditLogService';
|
||||
import {mapGuildToGuildResponse} from '@app/api/guild/GuildModel';
|
||||
import type {IGuildRepositoryAggregate} from '@app/api/guild/repositories/IGuildRepositoryAggregate';
|
||||
@@ -48,6 +49,7 @@ import {Logger} from '@app/api/Logger';
|
||||
import type {LimitConfigService} from '@app/api/limits/LimitConfigService';
|
||||
import {createLimitMatchContext} from '@app/api/limits/LimitMatchContextBuilder';
|
||||
import type {RequestCache} from '@app/api/middleware/RequestCacheMiddleware';
|
||||
import {getInstanceConfigRepository} from '@app/api/middleware/ServiceSingletons';
|
||||
import type {Channel} from '@app/api/models/Channel';
|
||||
import {ChannelPermissionOverwrite} from '@app/api/models/ChannelPermissionOverwrite';
|
||||
import type {ThreadState} from '@app/api/models/ThreadState';
|
||||
@@ -81,6 +83,7 @@ import {CannotExecuteOnDmError} from '@fluxer/errors/src/domains/core/CannotExec
|
||||
import {InputValidationError} from '@fluxer/errors/src/domains/core/InputValidationError';
|
||||
import {MissingPermissionsError} from '@fluxer/errors/src/domains/core/MissingPermissionsError';
|
||||
import {resolveLimit} from '@fluxer/limits/src/LimitResolver';
|
||||
import {resolveVoiceP2pAssignment} from '@fluxer/schema/src/domains/admin/VoiceP2pSchemas';
|
||||
import {ChannelNameType} from '@fluxer/schema/src/primitives/ChannelValidators';
|
||||
import type {ICacheService} from '@pkgs/cache/src/ICacheService';
|
||||
import type {IRateLimitService} from '@pkgs/rate_limit/src/IRateLimitService';
|
||||
@@ -105,6 +108,7 @@ export interface ChannelUpdateData {
|
||||
deny?: bigint;
|
||||
}> | null;
|
||||
rtc_region?: string | null;
|
||||
rtc_p2p?: boolean;
|
||||
icon?: string | null;
|
||||
owner_id?: bigint | null;
|
||||
nicks?: Record<string, string | null> | null;
|
||||
@@ -137,6 +141,21 @@ export class ChannelOperationsService {
|
||||
private snowflakeService: ISnowflakeService,
|
||||
) {}
|
||||
|
||||
private async assertVoiceP2pAssigned(userId: UserID, countryCode: string | null): Promise<void> {
|
||||
const config = await getInstanceConfigRepository().getVoiceP2pConfig();
|
||||
const user = config.enabled ? await this.userRepository.findUnique(userId) : null;
|
||||
if (
|
||||
user === null ||
|
||||
!resolveVoiceP2pAssignment(
|
||||
config,
|
||||
userId.toString(),
|
||||
await resolveExperimentTargeting(user, countryCode, [config]),
|
||||
).enabled
|
||||
) {
|
||||
throw InputValidationError.fromCode('rtc_p2p', ValidationErrorCodes.GUILD_FEATURE_NOT_TOGGLEABLE);
|
||||
}
|
||||
}
|
||||
|
||||
async getChannel({
|
||||
userId,
|
||||
viewer,
|
||||
@@ -183,6 +202,7 @@ export class ChannelOperationsService {
|
||||
auditLogReason,
|
||||
typeConversion,
|
||||
threadParent,
|
||||
countryCode,
|
||||
}: {
|
||||
userId: UserID;
|
||||
viewer: ThreadViewer;
|
||||
@@ -193,6 +213,7 @@ export class ChannelOperationsService {
|
||||
auditLogReason: string | null;
|
||||
typeConversion?: ChannelTypeConversion | null;
|
||||
threadParent?: ThreadParentSettingsInput | null;
|
||||
countryCode?: string | null;
|
||||
}): Promise<Channel> {
|
||||
const {channel, guild, checkPermission} = await this.channelAuthService.getChannelAuthenticated({
|
||||
userId,
|
||||
@@ -267,6 +288,9 @@ export class ChannelOperationsService {
|
||||
}
|
||||
}
|
||||
}
|
||||
if (data.rtc_p2p === true && !channel.rtcP2p && channel.type === ChannelTypes.GUILD_VOICE) {
|
||||
await this.assertVoiceP2pAssigned(userId, countryCode ?? null);
|
||||
}
|
||||
const previousPermissionOverwrites = channel.permissionOverwrites;
|
||||
let permissionOverwrites = channel.permissionOverwrites;
|
||||
if (data.permission_overwrites !== undefined) {
|
||||
@@ -378,6 +402,7 @@ export class ChannelOperationsService {
|
||||
data.rtc_region !== undefined && channel.type === ChannelTypes.GUILD_VOICE
|
||||
? data.rtc_region
|
||||
: channel.rtcRegion,
|
||||
rtc_p2p: data.rtc_p2p !== undefined && channel.type === ChannelTypes.GUILD_VOICE ? data.rtc_p2p : channel.rtcP2p,
|
||||
permission_overwrites: new Map(
|
||||
Array.from(permissionOverwrites.entries()).map(([targetId, overwrite]) => [
|
||||
targetId,
|
||||
|
||||
@@ -12,7 +12,9 @@ import type {ISnowflakeService} from '@app/api/infrastructure/ISnowflakeService'
|
||||
import type {RequestCache} from '@app/api/middleware/RequestCacheMiddleware';
|
||||
import type {Channel} from '@app/api/models/Channel';
|
||||
import type {IUserRepository} from '@app/api/user/IUserRepository';
|
||||
import {canUserAccessNsfwContent} from '@app/api/utils/AgeUtils';
|
||||
import {ChannelTypes, MessageTypes} from '@fluxer/constants/src/ChannelConstants';
|
||||
import {GroupDmMatureContentIneligibleError} from '@fluxer/errors/src/domains/channel/GroupDmMatureContentIneligibleError';
|
||||
import {InvalidChannelTypeError} from '@fluxer/errors/src/domains/channel/InvalidChannelTypeError';
|
||||
import {UnknownChannelError} from '@fluxer/errors/src/domains/channel/UnknownChannelError';
|
||||
import {MissingAccessError} from '@fluxer/errors/src/domains/core/MissingAccessError';
|
||||
@@ -37,6 +39,7 @@ export class GroupDmUpdateService {
|
||||
icon,
|
||||
ownerId,
|
||||
nicks,
|
||||
nsfw,
|
||||
requestCache,
|
||||
}: {
|
||||
userId: UserID;
|
||||
@@ -45,6 +48,7 @@ export class GroupDmUpdateService {
|
||||
icon?: string | null;
|
||||
ownerId?: UserID;
|
||||
nicks?: Record<string, string | null> | null;
|
||||
nsfw?: boolean;
|
||||
requestCache: RequestCache;
|
||||
}): Promise<Channel> {
|
||||
const channel = await this.channelRepository.channelData.findUnique(channelId);
|
||||
@@ -113,6 +117,20 @@ export class GroupDmUpdateService {
|
||||
updates.nicks = updatedNicknames.size > 0 ? (updatedNicknames as Map<string, string>) : null;
|
||||
}
|
||||
}
|
||||
if (nsfw !== undefined && nsfw !== channel.isNsfw) {
|
||||
if (channel.ownerId !== userId) {
|
||||
throw new MissingPermissionsError();
|
||||
}
|
||||
if (nsfw) {
|
||||
const recipients = await Promise.all(
|
||||
Array.from(channel.recipientIds).map((id) => this.userRepository.findUnique(id)),
|
||||
);
|
||||
if (recipients.some((recipient) => !recipient || !canUserAccessNsfwContent(recipient))) {
|
||||
throw new GroupDmMatureContentIneligibleError();
|
||||
}
|
||||
}
|
||||
updates.nsfw = nsfw;
|
||||
}
|
||||
let iconHash: string | null = null;
|
||||
if (icon !== undefined) {
|
||||
iconHash = await this.avatarService.uploadAvatar({
|
||||
|
||||
@@ -24,6 +24,7 @@ import type {User} from '@app/api/models/User';
|
||||
import {deleteChannelMessageSearchDocuments} from '@app/api/search/MessageSearchIndexCleanup';
|
||||
import {assertAccountNotLimited} from '@app/api/user/AccountLimit';
|
||||
import type {IUserRepository} from '@app/api/user/IUserRepository';
|
||||
import {canUserAccessNsfwContent} from '@app/api/utils/AgeUtils';
|
||||
import {UserPermissionUtils} from '@app/api/utils/UserPermissionUtils';
|
||||
import {ChannelTypes, MessageTypes} from '@fluxer/constants/src/ChannelConstants';
|
||||
import type {LimitKey} from '@fluxer/constants/src/LimitConfigMetadata';
|
||||
@@ -36,6 +37,7 @@ import {UnknownChannelError} from '@fluxer/errors/src/domains/channel/UnknownCha
|
||||
import {CannotRemoveOtherRecipientsError} from '@fluxer/errors/src/domains/core/CannotRemoveOtherRecipientsError';
|
||||
import {InputValidationError} from '@fluxer/errors/src/domains/core/InputValidationError';
|
||||
import {MissingAccessError} from '@fluxer/errors/src/domains/core/MissingAccessError';
|
||||
import {NsfwContentRequiresAgeVerificationError} from '@fluxer/errors/src/domains/moderation/NsfwContentRequiresAgeVerificationError';
|
||||
import {NotFriendsWithUserError} from '@fluxer/errors/src/domains/user/NotFriendsWithUserError';
|
||||
|
||||
export class GroupDmOperationsService {
|
||||
@@ -84,6 +86,9 @@ export class GroupDmOperationsService {
|
||||
userId,
|
||||
targetId: recipientId,
|
||||
});
|
||||
if (channel.isNsfw && !(await this.canJoinMatureGroup(recipientId))) {
|
||||
throw new MissingAccessError();
|
||||
}
|
||||
const {channel: updatedChannel} = await this.addRecipientViaInviteWithResult({
|
||||
channelId,
|
||||
recipientId,
|
||||
@@ -122,6 +127,11 @@ export class GroupDmOperationsService {
|
||||
return updatedChannel;
|
||||
}
|
||||
|
||||
private async canJoinMatureGroup(userId: UserID): Promise<boolean> {
|
||||
const user = await this.userRepository.findUnique(userId);
|
||||
return user != null && canUserAccessNsfwContent(user);
|
||||
}
|
||||
|
||||
private async assertInviterNotLimited(userId: UserID): Promise<void> {
|
||||
const user = await this.userRepository.findUnique(userId);
|
||||
if (user) assertAccountNotLimited(user);
|
||||
@@ -169,6 +179,9 @@ export class GroupDmOperationsService {
|
||||
if (channel.recipientIds.has(recipientId)) {
|
||||
return {channel, recipientAdded: false};
|
||||
}
|
||||
if (channel.isNsfw && !(await this.canJoinMatureGroup(recipientId))) {
|
||||
throw new NsfwContentRequiresAgeVerificationError();
|
||||
}
|
||||
const inviterUser = inviterId ? await this.userRepository.findUnique(inviterId) : null;
|
||||
const fallbackLimit = MAX_GROUP_DM_RECIPIENTS;
|
||||
const recipientLimit = this.resolveLimitForUser(inviterUser ?? null, 'max_group_dm_recipients', fallbackLimit);
|
||||
|
||||
@@ -9,7 +9,7 @@ import * as EmojiUtils from '@app/api/utils/EmojiUtils';
|
||||
import {ChannelTypes, GUILD_TEXT_BASED_CHANNEL_TYPES} from '@fluxer/constants/src/ChannelConstants';
|
||||
import {GuildExplicitContentFilterTypes, GuildFeatures, GuildNSFWLevel} from '@fluxer/constants/src/GuildConstants';
|
||||
import {THREAD_ONLY_CHANNEL_TYPES} from '@fluxer/constants/src/ThreadConstants';
|
||||
import {SensitiveMediaFilterLevel} from '@fluxer/constants/src/UserConstants';
|
||||
import {RelationshipTypes, SensitiveMediaFilterLevel} from '@fluxer/constants/src/UserConstants';
|
||||
import type {GuildMemberResponse} from '@fluxer/schema/src/domains/guild/GuildMemberSchemas';
|
||||
import type {GuildResponse} from '@fluxer/schema/src/domains/guild/GuildResponseSchemas';
|
||||
|
||||
@@ -40,6 +40,30 @@ export class MessageContentService {
|
||||
});
|
||||
}
|
||||
|
||||
async resolveDmNsfwContext(channel: Channel, senderId: UserID): Promise<DmNsfwContext | undefined> {
|
||||
if (channel.type !== ChannelTypes.DM) {
|
||||
return undefined;
|
||||
}
|
||||
const recipientIds = Array.from(channel.recipientIds).filter((id) => id !== senderId);
|
||||
if (recipientIds.length !== 1) {
|
||||
return undefined;
|
||||
}
|
||||
const recipientId = recipientIds[0];
|
||||
const [senderSettings, recipientSettings, friendship] = await Promise.all([
|
||||
this.userRepository.findSettings(senderId),
|
||||
this.userRepository.findSettings(recipientId),
|
||||
this.userRepository.getRelationship(senderId, recipientId, RelationshipTypes.FRIEND),
|
||||
]);
|
||||
const areFriends = friendship != null;
|
||||
const senderFilterLevel = areFriends
|
||||
? (senderSettings?.sensitiveContentFriendDmFilter ?? SensitiveMediaFilterLevel.SHOW)
|
||||
: (senderSettings?.sensitiveContentNonFriendDmFilter ?? SensitiveMediaFilterLevel.BLOCK);
|
||||
const recipientFilterLevel = areFriends
|
||||
? (recipientSettings?.sensitiveContentFriendDmFilter ?? SensitiveMediaFilterLevel.SHOW)
|
||||
: (recipientSettings?.sensitiveContentNonFriendDmFilter ?? SensitiveMediaFilterLevel.BLOCK);
|
||||
return {senderFilterLevel, recipientFilterLevel};
|
||||
}
|
||||
|
||||
isNSFWContentAllowed(params: {
|
||||
channel?: Channel;
|
||||
guild?: GuildResponse | null;
|
||||
@@ -61,6 +85,9 @@ export class MessageContentService {
|
||||
if (channel?.type === ChannelTypes.DM_PERSONAL_NOTES) {
|
||||
return true;
|
||||
}
|
||||
if (channel?.type === ChannelTypes.GROUP_DM && channel.isNsfw) {
|
||||
return true;
|
||||
}
|
||||
if (!guild) {
|
||||
if (dmNsfwContext) {
|
||||
const eitherBlocks =
|
||||
|
||||
@@ -175,6 +175,7 @@ export class MessageEditService {
|
||||
});
|
||||
}
|
||||
const isBugHunterBot = !!user?.isBot && (user.flags & UserFlags.BUG_HUNTER) !== 0n;
|
||||
const dmNsfwContext = guild ? undefined : await this.deps.persistenceService.resolveDmNsfwContext(channel, userId);
|
||||
const updateResult = await this.deps.messageWriteLock.withFreshMessage(channelId, messageId, async (fresh) => {
|
||||
if (!fresh) throw new UnknownMessageError();
|
||||
return this.deps.crosspostPropagation.withPublishedEditBudget({fresh, actor: 'author'}, () =>
|
||||
@@ -190,6 +191,7 @@ export class MessageEditService {
|
||||
isBot: user?.isBot,
|
||||
isBugHunterBot,
|
||||
locale: user?.locale,
|
||||
dmNsfwContext,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -158,6 +158,10 @@ export class MessagePersistenceService {
|
||||
return resolveNsfwScopeChannel(channel, (channelId) => this.channelRepository.channelData.findUnique(channelId));
|
||||
}
|
||||
|
||||
resolveDmNsfwContext(channel: Channel, senderId: UserID): Promise<DmNsfwContext | undefined> {
|
||||
return this.contentService.resolveDmNsfwContext(channel, senderId);
|
||||
}
|
||||
|
||||
getEmbedAttachmentResolver(): MessageEmbedAttachmentResolver {
|
||||
return this.embedAttachmentResolver;
|
||||
}
|
||||
@@ -402,6 +406,7 @@ export class MessagePersistenceService {
|
||||
isBot?: boolean;
|
||||
isBugHunterBot?: boolean;
|
||||
locale?: string | null;
|
||||
dmNsfwContext?: DmNsfwContext;
|
||||
}): Promise<UpdateMessageResult> {
|
||||
const {message, messageId, data, channel, guild, member} = params;
|
||||
if (message.messageSnapshots && message.messageSnapshots.length > 0) {
|
||||
@@ -412,6 +417,7 @@ export class MessagePersistenceService {
|
||||
guild,
|
||||
member,
|
||||
isBot: params.isBot,
|
||||
dmNsfwContext: params.dmNsfwContext,
|
||||
});
|
||||
const updatedRowData = {...message.toRow()};
|
||||
let hasChanges = false;
|
||||
|
||||
@@ -44,6 +44,7 @@ function dmChannelRow(lastMessageId: MessageID | null): ChannelRow {
|
||||
user_limit: null,
|
||||
voice_connection_limit: null,
|
||||
rtc_region: null,
|
||||
rtc_p2p: null,
|
||||
last_message_id: lastMessageId,
|
||||
last_pin_timestamp: null,
|
||||
permission_overwrites: null,
|
||||
|
||||
@@ -19,7 +19,6 @@ import type {AuthenticatedChannel} from '@app/api/channel/services/Authenticated
|
||||
import type {CrosspostPropagation} from '@app/api/channel/services/message/CrosspostPropagation';
|
||||
import {emitMessageCreated} from '@app/api/channel/services/message/MessageActivity';
|
||||
import type {MessageChannelAuthService} from '@app/api/channel/services/message/MessageChannelAuthService';
|
||||
import type {DmNsfwContext} from '@app/api/channel/services/message/MessageContentService';
|
||||
import type {MessageDispatchService} from '@app/api/channel/services/message/MessageDispatchService';
|
||||
import type {MessageEmbedAttachmentResolver} from '@app/api/channel/services/message/MessageEmbedAttachmentResolver';
|
||||
import {
|
||||
@@ -69,12 +68,7 @@ import {
|
||||
} from '@fluxer/constants/src/ChannelConstants';
|
||||
import {GuildNSFWLevel, GuildOperations} from '@fluxer/constants/src/GuildConstants';
|
||||
import {threadWriteBlock} from '@fluxer/constants/src/ThreadPermissionUtils';
|
||||
import {
|
||||
DELETED_USER_ID,
|
||||
RelationshipTypes,
|
||||
SensitiveMediaFilterLevel,
|
||||
UserFlags,
|
||||
} from '@fluxer/constants/src/UserConstants';
|
||||
import {DELETED_USER_ID, UserFlags} from '@fluxer/constants/src/UserConstants';
|
||||
import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes';
|
||||
import {UnknownChannelError} from '@fluxer/errors/src/domains/channel/UnknownChannelError';
|
||||
import {UnknownMessageError} from '@fluxer/errors/src/domains/channel/UnknownMessageError';
|
||||
@@ -193,33 +187,6 @@ export class MessageSendService {
|
||||
return includeDefault ? {includeDefault} : null;
|
||||
}
|
||||
|
||||
private async buildDmNsfwContext(channel: Channel, senderId: UserID): Promise<DmNsfwContext | undefined> {
|
||||
if (channel.type === ChannelTypes.GROUP_DM || channel.type === ChannelTypes.DM_PERSONAL_NOTES) {
|
||||
return undefined;
|
||||
}
|
||||
if (channel.type !== ChannelTypes.DM) {
|
||||
return undefined;
|
||||
}
|
||||
const recipientIds = Array.from(channel.recipientIds).filter((id) => id !== senderId);
|
||||
if (recipientIds.length !== 1) {
|
||||
return undefined;
|
||||
}
|
||||
const recipientId = recipientIds[0];
|
||||
const [senderSettings, recipientSettings, friendship] = await Promise.all([
|
||||
this.deps.userRepository.findSettings(senderId),
|
||||
this.deps.userRepository.findSettings(recipientId),
|
||||
this.deps.userRepository.getRelationship(senderId, recipientId, RelationshipTypes.FRIEND),
|
||||
]);
|
||||
const areFriends = friendship != null;
|
||||
const senderFilterLevel = areFriends
|
||||
? (senderSettings?.sensitiveContentFriendDmFilter ?? SensitiveMediaFilterLevel.SHOW)
|
||||
: (senderSettings?.sensitiveContentNonFriendDmFilter ?? SensitiveMediaFilterLevel.BLOCK);
|
||||
const recipientFilterLevel = areFriends
|
||||
? (recipientSettings?.sensitiveContentFriendDmFilter ?? SensitiveMediaFilterLevel.SHOW)
|
||||
: (recipientSettings?.sensitiveContentNonFriendDmFilter ?? SensitiveMediaFilterLevel.BLOCK);
|
||||
return {senderFilterLevel, recipientFilterLevel};
|
||||
}
|
||||
|
||||
private attachmentsToProcess(attachments?: Array<AttachmentRequestData>): Array<AttachmentToProcess> | undefined {
|
||||
if (!attachments) return undefined;
|
||||
const processed = attachments.filter(
|
||||
@@ -1033,7 +1000,7 @@ export class MessageSendService {
|
||||
channelId,
|
||||
data,
|
||||
});
|
||||
const dmNsfwContext = guild ? undefined : await this.buildDmNsfwContext(channel, user.id);
|
||||
const dmNsfwContext = guild ? undefined : await this.deps.persistenceService.resolveDmNsfwContext(channel, user.id);
|
||||
const messageId = forumStarter ? channelIdToMessageId(channel.id) : await this.generateMessageId(channel);
|
||||
let mentionData: SendMentionData | undefined;
|
||||
const shouldExtractMentions =
|
||||
|
||||
@@ -453,6 +453,7 @@ export class ThreadCreationService {
|
||||
user_limit: null,
|
||||
voice_connection_limit: null,
|
||||
rtc_region: null,
|
||||
rtc_p2p: null,
|
||||
last_message_id: null,
|
||||
last_pin_timestamp: null,
|
||||
permission_overwrites: null,
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {createTestAccount, type TestAccount} from '@app/api/auth/tests/AuthTestUtils';
|
||||
import {MessageContentService} from '@app/api/channel/services/message/MessageContentService';
|
||||
import {createChannelInvite, createFriendship, createGroupDmChannel} from '@app/api/channel/tests/ChannelTestUtils';
|
||||
import {ensureSessionStarted, sendMessage} from '@app/api/message/tests/MessageTestUtils';
|
||||
import {type ApiTestHarness, createApiTestHarness} from '@app/api/test/ApiTestHarness';
|
||||
import {createBuilder} from '@app/api/test/TestRequestBuilder';
|
||||
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
|
||||
import {ChannelTypes} from '@fluxer/constants/src/ChannelConstants';
|
||||
import type {ChannelResponse} from '@fluxer/schema/src/domains/channel/ChannelSchemas';
|
||||
import {afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi} from 'vitest';
|
||||
|
||||
describe('group dm mature content', () => {
|
||||
let harness: ApiTestHarness;
|
||||
|
||||
beforeAll(async () => {
|
||||
harness = await createApiTestHarness();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await harness.reset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await harness.shutdown();
|
||||
});
|
||||
|
||||
async function setup() {
|
||||
const owner = await createTestAccount(harness);
|
||||
const member = await createTestAccount(harness);
|
||||
const other = await createTestAccount(harness);
|
||||
await createFriendship(harness, owner, member);
|
||||
await createFriendship(harness, owner, other);
|
||||
await ensureSessionStarted(harness, owner.token);
|
||||
const group = await createGroupDmChannel(harness, owner.token, [member.userId, other.userId]);
|
||||
return {owner, member, other, groupId: group.id};
|
||||
}
|
||||
|
||||
async function joinAsMinor(groupId: string, owner: TestAccount, status: number, code?: string) {
|
||||
const minor = await createTestAccount(harness, {dateOfBirth: '2012-01-01'});
|
||||
const invite = await createChannelInvite(harness, owner.token, groupId);
|
||||
await createBuilder(harness, minor.token).post(`/invites/${invite.code}`).body({}).expect(status, code).execute();
|
||||
}
|
||||
|
||||
function setNsfw(account: TestAccount, channelId: string, nsfw: boolean, status = 200, code?: string) {
|
||||
return createBuilder<ChannelResponse>(harness, account.token)
|
||||
.patch(`/channels/${channelId}`)
|
||||
.body({type: ChannelTypes.GROUP_DM, nsfw})
|
||||
.expect(status, code)
|
||||
.execute();
|
||||
}
|
||||
|
||||
it('lets the owner mark the group and allows mature media once marked', async () => {
|
||||
const s = await setup();
|
||||
const scope = vi.spyOn(MessageContentService.prototype, 'isNSFWContentAllowed');
|
||||
await sendMessage(harness, s.owner.token, s.groupId, 'before');
|
||||
expect(scope.mock.results.at(-1)?.value).toBe(false);
|
||||
const updated = await setNsfw(s.owner, s.groupId, true);
|
||||
expect(updated.nsfw).toBe(true);
|
||||
await sendMessage(harness, s.owner.token, s.groupId, 'after');
|
||||
expect(scope.mock.results.at(-1)?.value).toBe(true);
|
||||
const cleared = await setNsfw(s.owner, s.groupId, false);
|
||||
expect(cleared.nsfw).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects the toggle from a member who is not the owner', async () => {
|
||||
const s = await setup();
|
||||
await setNsfw(s.member, s.groupId, true, 403);
|
||||
});
|
||||
|
||||
it('refuses to mark a group that includes a minor', async () => {
|
||||
const s = await setup();
|
||||
await joinAsMinor(s.groupId, s.owner, 200);
|
||||
await setNsfw(s.owner, s.groupId, true, 400, APIErrorCodes.GROUP_DM_MATURE_CONTENT_INELIGIBLE);
|
||||
});
|
||||
|
||||
it('refuses to let a minor join a marked group', async () => {
|
||||
const s = await setup();
|
||||
await setNsfw(s.owner, s.groupId, true);
|
||||
await joinAsMinor(s.groupId, s.owner, 403, APIErrorCodes.NSFW_CONTENT_AGE_RESTRICTED);
|
||||
});
|
||||
});
|
||||
@@ -38,6 +38,7 @@ function channel(id: bigint, type: number, overrides: Partial<ChannelRow> = {}):
|
||||
user_limit: null,
|
||||
voice_connection_limit: null,
|
||||
rtc_region: null,
|
||||
rtc_p2p: null,
|
||||
last_message_id: null,
|
||||
last_pin_timestamp: null,
|
||||
permission_overwrites: null,
|
||||
|
||||
@@ -192,6 +192,7 @@ export interface APIConfig {
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
};
|
||||
p2pStunUrls?: Array<string>;
|
||||
};
|
||||
stripe: {
|
||||
enabled: boolean;
|
||||
|
||||
@@ -47,7 +47,6 @@ export const CONTENT_I18N_MESSAGES = {
|
||||
'report_flow.label.csam': 'Real or AI-generated images or videos of child sexual abuse',
|
||||
'report_flow.label.discovery_listing': 'Discovery listing',
|
||||
'report_flow.label.dislike': "It's just not for me",
|
||||
'report_flow.label.dsa': "Content that's illegal in the EU (Digital Services Act)",
|
||||
'report_flow.label.eating_disorder_promotion': 'Encouraging disordered eating',
|
||||
'report_flow.label.eating_disorder_promotion_profile': 'Their profile promotes eating disorders',
|
||||
'report_flow.label.email': 'Email address',
|
||||
|
||||
@@ -45,7 +45,6 @@ const CONTENT_I18N_AR_MESSAGES = defineContentI18nLocaleMessages({
|
||||
"report_flow.label.csam": "صور أو مقاطع فيديو حقيقية أو مولدة بالذكاء الاصطناعي تُظهر اعتداءً جنسيًا على الأطفال",
|
||||
"report_flow.label.discovery_listing": "قائمة الاكتشاف",
|
||||
"report_flow.label.dislike": "هذا ببساطة لا يناسبني",
|
||||
"report_flow.label.dsa": "محتوى غير قانوني في الاتحاد الأوروبي (قانون الخدمات الرقمية)",
|
||||
"report_flow.label.eating_disorder_promotion": "تشجيع سلوكيات الأكل المضطربة",
|
||||
"report_flow.label.eating_disorder_promotion_profile": "ملفهم الشخصي يروج لاضطرابات الأكل",
|
||||
"report_flow.label.email": "عنوان البريد الإلكتروني",
|
||||
|
||||
@@ -34,10 +34,6 @@
|
||||
"msgctxt": "report_flow.label.dislike",
|
||||
"msgid": "It's just not for me"
|
||||
},
|
||||
{
|
||||
"msgctxt": "report_flow.label.dsa",
|
||||
"msgid": "Content that's illegal in the EU (Digital Services Act)"
|
||||
},
|
||||
{
|
||||
"msgctxt": "report_flow.label.eating_disorder_promotion",
|
||||
"msgid": "Encouraging disordered eating"
|
||||
|
||||
@@ -45,7 +45,6 @@ const CONTENT_I18N_BG_MESSAGES = defineContentI18nLocaleMessages({
|
||||
"report_flow.label.csam": "Реални или генерирани от изкуствен интелект изображения или видеоклипове на сексуално насилие над деца",
|
||||
"report_flow.label.discovery_listing": "Списък за откриване",
|
||||
"report_flow.label.dislike": "Просто не е за мен",
|
||||
"report_flow.label.dsa": "Съдържание, което е незаконно в ЕС (Акт за цифровите услуги)",
|
||||
"report_flow.label.eating_disorder_promotion": "Насърчаване на хранителни разстройства",
|
||||
"report_flow.label.eating_disorder_promotion_profile": "Профилът им насърчава хранителни разстройства",
|
||||
"report_flow.label.email": "Имейл адрес",
|
||||
|
||||
@@ -45,7 +45,6 @@ const CONTENT_I18N_CS_MESSAGES = defineContentI18nLocaleMessages({
|
||||
"report_flow.label.csam": "Skutečné nebo pomocí AI vytvořené obrázky či videa sexuálního zneužívání dětí",
|
||||
"report_flow.label.discovery_listing": "Záznam v seznamu objevování",
|
||||
"report_flow.label.dislike": "Prostě to není pro mě",
|
||||
"report_flow.label.dsa": "Obsah, který je v EU nelegální (akt o digitálních službách)",
|
||||
"report_flow.label.eating_disorder_promotion": "Nabádání k poruchám příjmu potravy",
|
||||
"report_flow.label.eating_disorder_promotion_profile": "Jejich profil propaguje poruchy příjmu potravy",
|
||||
"report_flow.label.email": "E-mailová adresa",
|
||||
|
||||
@@ -45,7 +45,6 @@ const CONTENT_I18N_DA_MESSAGES = defineContentI18nLocaleMessages({
|
||||
"report_flow.label.csam": "Ægte eller AI-genererede billeder eller videoer af seksuelle overgreb mod børn",
|
||||
"report_flow.label.discovery_listing": "Opslag i Opdag",
|
||||
"report_flow.label.dislike": "Det er bare ikke noget for mig",
|
||||
"report_flow.label.dsa": "Indhold, der er ulovligt i EU (retsakten om digitale tjenester, DSA)",
|
||||
"report_flow.label.eating_disorder_promotion": "Opfordrer til spiseforstyrrelser",
|
||||
"report_flow.label.eating_disorder_promotion_profile": "Deres profil fremmer spiseforstyrrelser",
|
||||
"report_flow.label.email": "E-mailadresse",
|
||||
|
||||
@@ -45,7 +45,6 @@ const CONTENT_I18N_DE_MESSAGES = defineContentI18nLocaleMessages({
|
||||
"report_flow.label.csam": "Reale oder KI-generierte Bilder oder Videos von sexuellem Kindesmissbrauch",
|
||||
"report_flow.label.discovery_listing": "Eintrag in „Entdecken“",
|
||||
"report_flow.label.dislike": "Das ist einfach nichts für mich",
|
||||
"report_flow.label.dsa": "Inhalte, die in der EU illegal sind (Gesetz über digitale Dienste)",
|
||||
"report_flow.label.eating_disorder_promotion": "Zu gestörtem Essverhalten ermutigen",
|
||||
"report_flow.label.eating_disorder_promotion_profile": "Das Profil der Person fördert Essstörungen",
|
||||
"report_flow.label.email": "E-Mail-Adresse",
|
||||
|
||||
@@ -45,7 +45,6 @@ const CONTENT_I18N_EL_MESSAGES = defineContentI18nLocaleMessages({
|
||||
"report_flow.label.csam": "Πραγματικές ή δημιουργημένες με AI εικόνες ή βίντεο σεξουαλικής κακοποίησης παιδιών",
|
||||
"report_flow.label.discovery_listing": "Καταχώριση ανακάλυψης",
|
||||
"report_flow.label.dislike": "Απλά δεν μου ταιριάζει",
|
||||
"report_flow.label.dsa": "Περιεχόμενο που είναι παράνομο στην ΕΕ (πράξη για τις ψηφιακές υπηρεσίες, DSA)",
|
||||
"report_flow.label.eating_disorder_promotion": "Ενθάρρυνση διατροφικών διαταραχών",
|
||||
"report_flow.label.eating_disorder_promotion_profile": "Το προφίλ τους προωθεί διατροφικές διαταραχές",
|
||||
"report_flow.label.email": "Διεύθυνση email",
|
||||
|
||||
@@ -45,7 +45,6 @@ const CONTENT_I18N_EN_GB_MESSAGES = defineContentI18nLocaleMessages({
|
||||
"report_flow.label.csam": "Real or AI-generated images or videos of child sexual abuse",
|
||||
"report_flow.label.discovery_listing": "Discovery listing",
|
||||
"report_flow.label.dislike": "It's just not for me",
|
||||
"report_flow.label.dsa": "Content that's illegal in the EU (Digital Services Act)",
|
||||
"report_flow.label.eating_disorder_promotion": "Encouraging disordered eating",
|
||||
"report_flow.label.eating_disorder_promotion_profile": "Their profile promotes eating disorders",
|
||||
"report_flow.label.email": "Email address",
|
||||
|
||||
@@ -45,7 +45,6 @@ const CONTENT_I18N_ES_419_MESSAGES = defineContentI18nLocaleMessages({
|
||||
"report_flow.label.csam": "Imágenes o videos reales o generados por IA de abuso sexual infantil",
|
||||
"report_flow.label.discovery_listing": "Listado en Descubrir",
|
||||
"report_flow.label.dislike": "Simplemente no es para mí",
|
||||
"report_flow.label.dsa": "Contenido ilegal en la UE (Reglamento de Servicios Digitales)",
|
||||
"report_flow.label.eating_disorder_promotion": "Fomentar trastornos alimenticios",
|
||||
"report_flow.label.eating_disorder_promotion_profile": "Su perfil promueve trastornos alimenticios",
|
||||
"report_flow.label.email": "Dirección de correo electrónico",
|
||||
|
||||
@@ -45,7 +45,6 @@ const CONTENT_I18N_ES_ES_MESSAGES = defineContentI18nLocaleMessages({
|
||||
"report_flow.label.csam": "Imágenes o vídeos reales o generados por IA de abuso sexual infantil",
|
||||
"report_flow.label.discovery_listing": "Ficha en Descubrir",
|
||||
"report_flow.label.dislike": "Simplemente no es para mí",
|
||||
"report_flow.label.dsa": "Contenido ilegal en la UE (Reglamento de Servicios Digitales)",
|
||||
"report_flow.label.eating_disorder_promotion": "Fomentar trastornos de la conducta alimentaria",
|
||||
"report_flow.label.eating_disorder_promotion_profile": "Su perfil promueve trastornos de la conducta alimentaria",
|
||||
"report_flow.label.email": "Dirección de correo electrónico",
|
||||
|
||||
@@ -45,7 +45,6 @@ const CONTENT_I18N_FI_MESSAGES = defineContentI18nLocaleMessages({
|
||||
"report_flow.label.csam": "Aitoja tai tekoälyn luomia kuvia tai videoita lapsiin kohdistuvasta seksuaalisesta hyväksikäytöstä",
|
||||
"report_flow.label.discovery_listing": "Löydä-listaus",
|
||||
"report_flow.label.dislike": "Se ei vain ole minun juttuni",
|
||||
"report_flow.label.dsa": "Laiton sisältö EU:ssa (digipalvelusäädös, DSA)",
|
||||
"report_flow.label.eating_disorder_promotion": "Syömishäiriöihin kannustaminen",
|
||||
"report_flow.label.eating_disorder_promotion_profile": "Hänen profiilinsa edistää syömishäiriöitä",
|
||||
"report_flow.label.email": "Sähköpostiosoite",
|
||||
|
||||
@@ -45,7 +45,6 @@ const CONTENT_I18N_FR_MESSAGES = defineContentI18nLocaleMessages({
|
||||
"report_flow.label.csam": "Images ou vidéos réelles ou générées par IA représentant des abus sexuels sur des enfants",
|
||||
"report_flow.label.discovery_listing": "Fiche dans Découverte",
|
||||
"report_flow.label.dislike": "Ce n'est pas pour moi",
|
||||
"report_flow.label.dsa": "Contenu illégal dans l'UE (règlement sur les services numériques, DSA)",
|
||||
"report_flow.label.eating_disorder_promotion": "Encourager les troubles du comportement alimentaire",
|
||||
"report_flow.label.eating_disorder_promotion_profile": "Son profil fait la promotion des troubles du comportement alimentaire",
|
||||
"report_flow.label.email": "Adresse e-mail",
|
||||
|
||||
@@ -45,7 +45,6 @@ const CONTENT_I18N_HE_MESSAGES = defineContentI18nLocaleMessages({
|
||||
"report_flow.label.csam": "תמונות או סרטונים של פגיעה מינית בילדים, אמיתיים או שנוצרו באמצעות AI",
|
||||
"report_flow.label.discovery_listing": "רישום בגילוי",
|
||||
"report_flow.label.dislike": "זה פשוט לא בשבילי",
|
||||
"report_flow.label.dsa": "תוכן בלתי חוקי באיחוד האירופי (חוק השירותים הדיגיטליים, DSA)",
|
||||
"report_flow.label.eating_disorder_promotion": "עידוד הפרעות אכילה",
|
||||
"report_flow.label.eating_disorder_promotion_profile": "הפרופיל שלהם מקדם הפרעות אכילה",
|
||||
"report_flow.label.email": "כתובת אימייל",
|
||||
|
||||
@@ -45,7 +45,6 @@ const CONTENT_I18N_HI_MESSAGES = defineContentI18nLocaleMessages({
|
||||
"report_flow.label.csam": "बाल यौन शोषण की असली या AI से बनाई गई तस्वीरें या वीडियो",
|
||||
"report_flow.label.discovery_listing": "डिस्कवरी लिस्टिंग",
|
||||
"report_flow.label.dislike": "यह बस मेरे लिए नहीं है",
|
||||
"report_flow.label.dsa": "यूरोपीय संघ में गैर-कानूनी सामग्री (डिजिटल सेवा अधिनियम)",
|
||||
"report_flow.label.eating_disorder_promotion": "खाने संबंधी विकार वाली आदतों को बढ़ावा देना",
|
||||
"report_flow.label.eating_disorder_promotion_profile": "उनकी प्रोफ़ाइल खाने संबंधी विकारों को बढ़ावा देती है",
|
||||
"report_flow.label.email": "ईमेल पता",
|
||||
|
||||
@@ -45,7 +45,6 @@ const CONTENT_I18N_HR_MESSAGES = defineContentI18nLocaleMessages({
|
||||
"report_flow.label.csam": "Stvarne ili AI-generirane slike ili videozapisi seksualnog zlostavljanja djece",
|
||||
"report_flow.label.discovery_listing": "Unos u Otkrivanju",
|
||||
"report_flow.label.dislike": "Jednostavno mi ne odgovara",
|
||||
"report_flow.label.dsa": "Sadržaj koji je nezakonit u EU-u (Akt o digitalnim uslugama)",
|
||||
"report_flow.label.eating_disorder_promotion": "Poticanje poremećaja hranjenja",
|
||||
"report_flow.label.eating_disorder_promotion_profile": "Njihov profil promiče poremećaje hranjenja",
|
||||
"report_flow.label.email": "Adresa e-pošte",
|
||||
|
||||
@@ -45,7 +45,6 @@ const CONTENT_I18N_HU_MESSAGES = defineContentI18nLocaleMessages({
|
||||
"report_flow.label.csam": "Gyermekek szexuális bántalmazását ábrázoló valódi vagy mesterséges intelligencia által generált képek vagy videók",
|
||||
"report_flow.label.discovery_listing": "Listázás a Felfedezésben",
|
||||
"report_flow.label.dislike": "Egyszerűen nem nekem való",
|
||||
"report_flow.label.dsa": "Az EU-ban illegális tartalom (digitális szolgáltatásokról szóló rendelet, DSA)",
|
||||
"report_flow.label.eating_disorder_promotion": "Kóros étkezési szokásokra buzdítás",
|
||||
"report_flow.label.eating_disorder_promotion_profile": "A profilja evészavarokat népszerűsít",
|
||||
"report_flow.label.email": "E-mail-cím",
|
||||
|
||||
@@ -45,7 +45,6 @@ const CONTENT_I18N_ID_MESSAGES = defineContentI18nLocaleMessages({
|
||||
"report_flow.label.csam": "Gambar atau video kekerasan seksual terhadap anak, baik yang asli maupun buatan AI",
|
||||
"report_flow.label.discovery_listing": "Listing di Discovery",
|
||||
"report_flow.label.dislike": "Ini tidak cocok untuk saya",
|
||||
"report_flow.label.dsa": "Konten yang ilegal di UE (Undang-Undang Layanan Digital/DSA)",
|
||||
"report_flow.label.eating_disorder_promotion": "Mendorong perilaku makan yang terganggu",
|
||||
"report_flow.label.eating_disorder_promotion_profile": "Profil mereka mempromosikan gangguan makan",
|
||||
"report_flow.label.email": "Alamat email",
|
||||
|
||||
@@ -45,7 +45,6 @@ const CONTENT_I18N_IT_MESSAGES = defineContentI18nLocaleMessages({
|
||||
"report_flow.label.csam": "Immagini o video reali o generati da IA di abusi sessuali su minori",
|
||||
"report_flow.label.discovery_listing": "Elenco in Scopri",
|
||||
"report_flow.label.dislike": "Non fa per me",
|
||||
"report_flow.label.dsa": "Contenuti illegali nell'UE (Regolamento sui servizi digitali, DSA)",
|
||||
"report_flow.label.eating_disorder_promotion": "Incoraggiare disturbi alimentari",
|
||||
"report_flow.label.eating_disorder_promotion_profile": "Il suo profilo promuove disturbi alimentari",
|
||||
"report_flow.label.email": "Indirizzo e-mail",
|
||||
|
||||
@@ -45,7 +45,6 @@ const CONTENT_I18N_JA_MESSAGES = defineContentI18nLocaleMessages({
|
||||
"report_flow.label.csam": "児童性的虐待の画像・動画(実在のもの、またはAI生成のもの)",
|
||||
"report_flow.label.discovery_listing": "「見つける」の掲載内容",
|
||||
"report_flow.label.dislike": "自分には合わない",
|
||||
"report_flow.label.dsa": "EUで違法なコンテンツ(デジタルサービス法、DSA)",
|
||||
"report_flow.label.eating_disorder_promotion": "摂食障害的な食行動を促している",
|
||||
"report_flow.label.eating_disorder_promotion_profile": "プロフィールが摂食障害を助長している",
|
||||
"report_flow.label.email": "メールアドレス",
|
||||
|
||||
@@ -45,7 +45,6 @@ const CONTENT_I18N_KO_MESSAGES = defineContentI18nLocaleMessages({
|
||||
"report_flow.label.csam": "아동 성착취물(실제 또는 AI로 생성된 이미지 또는 동영상)",
|
||||
"report_flow.label.discovery_listing": "디스커버리 목록",
|
||||
"report_flow.label.dislike": "그냥 내 취향이 아닙니다",
|
||||
"report_flow.label.dsa": "EU에서 불법인 콘텐츠(디지털 서비스법, DSA)",
|
||||
"report_flow.label.eating_disorder_promotion": "섭식 장애 행동 조장",
|
||||
"report_flow.label.eating_disorder_promotion_profile": "프로필에서 섭식 장애를 조장합니다",
|
||||
"report_flow.label.email": "이메일 주소",
|
||||
|
||||
@@ -45,7 +45,6 @@ const CONTENT_I18N_LT_MESSAGES = defineContentI18nLocaleMessages({
|
||||
"report_flow.label.csam": "Tikri ar dirbtinio intelekto sukurti vaikų seksualinės prievartos vaizdai ar vaizdo įrašai",
|
||||
"report_flow.label.discovery_listing": "Atradimo sąrašas",
|
||||
"report_flow.label.dislike": "Tai tiesiog ne man",
|
||||
"report_flow.label.dsa": "Turinys, kuris neteisėtas ES (Skaitmeninių paslaugų aktas)",
|
||||
"report_flow.label.eating_disorder_promotion": "Sutrikusių mitybos įpročių skatinimas",
|
||||
"report_flow.label.eating_disorder_promotion_profile": "Jų profilis propaguoja valgymo sutrikimus",
|
||||
"report_flow.label.email": "El. pašto adresas",
|
||||
|
||||
@@ -45,7 +45,6 @@ const CONTENT_I18N_NL_MESSAGES = defineContentI18nLocaleMessages({
|
||||
"report_flow.label.csam": "Echte of door AI gegenereerde beelden of video's van seksueel misbruik van kinderen",
|
||||
"report_flow.label.discovery_listing": "Vermelding bij Ontdekken",
|
||||
"report_flow.label.dislike": "Het is gewoon niets voor mij",
|
||||
"report_flow.label.dsa": "Inhoud die illegaal is in de EU (digitaledienstenverordening)",
|
||||
"report_flow.label.eating_disorder_promotion": "Gestoord eetgedrag aanmoedigen",
|
||||
"report_flow.label.eating_disorder_promotion_profile": "Hun profiel promoot eetstoornissen",
|
||||
"report_flow.label.email": "E-mailadres",
|
||||
|
||||
@@ -45,7 +45,6 @@ const CONTENT_I18N_NO_MESSAGES = defineContentI18nLocaleMessages({
|
||||
"report_flow.label.csam": "Ekte eller AI-genererte bilder eller videoer av seksuelle overgrep mot barn",
|
||||
"report_flow.label.discovery_listing": "Oppføring i Oppdag",
|
||||
"report_flow.label.dislike": "Det er bare ikke noe for meg",
|
||||
"report_flow.label.dsa": "Innhold som er ulovlig i EU (forordningen om digitale tjenester, DSA)",
|
||||
"report_flow.label.eating_disorder_promotion": "Oppfordrer til spiseforstyrrelser",
|
||||
"report_flow.label.eating_disorder_promotion_profile": "Profilen deres fremmer spiseforstyrrelser",
|
||||
"report_flow.label.email": "E-postadresse",
|
||||
|
||||
@@ -45,7 +45,6 @@ const CONTENT_I18N_PL_MESSAGES = defineContentI18nLocaleMessages({
|
||||
"report_flow.label.csam": "Prawdziwe lub wygenerowane przez AI obrazy lub filmy przedstawiające wykorzystywanie seksualne dzieci",
|
||||
"report_flow.label.discovery_listing": "Wpis w sekcji Odkrywanie",
|
||||
"report_flow.label.dislike": "To po prostu nie jest dla mnie",
|
||||
"report_flow.label.dsa": "Treści, które są nielegalne w UE (akt o usługach cyfrowych)",
|
||||
"report_flow.label.eating_disorder_promotion": "Zachęcanie do zaburzeń odżywiania",
|
||||
"report_flow.label.eating_disorder_promotion_profile": "Ich profil promuje zaburzenia odżywiania",
|
||||
"report_flow.label.email": "Adres e-mail",
|
||||
|
||||
@@ -45,7 +45,6 @@ const CONTENT_I18N_PT_BR_MESSAGES = defineContentI18nLocaleMessages({
|
||||
"report_flow.label.csam": "Imagens ou vídeos reais ou gerados por IA de abuso sexual infantil",
|
||||
"report_flow.label.discovery_listing": "Listagem em Descobrir",
|
||||
"report_flow.label.dislike": "Não é para mim",
|
||||
"report_flow.label.dsa": "Conteúdo ilegal na UE (Regulamento dos Serviços Digitais)",
|
||||
"report_flow.label.eating_disorder_promotion": "Incentivando transtornos alimentares",
|
||||
"report_flow.label.eating_disorder_promotion_profile": "O perfil dele(a) promove transtornos alimentares",
|
||||
"report_flow.label.email": "Endereço de e-mail",
|
||||
|
||||
@@ -45,7 +45,6 @@ const CONTENT_I18N_RO_MESSAGES = defineContentI18nLocaleMessages({
|
||||
"report_flow.label.csam": "Imagini sau videoclipuri reale sau generate de AI cu abuz sexual asupra copiilor",
|
||||
"report_flow.label.discovery_listing": "Listarea în Discovery",
|
||||
"report_flow.label.dislike": "Pur și simplu nu este pentru mine",
|
||||
"report_flow.label.dsa": "Conținut ilegal în UE (Regulamentul privind serviciile digitale)",
|
||||
"report_flow.label.eating_disorder_promotion": "Încurajarea tulburărilor de alimentație",
|
||||
"report_flow.label.eating_disorder_promotion_profile": "Profilul său promovează tulburările de alimentație",
|
||||
"report_flow.label.email": "Adresă de e-mail",
|
||||
|
||||
@@ -45,7 +45,6 @@ const CONTENT_I18N_RU_MESSAGES = defineContentI18nLocaleMessages({
|
||||
"report_flow.label.csam": "Реальные или созданные ИИ изображения или видео сексуального насилия над детьми",
|
||||
"report_flow.label.discovery_listing": "Запись в каталоге",
|
||||
"report_flow.label.dislike": "Мне это не подходит",
|
||||
"report_flow.label.dsa": "Незаконный в ЕС контент (Закон о цифровых услугах, DSA)",
|
||||
"report_flow.label.eating_disorder_promotion": "Поощрение расстройств пищевого поведения",
|
||||
"report_flow.label.eating_disorder_promotion_profile": "Их профиль пропагандирует расстройства пищевого поведения",
|
||||
"report_flow.label.email": "Адрес электронной почты",
|
||||
|
||||
@@ -45,7 +45,6 @@ const CONTENT_I18N_SV_SE_MESSAGES = defineContentI18nLocaleMessages({
|
||||
"report_flow.label.csam": "Verkliga eller AI-genererade bilder eller videor av sexuella övergrepp mot barn",
|
||||
"report_flow.label.discovery_listing": "Listning i Upptäck",
|
||||
"report_flow.label.dislike": "Det är bara inget för mig",
|
||||
"report_flow.label.dsa": "Innehåll som är olagligt i EU (förordningen om digitala tjänster)",
|
||||
"report_flow.label.eating_disorder_promotion": "Uppmuntrar till ätstörningar",
|
||||
"report_flow.label.eating_disorder_promotion_profile": "Deras profil främjar ätstörningar",
|
||||
"report_flow.label.email": "E-postadress",
|
||||
|
||||
@@ -45,7 +45,6 @@ const CONTENT_I18N_TH_MESSAGES = defineContentI18nLocaleMessages({
|
||||
"report_flow.label.csam": "ภาพหรือวิดีโอการล่วงละเมิดทางเพศเด็ก ไม่ว่าจะเป็นของจริงหรือสร้างโดย AI",
|
||||
"report_flow.label.discovery_listing": "รายการใน Discovery",
|
||||
"report_flow.label.dislike": "ไม่เหมาะกับฉัน",
|
||||
"report_flow.label.dsa": "เนื้อหาที่ผิดกฎหมายในสหภาพยุโรป (กฎหมายบริการดิจิทัล หรือ DSA)",
|
||||
"report_flow.label.eating_disorder_promotion": "ส่งเสริมพฤติกรรมการกินที่ผิดปกติ",
|
||||
"report_flow.label.eating_disorder_promotion_profile": "โปรไฟล์ของพวกเขาส่งเสริมความผิดปกติในการกิน",
|
||||
"report_flow.label.email": "ที่อยู่อีเมล",
|
||||
|
||||
@@ -45,7 +45,6 @@ const CONTENT_I18N_TR_MESSAGES = defineContentI18nLocaleMessages({
|
||||
"report_flow.label.csam": "Çocuk cinsel istismarına ait gerçek veya yapay zeka ile üretilmiş görseller ya da videolar",
|
||||
"report_flow.label.discovery_listing": "Keşfet listesi",
|
||||
"report_flow.label.dislike": "Bana göre değil",
|
||||
"report_flow.label.dsa": "AB'de yasa dışı içerik (Dijital Hizmetler Yasası)",
|
||||
"report_flow.label.eating_disorder_promotion": "Düzensiz yeme davranışlarını teşvik etme",
|
||||
"report_flow.label.eating_disorder_promotion_profile": "Profili yeme bozukluklarını teşvik ediyor",
|
||||
"report_flow.label.email": "E-posta adresi",
|
||||
|
||||
@@ -45,7 +45,6 @@ const CONTENT_I18N_UK_MESSAGES = defineContentI18nLocaleMessages({
|
||||
"report_flow.label.csam": "Реальні або згенеровані ШІ зображення чи відео сексуального насильства над дітьми",
|
||||
"report_flow.label.discovery_listing": "Запис у Discovery",
|
||||
"report_flow.label.dislike": "Мені це просто не підходить",
|
||||
"report_flow.label.dsa": "Вміст, незаконний в ЄС (Акт про цифрові послуги)",
|
||||
"report_flow.label.eating_disorder_promotion": "Заохочення розладів харчової поведінки",
|
||||
"report_flow.label.eating_disorder_promotion_profile": "Їхній профіль пропагує розлади харчової поведінки",
|
||||
"report_flow.label.email": "Адреса електронної пошти",
|
||||
|
||||
@@ -45,7 +45,6 @@ const CONTENT_I18N_VI_MESSAGES = defineContentI18nLocaleMessages({
|
||||
"report_flow.label.csam": "Ảnh hoặc video lạm dụng tình dục trẻ em, dù là thật hay do AI tạo ra",
|
||||
"report_flow.label.discovery_listing": "Danh sách khám phá",
|
||||
"report_flow.label.dislike": "Không hợp với tôi",
|
||||
"report_flow.label.dsa": "Nội dung bất hợp pháp tại EU (Đạo luật Dịch vụ Kỹ thuật số)",
|
||||
"report_flow.label.eating_disorder_promotion": "Khuyến khích chứng rối loạn ăn uống",
|
||||
"report_flow.label.eating_disorder_promotion_profile": "Hồ sơ của họ quảng bá chứng rối loạn ăn uống",
|
||||
"report_flow.label.email": "Địa chỉ email",
|
||||
|
||||
@@ -45,7 +45,6 @@ const CONTENT_I18N_ZH_CN_MESSAGES = defineContentI18nLocaleMessages({
|
||||
"report_flow.label.csam": "真实或 AI 生成的儿童性虐待图像或视频",
|
||||
"report_flow.label.discovery_listing": "社区发现列表",
|
||||
"report_flow.label.dislike": "我就是不喜欢",
|
||||
"report_flow.label.dsa": "在欧盟属违法的内容(《数字服务法》)",
|
||||
"report_flow.label.eating_disorder_promotion": "鼓励进食障碍行为",
|
||||
"report_flow.label.eating_disorder_promotion_profile": "其个人资料宣传进食障碍",
|
||||
"report_flow.label.email": "电子邮件地址",
|
||||
|
||||
@@ -45,7 +45,6 @@ const CONTENT_I18N_ZH_TW_MESSAGES = defineContentI18nLocaleMessages({
|
||||
"report_flow.label.csam": "兒童性虐待的真實或 AI 生成圖片或影片",
|
||||
"report_flow.label.discovery_listing": "探索列表",
|
||||
"report_flow.label.dislike": "對我來說不太適合",
|
||||
"report_flow.label.dsa": "在歐盟屬違法的內容(《數位服務法》)",
|
||||
"report_flow.label.eating_disorder_promotion": "鼓勵飲食失調",
|
||||
"report_flow.label.eating_disorder_promotion_profile": "他們的個人檔案宣傳飲食失調",
|
||||
"report_flow.label.email": "電子郵件地址",
|
||||
|
||||
@@ -41,7 +41,6 @@
|
||||
"report_flow.label.csam": "صور أو مقاطع فيديو حقيقية أو مولدة بالذكاء الاصطناعي تُظهر اعتداءً جنسيًا على الأطفال",
|
||||
"report_flow.label.discovery_listing": "قائمة الاكتشاف",
|
||||
"report_flow.label.dislike": "هذا ببساطة لا يناسبني",
|
||||
"report_flow.label.dsa": "محتوى غير قانوني في الاتحاد الأوروبي (قانون الخدمات الرقمية)",
|
||||
"report_flow.label.eating_disorder_promotion": "تشجيع سلوكيات الأكل المضطربة",
|
||||
"report_flow.label.eating_disorder_promotion_profile": "ملفهم الشخصي يروج لاضطرابات الأكل",
|
||||
"report_flow.label.email": "عنوان البريد الإلكتروني",
|
||||
|
||||
@@ -41,7 +41,6 @@
|
||||
"report_flow.label.csam": "Реални или генерирани от изкуствен интелект изображения или видеоклипове на сексуално насилие над деца",
|
||||
"report_flow.label.discovery_listing": "Списък за откриване",
|
||||
"report_flow.label.dislike": "Просто не е за мен",
|
||||
"report_flow.label.dsa": "Съдържание, което е незаконно в ЕС (Акт за цифровите услуги)",
|
||||
"report_flow.label.eating_disorder_promotion": "Насърчаване на хранителни разстройства",
|
||||
"report_flow.label.eating_disorder_promotion_profile": "Профилът им насърчава хранителни разстройства",
|
||||
"report_flow.label.email": "Имейл адрес",
|
||||
|
||||
@@ -41,7 +41,6 @@
|
||||
"report_flow.label.csam": "Skutečné nebo pomocí AI vytvořené obrázky či videa sexuálního zneužívání dětí",
|
||||
"report_flow.label.discovery_listing": "Záznam v seznamu objevování",
|
||||
"report_flow.label.dislike": "Prostě to není pro mě",
|
||||
"report_flow.label.dsa": "Obsah, který je v EU nelegální (akt o digitálních službách)",
|
||||
"report_flow.label.eating_disorder_promotion": "Nabádání k poruchám příjmu potravy",
|
||||
"report_flow.label.eating_disorder_promotion_profile": "Jejich profil propaguje poruchy příjmu potravy",
|
||||
"report_flow.label.email": "E-mailová adresa",
|
||||
|
||||
@@ -41,7 +41,6 @@
|
||||
"report_flow.label.csam": "Ægte eller AI-genererede billeder eller videoer af seksuelle overgreb mod børn",
|
||||
"report_flow.label.discovery_listing": "Opslag i Opdag",
|
||||
"report_flow.label.dislike": "Det er bare ikke noget for mig",
|
||||
"report_flow.label.dsa": "Indhold, der er ulovligt i EU (retsakten om digitale tjenester, DSA)",
|
||||
"report_flow.label.eating_disorder_promotion": "Opfordrer til spiseforstyrrelser",
|
||||
"report_flow.label.eating_disorder_promotion_profile": "Deres profil fremmer spiseforstyrrelser",
|
||||
"report_flow.label.email": "E-mailadresse",
|
||||
|
||||
@@ -41,7 +41,6 @@
|
||||
"report_flow.label.csam": "Reale oder KI-generierte Bilder oder Videos von sexuellem Kindesmissbrauch",
|
||||
"report_flow.label.discovery_listing": "Eintrag in „Entdecken“",
|
||||
"report_flow.label.dislike": "Das ist einfach nichts für mich",
|
||||
"report_flow.label.dsa": "Inhalte, die in der EU illegal sind (Gesetz über digitale Dienste)",
|
||||
"report_flow.label.eating_disorder_promotion": "Zu gestörtem Essverhalten ermutigen",
|
||||
"report_flow.label.eating_disorder_promotion_profile": "Das Profil der Person fördert Essstörungen",
|
||||
"report_flow.label.email": "E-Mail-Adresse",
|
||||
|
||||
@@ -41,7 +41,6 @@
|
||||
"report_flow.label.csam": "Πραγματικές ή δημιουργημένες με AI εικόνες ή βίντεο σεξουαλικής κακοποίησης παιδιών",
|
||||
"report_flow.label.discovery_listing": "Καταχώριση ανακάλυψης",
|
||||
"report_flow.label.dislike": "Απλά δεν μου ταιριάζει",
|
||||
"report_flow.label.dsa": "Περιεχόμενο που είναι παράνομο στην ΕΕ (πράξη για τις ψηφιακές υπηρεσίες, DSA)",
|
||||
"report_flow.label.eating_disorder_promotion": "Ενθάρρυνση διατροφικών διαταραχών",
|
||||
"report_flow.label.eating_disorder_promotion_profile": "Το προφίλ τους προωθεί διατροφικές διαταραχές",
|
||||
"report_flow.label.email": "Διεύθυνση email",
|
||||
|
||||
@@ -41,7 +41,6 @@
|
||||
"report_flow.label.csam": "Real or AI-generated images or videos of child sexual abuse",
|
||||
"report_flow.label.discovery_listing": "Discovery listing",
|
||||
"report_flow.label.dislike": "It's just not for me",
|
||||
"report_flow.label.dsa": "Content that's illegal in the EU (Digital Services Act)",
|
||||
"report_flow.label.eating_disorder_promotion": "Encouraging disordered eating",
|
||||
"report_flow.label.eating_disorder_promotion_profile": "Their profile promotes eating disorders",
|
||||
"report_flow.label.email": "Email address",
|
||||
|
||||
@@ -41,7 +41,6 @@
|
||||
"report_flow.label.csam": "Imágenes o videos reales o generados por IA de abuso sexual infantil",
|
||||
"report_flow.label.discovery_listing": "Listado en Descubrir",
|
||||
"report_flow.label.dislike": "Simplemente no es para mí",
|
||||
"report_flow.label.dsa": "Contenido ilegal en la UE (Reglamento de Servicios Digitales)",
|
||||
"report_flow.label.eating_disorder_promotion": "Fomentar trastornos alimenticios",
|
||||
"report_flow.label.eating_disorder_promotion_profile": "Su perfil promueve trastornos alimenticios",
|
||||
"report_flow.label.email": "Dirección de correo electrónico",
|
||||
|
||||
@@ -41,7 +41,6 @@
|
||||
"report_flow.label.csam": "Imágenes o vídeos reales o generados por IA de abuso sexual infantil",
|
||||
"report_flow.label.discovery_listing": "Ficha en Descubrir",
|
||||
"report_flow.label.dislike": "Simplemente no es para mí",
|
||||
"report_flow.label.dsa": "Contenido ilegal en la UE (Reglamento de Servicios Digitales)",
|
||||
"report_flow.label.eating_disorder_promotion": "Fomentar trastornos de la conducta alimentaria",
|
||||
"report_flow.label.eating_disorder_promotion_profile": "Su perfil promueve trastornos de la conducta alimentaria",
|
||||
"report_flow.label.email": "Dirección de correo electrónico",
|
||||
|
||||
@@ -41,7 +41,6 @@
|
||||
"report_flow.label.csam": "Aitoja tai tekoälyn luomia kuvia tai videoita lapsiin kohdistuvasta seksuaalisesta hyväksikäytöstä",
|
||||
"report_flow.label.discovery_listing": "Löydä-listaus",
|
||||
"report_flow.label.dislike": "Se ei vain ole minun juttuni",
|
||||
"report_flow.label.dsa": "Laiton sisältö EU:ssa (digipalvelusäädös, DSA)",
|
||||
"report_flow.label.eating_disorder_promotion": "Syömishäiriöihin kannustaminen",
|
||||
"report_flow.label.eating_disorder_promotion_profile": "Hänen profiilinsa edistää syömishäiriöitä",
|
||||
"report_flow.label.email": "Sähköpostiosoite",
|
||||
|
||||
@@ -41,7 +41,6 @@
|
||||
"report_flow.label.csam": "Images ou vidéos réelles ou générées par IA représentant des abus sexuels sur des enfants",
|
||||
"report_flow.label.discovery_listing": "Fiche dans Découverte",
|
||||
"report_flow.label.dislike": "Ce n'est pas pour moi",
|
||||
"report_flow.label.dsa": "Contenu illégal dans l'UE (règlement sur les services numériques, DSA)",
|
||||
"report_flow.label.eating_disorder_promotion": "Encourager les troubles du comportement alimentaire",
|
||||
"report_flow.label.eating_disorder_promotion_profile": "Son profil fait la promotion des troubles du comportement alimentaire",
|
||||
"report_flow.label.email": "Adresse e-mail",
|
||||
|
||||
@@ -41,7 +41,6 @@
|
||||
"report_flow.label.csam": "תמונות או סרטונים של פגיעה מינית בילדים, אמיתיים או שנוצרו באמצעות AI",
|
||||
"report_flow.label.discovery_listing": "רישום בגילוי",
|
||||
"report_flow.label.dislike": "זה פשוט לא בשבילי",
|
||||
"report_flow.label.dsa": "תוכן בלתי חוקי באיחוד האירופי (חוק השירותים הדיגיטליים, DSA)",
|
||||
"report_flow.label.eating_disorder_promotion": "עידוד הפרעות אכילה",
|
||||
"report_flow.label.eating_disorder_promotion_profile": "הפרופיל שלהם מקדם הפרעות אכילה",
|
||||
"report_flow.label.email": "כתובת אימייל",
|
||||
|
||||
@@ -41,7 +41,6 @@
|
||||
"report_flow.label.csam": "बाल यौन शोषण की असली या AI से बनाई गई तस्वीरें या वीडियो",
|
||||
"report_flow.label.discovery_listing": "डिस्कवरी लिस्टिंग",
|
||||
"report_flow.label.dislike": "यह बस मेरे लिए नहीं है",
|
||||
"report_flow.label.dsa": "यूरोपीय संघ में गैर-कानूनी सामग्री (डिजिटल सेवा अधिनियम)",
|
||||
"report_flow.label.eating_disorder_promotion": "खाने संबंधी विकार वाली आदतों को बढ़ावा देना",
|
||||
"report_flow.label.eating_disorder_promotion_profile": "उनकी प्रोफ़ाइल खाने संबंधी विकारों को बढ़ावा देती है",
|
||||
"report_flow.label.email": "ईमेल पता",
|
||||
|
||||
@@ -41,7 +41,6 @@
|
||||
"report_flow.label.csam": "Stvarne ili AI-generirane slike ili videozapisi seksualnog zlostavljanja djece",
|
||||
"report_flow.label.discovery_listing": "Unos u Otkrivanju",
|
||||
"report_flow.label.dislike": "Jednostavno mi ne odgovara",
|
||||
"report_flow.label.dsa": "Sadržaj koji je nezakonit u EU-u (Akt o digitalnim uslugama)",
|
||||
"report_flow.label.eating_disorder_promotion": "Poticanje poremećaja hranjenja",
|
||||
"report_flow.label.eating_disorder_promotion_profile": "Njihov profil promiče poremećaje hranjenja",
|
||||
"report_flow.label.email": "Adresa e-pošte",
|
||||
|
||||
@@ -41,7 +41,6 @@
|
||||
"report_flow.label.csam": "Gyermekek szexuális bántalmazását ábrázoló valódi vagy mesterséges intelligencia által generált képek vagy videók",
|
||||
"report_flow.label.discovery_listing": "Listázás a Felfedezésben",
|
||||
"report_flow.label.dislike": "Egyszerűen nem nekem való",
|
||||
"report_flow.label.dsa": "Az EU-ban illegális tartalom (digitális szolgáltatásokról szóló rendelet, DSA)",
|
||||
"report_flow.label.eating_disorder_promotion": "Kóros étkezési szokásokra buzdítás",
|
||||
"report_flow.label.eating_disorder_promotion_profile": "A profilja evészavarokat népszerűsít",
|
||||
"report_flow.label.email": "E-mail-cím",
|
||||
|
||||
@@ -41,7 +41,6 @@
|
||||
"report_flow.label.csam": "Gambar atau video kekerasan seksual terhadap anak, baik yang asli maupun buatan AI",
|
||||
"report_flow.label.discovery_listing": "Listing di Discovery",
|
||||
"report_flow.label.dislike": "Ini tidak cocok untuk saya",
|
||||
"report_flow.label.dsa": "Konten yang ilegal di UE (Undang-Undang Layanan Digital/DSA)",
|
||||
"report_flow.label.eating_disorder_promotion": "Mendorong perilaku makan yang terganggu",
|
||||
"report_flow.label.eating_disorder_promotion_profile": "Profil mereka mempromosikan gangguan makan",
|
||||
"report_flow.label.email": "Alamat email",
|
||||
|
||||
@@ -41,7 +41,6 @@
|
||||
"report_flow.label.csam": "Immagini o video reali o generati da IA di abusi sessuali su minori",
|
||||
"report_flow.label.discovery_listing": "Elenco in Scopri",
|
||||
"report_flow.label.dislike": "Non fa per me",
|
||||
"report_flow.label.dsa": "Contenuti illegali nell'UE (Regolamento sui servizi digitali, DSA)",
|
||||
"report_flow.label.eating_disorder_promotion": "Incoraggiare disturbi alimentari",
|
||||
"report_flow.label.eating_disorder_promotion_profile": "Il suo profilo promuove disturbi alimentari",
|
||||
"report_flow.label.email": "Indirizzo e-mail",
|
||||
|
||||
@@ -41,7 +41,6 @@
|
||||
"report_flow.label.csam": "児童性的虐待の画像・動画(実在のもの、またはAI生成のもの)",
|
||||
"report_flow.label.discovery_listing": "「見つける」の掲載内容",
|
||||
"report_flow.label.dislike": "自分には合わない",
|
||||
"report_flow.label.dsa": "EUで違法なコンテンツ(デジタルサービス法、DSA)",
|
||||
"report_flow.label.eating_disorder_promotion": "摂食障害的な食行動を促している",
|
||||
"report_flow.label.eating_disorder_promotion_profile": "プロフィールが摂食障害を助長している",
|
||||
"report_flow.label.email": "メールアドレス",
|
||||
|
||||
@@ -41,7 +41,6 @@
|
||||
"report_flow.label.csam": "아동 성착취물(실제 또는 AI로 생성된 이미지 또는 동영상)",
|
||||
"report_flow.label.discovery_listing": "디스커버리 목록",
|
||||
"report_flow.label.dislike": "그냥 내 취향이 아닙니다",
|
||||
"report_flow.label.dsa": "EU에서 불법인 콘텐츠(디지털 서비스법, DSA)",
|
||||
"report_flow.label.eating_disorder_promotion": "섭식 장애 행동 조장",
|
||||
"report_flow.label.eating_disorder_promotion_profile": "프로필에서 섭식 장애를 조장합니다",
|
||||
"report_flow.label.email": "이메일 주소",
|
||||
|
||||
@@ -41,7 +41,6 @@
|
||||
"report_flow.label.csam": "Tikri ar dirbtinio intelekto sukurti vaikų seksualinės prievartos vaizdai ar vaizdo įrašai",
|
||||
"report_flow.label.discovery_listing": "Atradimo sąrašas",
|
||||
"report_flow.label.dislike": "Tai tiesiog ne man",
|
||||
"report_flow.label.dsa": "Turinys, kuris neteisėtas ES (Skaitmeninių paslaugų aktas)",
|
||||
"report_flow.label.eating_disorder_promotion": "Sutrikusių mitybos įpročių skatinimas",
|
||||
"report_flow.label.eating_disorder_promotion_profile": "Jų profilis propaguoja valgymo sutrikimus",
|
||||
"report_flow.label.email": "El. pašto adresas",
|
||||
|
||||
@@ -41,7 +41,6 @@
|
||||
"report_flow.label.csam": "Echte of door AI gegenereerde beelden of video's van seksueel misbruik van kinderen",
|
||||
"report_flow.label.discovery_listing": "Vermelding bij Ontdekken",
|
||||
"report_flow.label.dislike": "Het is gewoon niets voor mij",
|
||||
"report_flow.label.dsa": "Inhoud die illegaal is in de EU (digitaledienstenverordening)",
|
||||
"report_flow.label.eating_disorder_promotion": "Gestoord eetgedrag aanmoedigen",
|
||||
"report_flow.label.eating_disorder_promotion_profile": "Hun profiel promoot eetstoornissen",
|
||||
"report_flow.label.email": "E-mailadres",
|
||||
|
||||
@@ -41,7 +41,6 @@
|
||||
"report_flow.label.csam": "Ekte eller AI-genererte bilder eller videoer av seksuelle overgrep mot barn",
|
||||
"report_flow.label.discovery_listing": "Oppføring i Oppdag",
|
||||
"report_flow.label.dislike": "Det er bare ikke noe for meg",
|
||||
"report_flow.label.dsa": "Innhold som er ulovlig i EU (forordningen om digitale tjenester, DSA)",
|
||||
"report_flow.label.eating_disorder_promotion": "Oppfordrer til spiseforstyrrelser",
|
||||
"report_flow.label.eating_disorder_promotion_profile": "Profilen deres fremmer spiseforstyrrelser",
|
||||
"report_flow.label.email": "E-postadresse",
|
||||
|
||||
@@ -41,7 +41,6 @@
|
||||
"report_flow.label.csam": "Prawdziwe lub wygenerowane przez AI obrazy lub filmy przedstawiające wykorzystywanie seksualne dzieci",
|
||||
"report_flow.label.discovery_listing": "Wpis w sekcji Odkrywanie",
|
||||
"report_flow.label.dislike": "To po prostu nie jest dla mnie",
|
||||
"report_flow.label.dsa": "Treści, które są nielegalne w UE (akt o usługach cyfrowych)",
|
||||
"report_flow.label.eating_disorder_promotion": "Zachęcanie do zaburzeń odżywiania",
|
||||
"report_flow.label.eating_disorder_promotion_profile": "Ich profil promuje zaburzenia odżywiania",
|
||||
"report_flow.label.email": "Adres e-mail",
|
||||
|
||||
@@ -41,7 +41,6 @@
|
||||
"report_flow.label.csam": "Imagens ou vídeos reais ou gerados por IA de abuso sexual infantil",
|
||||
"report_flow.label.discovery_listing": "Listagem em Descobrir",
|
||||
"report_flow.label.dislike": "Não é para mim",
|
||||
"report_flow.label.dsa": "Conteúdo ilegal na UE (Regulamento dos Serviços Digitais)",
|
||||
"report_flow.label.eating_disorder_promotion": "Incentivando transtornos alimentares",
|
||||
"report_flow.label.eating_disorder_promotion_profile": "O perfil dele(a) promove transtornos alimentares",
|
||||
"report_flow.label.email": "Endereço de e-mail",
|
||||
|
||||
@@ -41,7 +41,6 @@
|
||||
"report_flow.label.csam": "Imagini sau videoclipuri reale sau generate de AI cu abuz sexual asupra copiilor",
|
||||
"report_flow.label.discovery_listing": "Listarea în Discovery",
|
||||
"report_flow.label.dislike": "Pur și simplu nu este pentru mine",
|
||||
"report_flow.label.dsa": "Conținut ilegal în UE (Regulamentul privind serviciile digitale)",
|
||||
"report_flow.label.eating_disorder_promotion": "Încurajarea tulburărilor de alimentație",
|
||||
"report_flow.label.eating_disorder_promotion_profile": "Profilul său promovează tulburările de alimentație",
|
||||
"report_flow.label.email": "Adresă de e-mail",
|
||||
|
||||
@@ -41,7 +41,6 @@
|
||||
"report_flow.label.csam": "Реальные или созданные ИИ изображения или видео сексуального насилия над детьми",
|
||||
"report_flow.label.discovery_listing": "Запись в каталоге",
|
||||
"report_flow.label.dislike": "Мне это не подходит",
|
||||
"report_flow.label.dsa": "Незаконный в ЕС контент (Закон о цифровых услугах, DSA)",
|
||||
"report_flow.label.eating_disorder_promotion": "Поощрение расстройств пищевого поведения",
|
||||
"report_flow.label.eating_disorder_promotion_profile": "Их профиль пропагандирует расстройства пищевого поведения",
|
||||
"report_flow.label.email": "Адрес электронной почты",
|
||||
|
||||
@@ -41,7 +41,6 @@
|
||||
"report_flow.label.csam": "Verkliga eller AI-genererade bilder eller videor av sexuella övergrepp mot barn",
|
||||
"report_flow.label.discovery_listing": "Listning i Upptäck",
|
||||
"report_flow.label.dislike": "Det är bara inget för mig",
|
||||
"report_flow.label.dsa": "Innehåll som är olagligt i EU (förordningen om digitala tjänster)",
|
||||
"report_flow.label.eating_disorder_promotion": "Uppmuntrar till ätstörningar",
|
||||
"report_flow.label.eating_disorder_promotion_profile": "Deras profil främjar ätstörningar",
|
||||
"report_flow.label.email": "E-postadress",
|
||||
|
||||
@@ -41,7 +41,6 @@
|
||||
"report_flow.label.csam": "ภาพหรือวิดีโอการล่วงละเมิดทางเพศเด็ก ไม่ว่าจะเป็นของจริงหรือสร้างโดย AI",
|
||||
"report_flow.label.discovery_listing": "รายการใน Discovery",
|
||||
"report_flow.label.dislike": "ไม่เหมาะกับฉัน",
|
||||
"report_flow.label.dsa": "เนื้อหาที่ผิดกฎหมายในสหภาพยุโรป (กฎหมายบริการดิจิทัล หรือ DSA)",
|
||||
"report_flow.label.eating_disorder_promotion": "ส่งเสริมพฤติกรรมการกินที่ผิดปกติ",
|
||||
"report_flow.label.eating_disorder_promotion_profile": "โปรไฟล์ของพวกเขาส่งเสริมความผิดปกติในการกิน",
|
||||
"report_flow.label.email": "ที่อยู่อีเมล",
|
||||
|
||||
Loaded 100 of 699 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user