Compare commits

..
Author SHA1 Message Date
HampusandGitHub dc32a7c70e feat(admin): allow system DMs to all users (#3073) 2026-09-30 21:29:05 +02:00
HampusandGitHub ab0b483fbe perf(gateway): speed up presence and harden guild queries (#3072) 2026-09-30 21:12:13 +02:00
HampusandGitHub 6e2f90b03c fix(premium): drop the grace period after a voluntary cancel (#3071) 2026-09-30 21:03:25 +02:00
HampusandGitHub 5e0806f479 fix(voice): preserve microphone channels during screen sharing (#3070) 2026-09-30 20:47:30 +02:00
HampusandGitHub dfdfffe5de feat(premium): give failed renewals a billing-cycle grace period (#3066) 2026-09-30 18:48:01 +02:00
HampusandGitHub f5e32aed31 fix(ci): correct TTL fixtures and unused dependencies (#3065) 2026-09-30 17:45:07 +02:00
HampusandGitHub 710c1aeaa8 fix(deps): bump yanked yoke-derive to 0.8.4 (#3063) 2026-09-30 16:59:59 +02:00
HampusandGitHub af49cd6cc4 refactor(ban): drop ipinfo cgnat blast-radius guard (#3062) 2026-09-30 16:54:43 +02:00
omsterandGitHub ca719e7b5e feat(admin,api): restrict community creation on self-hosted (#3055) 2026-09-30 16:32:45 +02:00
HampusandGitHub ab4069ed0e fix(app): let hidden sidebar buttons be shown again (#3061) 2026-09-30 15:03:44 +02:00
HampusandGitHub 12bfaa83ba fix(sso): route mobile sign-in through the web callback (#3060) 2026-09-30 14:48:24 +02:00
HampusandGitHub 1076728241 perf(gateway): keep large guilds responsive under floods (#3058) 2026-09-30 12:26:21 +02:00
HampusandGitHub 360b984adc fix(ci): repair admin test config and a ttl race in api tests (#3054) 2026-09-30 02:39:46 +02:00
HampusandGitHub dcdf7e1d93 fix(api): let new channels inherit the adult-only setting (#3053) 2026-09-30 02:31:47 +02:00
300 changed files with 12339 additions and 7407 deletions
Generated
+2 -2
View File
@@ -5830,9 +5830,9 @@ dependencies = [
[[package]]
name = "yoke-derive"
version = "0.8.3"
version = "0.8.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "33811428bee40dbceb6d545e95754741d17a6aef9a4849f0fd62e2ba4f412a78"
checksum = "ec8ebde2db3681e8c9980cc27822030e68752690ddfa9473e739aeb4dbde6d71"
dependencies = [
"proc-macro2",
"quote",
-1
View File
@@ -160,7 +160,6 @@ MEILI_MASTER_KEY=CHANGE_ME
# api.pwnedpasswords.com.
#FLUXER_BREACHED_PASSWORD_CHECK_ENABLED=false
#FLUXER_BLOCKLIST_FEEDS_ENABLED=false
#FLUXER_IPINFO_API_KEY=
# A local path, or an s3:// URL read with the S3 credentials of this file.
#FLUXER_GEOIP_DB_PATH=
-1
View File
@@ -33,7 +33,6 @@ x-fluxer-env: &fluxer-env
FLUXER_APP_ORIGIN_ALIASES: ${FLUXER_APP_ORIGIN_ALIASES:-}
FLUXER_BREACHED_PASSWORD_CHECK_ENABLED: ${FLUXER_BREACHED_PASSWORD_CHECK_ENABLED:-}
FLUXER_BLOCKLIST_FEEDS_ENABLED: ${FLUXER_BLOCKLIST_FEEDS_ENABLED:-}
FLUXER_IPINFO_API_KEY: ${FLUXER_IPINFO_API_KEY:-}
FLUXER_GEOIP_DB_PATH: ${FLUXER_GEOIP_DB_PATH:-}
FLUXER_API_ENDPOINT: ${FLUXER_API_ENDPOINT:-}
+15 -6
View File
@@ -1251,7 +1251,7 @@
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
}
},
"description": "Add a value to a blocklist. The request body is the shape the blocklist named by list_type accepts, and the value is validated and canonicalized for that blocklist. Adding an IP address that is on the instance exemption list, or that IPInfo reports as a high blast-radius carrier NAT, is refused with 400 IP_BAN_DECLINED and recorded in the audit log.",
"description": "Add a value to a blocklist. The request body is the shape the blocklist named by list_type accepts, and the value is validated and canonicalized for that blocklist. Adding an IP address that is on the instance exemption list is refused with 400 IP_BAN_DECLINED and recorded in the audit log.",
"security": [{"adminApiKey": []}],
"parameters": [
{
@@ -5435,7 +5435,7 @@
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
}
},
"description": "Queue a worker job that delivers the same content to every listed user as a direct message from the system account. Progress is observable through the Jobs admin resource (task_type=sendSystemDm), and an in-flight broadcast is stopped by cancelling that job. Requires SYSTEM_DM_SEND permission.",
"description": "Queue a worker job that delivers the same content to every listed user, or to every user when all_users is set, as a direct message from the system account. Progress is observable through the Jobs admin resource (task_type=sendSystemDm), and an in-flight broadcast is stopped by cancelling that job. Requires SYSTEM_DM_SEND permission.",
"security": [{"adminApiKey": []}],
"requestBody": {
"required": true,
@@ -10150,20 +10150,25 @@
"description": "Message content to send to each recipient"
},
"user_ids": {
"description": "Recipient user IDs. Each receives the same content as a system DM.",
"minItems": 1,
"maxItems": 10000,
"type": "array",
"items": {"$ref": "#/components/schemas/SnowflakeType"},
"description": "Recipient user IDs. Each receives the same content as a system DM."
"items": {"$ref": "#/components/schemas/SnowflakeType"}
},
"all_users": {
"description": "Send to every user account, skipping bots, system accounts, and deleted or disabled accounts",
"type": "boolean"
}
},
"required": ["content", "user_ids"]
"required": ["content"]
},
"SendSystemDmResponse": {
"type": "object",
"properties": {
"recipient_count": {
"description": "Number of recipients the worker job was queued to deliver to",
"nullable": true,
"description": "Number of recipients the worker job was queued to deliver to, or null when sending to all users",
"allOf": [{"$ref": "#/components/schemas/Int32Type"}]
}
},
@@ -10659,6 +10664,7 @@
"feature_custom_notification_sounds",
"feature_early_access",
"feature_global_expressions",
"feature_guild_create",
"feature_higher_video_quality",
"feature_per_guild_profiles",
"feature_voice_entrance_sounds",
@@ -10966,6 +10972,7 @@
"single_community_guild_id": {"nullable": true, "type": "string"},
"direct_messages_disabled": {"type": "boolean"},
"direct_messages_locked": {"type": "boolean"},
"guild_create_access": {"type": "boolean"},
"premium_mode": {"type": "string", "enum": ["mirror", "everyone"]},
"services": {
"type": "object",
@@ -11003,6 +11010,7 @@
"single_community_guild_id",
"direct_messages_disabled",
"direct_messages_locked",
"guild_create_access",
"premium_mode",
"services",
"services_resolved",
@@ -11523,6 +11531,7 @@
"direct_messages_disabled": {"type": "boolean"},
"direct_messages_locked": {"type": "boolean", "enum": [false]},
"premium_mode": {"type": "string", "enum": ["mirror", "everyone"]},
"guild_create_access": {"type": "boolean"},
"services": {
"nullable": true,
"type": "object",
+17
View File
@@ -0,0 +1,17 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use crate::templates::components::tooltip::{Hint, HintLink};
pub fn limit_key_hint(key: &str) -> Option<Hint<'static>> {
match key {
"feature_guild_create" => Some(Hint {
name: Some("Community Creation Access"),
body: "Admins with the wildcard ACL can always create communities.",
link: Some(HintLink::new(
"/instance-config#community-creation",
"Community creation policy",
)),
}),
_ => None,
}
}
+7 -2
View File
@@ -8,13 +8,18 @@ use super::types::SendSystemDmResponse;
impl AdminApiClient {
pub async fn send_system_dm(
&self,
user_ids: &[String],
user_ids: Option<&[String]>,
content: &str,
) -> ApiResult<SendSystemDmResponse> {
let body = generated_types::SendSystemDmRequest {
content: generated_types::SendSystemDmRequestContent::try_from(content)
.map_err(|e| ApiError::Parse(e.to_string()))?,
user_ids: user_ids.iter().map(|id| snowflake(id)).collect(),
user_ids: user_ids
.unwrap_or_default()
.iter()
.map(|id| snowflake(id))
.collect(),
all_users: user_ids.is_none().then_some(true),
};
let response = self
.generated()
@@ -43,6 +43,8 @@ pub struct InstancePolicyResponse {
pub direct_messages_locked: bool,
#[serde(default)]
pub premium_mode: PremiumMode,
#[serde(default = "default_guild_create_access")]
pub guild_create_access: bool,
#[serde(default)]
pub services: InstanceServicesOverrides,
#[serde(default)]
@@ -51,6 +53,10 @@ pub struct InstancePolicyResponse {
pub services_available: InstanceServicesAvailable,
}
fn default_guild_create_access() -> bool {
true
}
impl Default for InstancePolicyResponse {
fn default() -> Self {
Self {
@@ -59,6 +65,7 @@ impl Default for InstancePolicyResponse {
direct_messages_disabled: false,
direct_messages_locked: false,
premium_mode: PremiumMode::Everyone,
guild_create_access: default_guild_create_access(),
services: InstanceServicesOverrides::default(),
services_resolved: InstanceServicesResolved::default(),
services_available: InstanceServicesAvailable::default(),
@@ -656,6 +663,8 @@ pub struct InstancePolicyUpdateRequest {
#[serde(skip_serializing_if = "Option::is_none")]
pub direct_messages_disabled: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub guild_create_access: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub premium_mode: Option<PremiumMode>,
#[serde(skip_serializing_if = "Option::is_none")]
pub services: Option<InstanceServicesUpdateRequest>,
+1 -1
View File
@@ -4,5 +4,5 @@ use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct SendSystemDmResponse {
pub recipient_count: i64,
pub recipient_count: Option<i64>,
}
+1
View File
@@ -2,6 +2,7 @@
pub mod acl;
pub mod admin_flags;
pub mod admin_hints;
pub mod api;
pub mod config;
pub mod fonts;
+2 -1
View File
@@ -171,7 +171,8 @@ pub(crate) async fn system_dms_post(
let flash = if let Some(content) = content.as_deref()
&& !user_ids.is_empty()
{
match client.send_system_dm(&user_ids, content).await {
let recipients = (user_ids != ["*"]).then_some(user_ids.as_slice());
match client.send_system_dm(recipients, content).await {
Ok(_) => FlashData::success("System DM sent"),
Err(error) => {
tracing::warn!(%error, "admin API request failed: send system DM");
+5 -4
View File
@@ -734,6 +734,9 @@ fn build_policy_update(form: &MultiValueForm) -> InstanceConfigUpdateRequest {
let direct_messages_disabled = form
.first("policy_direct_messages_disabled")
.map(|value| value == "true");
let guild_create_access = form
.first("policy_guild_create_access")
.map(|value| value == "true");
let premium_mode = match form.first("policy_premium_mode") {
Some("mirror") => Some(PremiumMode::Mirror),
Some("everyone") => Some(PremiumMode::Everyone),
@@ -745,6 +748,7 @@ fn build_policy_update(form: &MultiValueForm) -> InstanceConfigUpdateRequest {
single_community_enabled: None,
single_community_name: None,
direct_messages_disabled,
guild_create_access,
premium_mode,
services,
}),
@@ -875,10 +879,7 @@ fn build_single_community_update(enabled: bool) -> InstanceConfigUpdateRequest {
InstanceConfigUpdateRequest {
policy: Some(InstancePolicyUpdateRequest {
single_community_enabled: Some(enabled),
single_community_name: None,
direct_messages_disabled: None,
premium_mode: None,
services: None,
..Default::default()
}),
..Default::default()
}
+25
View File
@@ -238,3 +238,28 @@ input:disabled + .checkbox-custom {
border: 2px solid transparent;
background-clip: content-box;
}
:target {
padding: 0.5rem;
border-radius: 0.25rem;
scroll-margin-top: 6rem;
animation: target-pulse 700ms ease-in-out 3;
}
@keyframes target-pulse {
0%,
100% {
background-color: transparent;
}
50% {
background-color: hsl(242 70% 55% / 0.18);
}
}
@media (prefers-reduced-motion: reduce) {
:target {
background-color: hsl(242 70% 55% / 0.12);
animation: none;
}
}
@@ -21,6 +21,7 @@ pub mod resource_link;
pub mod section_card;
pub mod stack;
pub mod table;
pub mod tooltip;
pub mod typography;
pub mod user_display;
pub mod user_profile_badges;
@@ -0,0 +1,93 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use super::icons::paperclip_icon;
use maud::{Markup, html};
use std::sync::atomic::{AtomicUsize, Ordering};
static HINT_TOGGLE_ID: AtomicUsize = AtomicUsize::new(0);
pub struct HintLink<'a> {
href: &'a str,
label: &'a str,
}
impl<'a> HintLink<'a> {
pub fn new(href: &'a str, label: &'a str) -> Self {
debug_assert!(
href.starts_with('/'),
"hint link href must be admin-absolute: {href:?}"
);
debug_assert!(
href.contains('#'),
"hint link href should point at an anchor: {href:?}"
);
debug_assert!(!label.trim().is_empty(), "hint link needs a label");
Self { href, label }
}
}
pub struct Hint<'a> {
pub name: Option<&'a str>,
pub body: &'a str,
pub link: Option<HintLink<'a>>,
}
pub fn info(base: &str, hint: &Hint<'_>) -> Markup {
let aria_label = match hint.name {
Some(name) => format!("About {name}"),
None => "More information".to_owned(),
};
let toggle_id = format!(
"hint-toggle-{}",
HINT_TOGGLE_ID.fetch_add(1, Ordering::Relaxed)
);
html! {
span class="group relative inline-flex items-center" {
input type="checkbox" id=(toggle_id) class="peer sr-only";
label for=(toggle_id) tabindex="0" aria-label=(aria_label)
class="flex h-4 w-4 shrink-0 cursor-pointer items-center justify-center rounded-full \
font-semibold text-brand-primary leading-none active:scale-97 \
hover:text-brand-primary-dark" {
"?"
}
label for=(toggle_id) aria-hidden="true"
class="invisible fixed inset-0 z-20 cursor-default peer-checked:visible" {}
div class="invisible absolute bottom-full left-2 z-30 w-64 pb-3 pl-2 opacity-0 \
transition-[opacity,visibility] duration-200 ease-out motion-reduce:transition-none \
group-hover:visible group-hover:opacity-100 \
group-focus-within:visible group-focus-within:opacity-100 \
peer-checked:visible peer-checked:opacity-100" {
div class="rounded-lg border border-neutral-200 bg-white p-3 text-neutral-600 \
text-xs shadow-lg" {
@if let Some(name) = hint.name {
p class="font-semibold text-neutral-900" { (name) }
}
p class=[hint.name.is_some().then_some("mt-1")] { (hint.body) }
@if let Some(link) = &hint.link {
a href={(base) (link.href)} hx-boost="false"
class="mt-2 inline-flex items-center gap-1 text-blue-600 hover:underline" {
(paperclip_icon(""))(link.label)
}
}
}
}
}
}
}
#[cfg(test)]
mod tests {
use super::HintLink;
#[test]
#[should_panic(expected = "anchor")]
fn rejects_a_link_that_points_at_no_anchor() {
let _ = HintLink::new("/instance-config", "Instance policy");
}
#[test]
#[should_panic(expected = "label")]
fn rejects_a_link_with_no_label() {
let _ = HintLink::new("/instance-config#community-creation", " ");
}
}
@@ -245,6 +245,7 @@ fn policy_config_section(
(single_community_form(base, csrf_token, policy))
(direct_messages_form(base, csrf_token, policy))
(premium_mode_form(base, csrf_token, policy, premium_name))
(community_creation_form(base, csrf_token, policy))
(services_form(base, csrf_token, policy))
}
},
@@ -364,6 +365,37 @@ fn premium_mode_form(
}
}
fn community_creation_form(
base: &str,
csrf_token: &str,
policy: &InstancePolicyResponse,
) -> Markup {
html! {
div id="community-creation" class="space-y-4 border-t border-neutral-200 pt-6" {
h3 class="text-sm font-semibold text-neutral-900" { "Community creation" }
form method="post" action={(base) "/instance-config?action=update_policy"} {
(csrf_input(csrf_token))
div class="space-y-4" {
(select_input("policy_guild_create_access", "Who can create communities", &[
("true", "Everyone"),
("false", "Restricted"),
], if policy.guild_create_access { "true" } else { "false" }))
p class="text-xs text-neutral-500" {
"When restricted, only admins with the wildcard ACL and users matched by a "
a href={(base) "/limit-config"} class="text-blue-600 hover:underline" {
"limit rule"
}
" that grants Community Creation Access can create communities."
}
(form_actions(html! {
(submit_button("Save community creation policy"))
}))
}
}
}
}
}
fn service_select(name: &str, label: &str, override_value: Option<bool>, resolved: bool) -> Markup {
let selected = match override_value {
None => "inherit",
@@ -2,6 +2,7 @@
use crate::{
acl::{self, INSTANCE_LIMIT_CONFIG_UPDATE},
admin_hints,
api::types::{LimitConfigResponse, LimitKeyMetadata, LimitRule},
config::AdminConfig,
middleware::auth::AuthContext,
@@ -9,6 +10,7 @@ use crate::{
components::{
form::{FORM_INPUT_CLASS, csrf_input, danger_button, form_actions, submit_button},
page_container::{card_with_header, page_header},
tooltip,
},
layout::admin_layout,
},
@@ -208,7 +210,7 @@ fn rule_editor(
@for category in CATEGORY_ORDER {
@let keys = keys_for_category(response, category);
@if !keys.is_empty() {
(category_section(response, rule, category, &keys, can_update))
(category_section(&config.base_path, response, rule, category, &keys, can_update))
}
}
@if can_update {
@@ -274,6 +276,7 @@ fn keys_for_category(response: &LimitConfigResponse, category: &str) -> Vec<Stri
}
fn category_section(
base: &str,
response: &LimitConfigResponse,
rule: &LimitRule,
category: &str,
@@ -292,7 +295,7 @@ fn category_section(
div class="space-y-4" {
@for key in keys {
@if let Some(metadata) = response.metadata.get(key) {
(limit_field(response, rule, key, metadata, can_update))
(limit_field(base, response, rule, key, metadata, can_update))
}
}
}
@@ -301,6 +304,7 @@ fn category_section(
}
fn limit_field(
base: &str,
response: &LimitConfigResponse,
rule: &LimitRule,
key: &str,
@@ -319,9 +323,10 @@ fn limit_field(
.as_ref()
.is_some_and(|fields| fields.iter().any(|field| field == key));
if metadata.is_toggle {
toggle_field(key, metadata, current_value, modified, can_update)
toggle_field(base, key, metadata, current_value, modified, can_update)
} else {
numeric_field(
base,
key,
metadata,
current_value,
@@ -333,6 +338,7 @@ fn limit_field(
}
fn toggle_field(
base: &str,
key: &str,
metadata: &LimitKeyMetadata,
current_value: Option<u64>,
@@ -343,7 +349,7 @@ fn toggle_field(
html! {
div class={(field_class(modified, false))} {
div class="flex-1 space-y-1" {
(field_label_row(key, metadata, modified))
(field_label_row(base, key, metadata, modified))
p class="text-xs text-neutral-500" { (metadata.description) }
}
div class="shrink-0" {
@@ -369,6 +375,7 @@ fn toggle_field(
}
fn numeric_field(
base: &str,
key: &str,
metadata: &LimitKeyMetadata,
current_value: Option<u64>,
@@ -385,7 +392,7 @@ fn numeric_field(
html! {
div class={(field_class(modified, true))} {
div class="flex flex-wrap items-center justify-between gap-2" {
(field_label_row(key, metadata, modified))
(field_label_row(base, key, metadata, modified))
}
p class="text-xs text-neutral-500" {
(metadata.description)
@@ -417,10 +424,13 @@ fn numeric_field(
}
}
fn field_label_row(key: &str, metadata: &LimitKeyMetadata, modified: bool) -> Markup {
fn field_label_row(base: &str, key: &str, metadata: &LimitKeyMetadata, modified: bool) -> Markup {
html! {
div class="flex flex-wrap items-center gap-2" {
label for=(key) class="font-medium text-neutral-900 text-sm" { (metadata.label) }
@if let Some(hint) = admin_hints::limit_key_hint(key) {
(tooltip::info(base, &hint))
}
span class=(scope_class(&metadata.scope)) { (scope_label(&metadata.scope)) }
@if modified {
span class="rounded bg-neutral-100 px-1.5 py-0.5 text-neutral-700 text-xs" { "Modified" }
@@ -58,7 +58,7 @@ pub fn system_dm_page(
(form_field_group(
"Recipient user IDs", "system-dm-user-ids",
true, None,
Some("One per line. Snowflake IDs only."),
Some("One per line. Snowflake IDs only, or a single * to send to every user."),
html! {
textarea id="system-dm-user-ids" name="user_ids"
required rows="10"
@@ -461,6 +461,7 @@ fn deserialize_instance_config_response_with_unknown_keys() {
"single_community_guild_id": null,
"direct_messages_disabled": false,
"direct_messages_locked": false,
"guild_create_access": false,
"premium_mode": "mirror",
"services": {
"gif_enabled": true,
@@ -354,12 +354,10 @@ fn test_config(api_endpoint: String) -> AdminConfig {
static_cdn_endpoint: "https://static.example.test".to_owned(),
admin_endpoint: "https://admin.example.test".to_owned(),
web_app_endpoint: "https://app.example.test".to_owned(),
kv_url: String::new(),
oauth_client_id: "admin-client".to_owned(),
oauth_client_secret: "admin-secret".to_owned(),
oauth_redirect_uri: "https://admin.example.test/callback".to_owned(),
build_version: "test".to_owned(),
release_channel: "test".to_owned(),
self_hosted: false,
proxy: ProxyConfig {
trust_client_ip_header: false,
+1 -4
View File
@@ -11,13 +11,10 @@
},
"dependencies": {
"@aws-sdk/client-s3": "catalog:",
"@pkgs/cassandra": "workspace:*",
"@fluxer/geo_utils": "workspace:*",
"@fluxer/instance_bootstrap": "workspace:*",
"@fluxer/ip_utils": "workspace:*",
"@pkgs/postgres": "workspace:*",
"maxmind": "catalog:",
"zod": "catalog:"
"maxmind": "catalog:"
},
"devDependencies": {
"@types/node": "catalog:",
@@ -1,60 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {ICassandraClient} from '@pkgs/cassandra/src/Client';
import type {IpInfoCache} from '@pkgs/geoip/src/IpInfoService';
const TABLE = 'ipinfo_cache';
const SELECT_CQL = `SELECT payload FROM ${TABLE} WHERE cache_key = :cache_key LIMIT 1;`;
const INSERT_WITH_TTL_CQL = `INSERT INTO ${TABLE} (cache_key, payload) VALUES (:cache_key, :payload) USING TTL :ttl;`;
const INSERT_DEFAULT_TTL_CQL = `INSERT INTO ${TABLE} (cache_key, payload) VALUES (:cache_key, :payload);`;
interface CassandraIpInfoCacheOptions {
client?: ICassandraClient;
getClient?: () => ICassandraClient;
}
export function createCassandraIpInfoCache(options: CassandraIpInfoCacheOptions): IpInfoCache {
return {
async get<T>(key: string): Promise<T | null> {
try {
const client = options.client ?? options.getClient?.();
if (!client) {
return null;
}
const result = await client.execute({cql: SELECT_CQL, params: {cache_key: key}});
const row = result.first();
if (!row) return null;
const payload = row.get('payload');
if (typeof payload !== 'string') return null;
return JSON.parse(payload) as T;
} catch {
return null;
}
},
async set<T>(key: string, value: T, ttlSeconds?: number): Promise<void> {
let payload: string;
try {
payload = JSON.stringify(value);
} catch {
return;
}
try {
const client = options.client ?? options.getClient?.();
if (!client) {
return;
}
if (ttlSeconds != null && Number.isFinite(ttlSeconds) && ttlSeconds > 0) {
await client.execute({
cql: INSERT_WITH_TTL_CQL,
params: {cache_key: key, payload, ttl: ttlSeconds},
});
} else {
await client.execute({
cql: INSERT_DEFAULT_TTL_CQL,
params: {cache_key: key, payload},
});
}
} catch {}
},
};
}
@@ -1,119 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {randomUUID} from 'node:crypto';
import type {ICassandraClient} from '@pkgs/cassandra/src/Client';
import type {IpInfoRequestAuditEvent, IpInfoRequestAuditLogger} from '@pkgs/geoip/src/IpInfoService';
const TABLE = 'ipinfo_requests_by_hour';
const INSERT_CQL = `INSERT INTO ${TABLE} (
bucket_date,
bucket_hour,
requested_at,
event_id,
source,
reason,
ip,
cache_key,
request_url,
http_status,
outcome,
available,
risk_note,
latency_ms,
response_ip,
country_code,
asn,
is_anonymous,
is_tor,
is_vpn,
is_proxy,
is_residential_proxy,
metadata_json
) VALUES (
:bucket_date,
:bucket_hour,
:requested_at,
:event_id,
:source,
:reason,
:ip,
:cache_key,
:request_url,
:http_status,
:outcome,
:available,
:risk_note,
:latency_ms,
:response_ip,
:country_code,
:asn,
:is_anonymous,
:is_tor,
:is_vpn,
:is_proxy,
:is_residential_proxy,
:metadata_json
);`;
interface CassandraIpInfoRequestAuditOptions {
client?: ICassandraClient;
getClient?: () => ICassandraClient;
}
export function createCassandraIpInfoRequestAuditLogger(
options: CassandraIpInfoRequestAuditOptions,
): IpInfoRequestAuditLogger {
return {
async record(event: IpInfoRequestAuditEvent): Promise<void> {
try {
const client = options.client ?? options.getClient?.();
if (!client) {
return;
}
await client.execute({
cql: INSERT_CQL,
params: {
bucket_date: formatUtcDate(event.requestedAt),
bucket_hour: event.requestedAt.getUTCHours(),
requested_at: event.requestedAt,
event_id: randomUUID(),
source: event.source,
reason: event.reason,
ip: event.ip,
cache_key: event.cacheKey,
request_url: event.requestUrl,
http_status: event.httpStatus,
outcome: event.outcome,
available: event.available,
risk_note: event.note,
latency_ms: event.latencyMs,
response_ip: event.responseIp,
country_code: event.countryCode,
asn: event.asnNumber,
is_anonymous: event.isAnonymous,
is_tor: event.isTor,
is_vpn: event.isVpn,
is_proxy: event.isProxy,
is_residential_proxy: event.isResidentialProxy,
metadata_json: serializeMetadata(event.metadata),
},
});
} catch {}
},
};
}
function formatUtcDate(value: Date): string {
return value.toISOString().slice(0, 10);
}
function serializeMetadata(metadata: IpInfoRequestAuditEvent['metadata']): string | null {
if (!metadata || Object.keys(metadata).length === 0) {
return null;
}
try {
return JSON.stringify(metadata);
} catch {
return null;
}
}
-506
View File
@@ -1,506 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {getSameIpDecisionKey} from '@fluxer/ip_utils/src/IpAddress';
import {z} from 'zod';
const IPINFO_BASE_URL = 'https://api.ipinfo.io/lookup';
const FETCH_TIMEOUT_MS = 3000;
const CACHE_KEY_PREFIX = 'ipinfo:max:';
const ISO_DATE_REGEX = /^\d{4}-\d{2}-\d{2}$/u;
const POSITIVE_CACHE_TTL_SECONDS = 7 * 24 * 60 * 60;
const NEGATIVE_CACHE_TTL_SECONDS = 14 * 24 * 60 * 60;
const FAILURE_TTL_REQUEST_FAILED_SECONDS = 60;
const FAILURE_TTL_HTTP_ERROR_SECONDS = 300;
const FAILURE_TTL_QUOTA_SECONDS = 900;
const FAILURE_TTL_SCHEMA_MISMATCH_SECONDS = 600;
export interface IpInfoGeoBlock {
countryCode: string | null;
countryName: string | null;
continent: string | null;
continentCode: string | null;
region: string | null;
regionCode: string | null;
city: string | null;
postalCode: string | null;
timezone: string | null;
latitude: number | null;
longitude: number | null;
accuracyRadiusKm: number | null;
}
export interface IpInfoAsnBlock {
asn: string | null;
number: number | null;
name: string | null;
domain: string | null;
type: string | null;
}
export interface IpInfoMobileBlock {
name: string | null;
mcc: string | null;
mnc: string | null;
}
export interface IpInfoAnonymousBlock {
isAnonymous: boolean;
providerName: string | null;
isVpn: boolean;
isProxy: boolean;
isResidentialProxy: boolean;
isTor: boolean;
isRelay: boolean;
percentDaysSeen: number | null;
}
export interface IpInfoFlags {
isAnycast: boolean;
isHosting: boolean;
isMobile: boolean;
isSatellite: boolean;
}
export interface IpInfoLookupResult {
ip: string;
available: boolean;
note: string;
geo: IpInfoGeoBlock;
asn: IpInfoAsnBlock;
mobile: IpInfoMobileBlock;
anonymous: IpInfoAnonymousBlock;
flags: IpInfoFlags;
}
export interface IpInfoCache {
get<T>(key: string): Promise<T | null>;
set<T>(key: string, value: T, ttlSeconds?: number): Promise<void>;
}
export interface CachedIpInfoFailure extends IpInfoLookupResult {
cachedFailure: true;
failureOutcome: 'http_error' | 'request_failed' | 'schema_mismatch';
failureHttpStatus: number | null;
cachedAtMs: number;
}
export function isCachedIpInfoFailure(value: unknown): value is CachedIpInfoFailure {
return typeof value === 'object' && value !== null && (value as {available?: unknown}).available === false;
}
function failureCacheTtlSeconds(outcome: CachedIpInfoFailure['failureOutcome'], httpStatus: number | null): number {
if (outcome === 'request_failed') return FAILURE_TTL_REQUEST_FAILED_SECONDS;
if (outcome === 'schema_mismatch') return FAILURE_TTL_SCHEMA_MISMATCH_SECONDS;
if (httpStatus === 402 || httpStatus === 403 || httpStatus === 429) return FAILURE_TTL_QUOTA_SECONDS;
return FAILURE_TTL_HTTP_ERROR_SECONDS;
}
export interface IpInfoLookupContext {
source?: string;
reason?: string;
metadata?: Record<string, string | number | boolean | null>;
}
export interface IpInfoRequestAuditEvent {
requestedAt: Date;
ip: string;
cacheKey: string;
source: string;
reason: string | null;
metadata?: Record<string, string | number | boolean | null>;
outcome: 'http_success' | 'http_error' | 'request_failed' | 'schema_mismatch';
httpStatus: number | null;
available: boolean;
note: string;
latencyMs: number;
requestUrl: string;
responseIp: string | null;
countryCode: string | null;
asnNumber: number | null;
isAnonymous: boolean;
isTor: boolean;
isVpn: boolean;
isProxy: boolean;
isResidentialProxy: boolean;
}
export interface IpInfoRequestAuditLogger {
record(event: IpInfoRequestAuditEvent): Promise<void>;
}
interface IpInfoServiceContext {
apiKey: string;
cache: IpInfoCache;
auditLogger?: IpInfoRequestAuditLogger;
}
export interface IpInfoService {
lookup(ip: string, context?: IpInfoLookupContext): Promise<IpInfoLookupResult>;
}
const IpInfoDateSchema = z.string().regex(ISO_DATE_REGEX);
const RawIpInfoGeoSchema = z.object({
city: z.string().optional(),
region: z.string().optional(),
region_code: z.string().optional(),
country: z.string().optional(),
country_code: z.string().optional(),
continent: z.string().optional(),
continent_code: z.string().optional(),
latitude: z.number().optional(),
longitude: z.number().optional(),
timezone: z.string().optional(),
postal_code: z.string().optional(),
dma_code: z.string().optional(),
geoname_id: z.string().optional(),
radius: z.number().int().optional(),
last_changed: IpInfoDateSchema.optional(),
});
const RawIpInfoAsSchema = z.object({
asn: z.string().optional(),
name: z.string().optional(),
domain: z.string().optional(),
type: z.string().optional(),
last_changed: IpInfoDateSchema.optional(),
});
const RawIpInfoMobileSchema = z.object({
name: z.string().optional(),
mcc: z.string().optional(),
mnc: z.string().optional(),
});
const RawIpInfoAnonymousSchema = z.object({
name: z.string().optional(),
last_seen: IpInfoDateSchema.optional(),
percent_days_seen: z.number().int().optional(),
is_proxy: z.boolean().optional(),
is_relay: z.boolean().optional(),
is_tor: z.boolean().optional(),
is_vpn: z.boolean().optional(),
is_res_proxy: z.boolean().optional(),
});
const RawIpInfoResponseSchema = z.object({
ip: z.string(),
hostname: z.string().optional(),
geo: RawIpInfoGeoSchema,
as: RawIpInfoAsSchema,
mobile: RawIpInfoMobileSchema.optional(),
anonymous: RawIpInfoAnonymousSchema,
is_anonymous: z.boolean().optional(),
is_anycast: z.boolean().optional(),
is_hosting: z.boolean().optional(),
is_mobile: z.boolean().optional(),
is_satellite: z.boolean().optional(),
});
type RawIpInfoResponse = z.infer<typeof RawIpInfoResponseSchema>;
export function createIpInfoService(ctx: IpInfoServiceContext): IpInfoService {
const inflight: Map<string, Promise<IpInfoLookupResult>> = new Map();
return {
async lookup(ip: string, context?: IpInfoLookupContext): Promise<IpInfoLookupResult> {
const cacheKey = `${CACHE_KEY_PREFIX}${getSameIpDecisionKey(ip) ?? ip}`;
const cached = await ctx.cache.get<IpInfoLookupResult>(cacheKey);
if (cached !== null) {
if (isCachedIpInfoFailure(cached)) {
return unavailable(ip, cached.note);
}
return {...cached, ip};
}
const existing = inflight.get(cacheKey);
if (existing) {
const result = await existing;
return {...result, ip};
}
const requestedAt = new Date();
const startedAt = Date.now();
const requestUrl = `${IPINFO_BASE_URL}/${encodeURIComponent(ip)}`;
const fetchUrl = `${requestUrl}?token=${encodeURIComponent(ctx.apiKey)}`;
const finalize = async (params: {
result: IpInfoLookupResult;
outcome: IpInfoRequestAuditEvent['outcome'];
httpStatus: number | null;
}): Promise<IpInfoLookupResult> => {
await ctx.auditLogger
?.record({
requestedAt,
ip,
cacheKey,
source: context?.source ?? 'unknown',
reason: context?.reason ?? null,
metadata: context?.metadata,
outcome: params.outcome,
httpStatus: params.httpStatus,
available: params.result.available,
note: params.result.note,
latencyMs: Date.now() - startedAt,
requestUrl,
responseIp: params.result.available ? params.result.ip : null,
countryCode: params.result.geo.countryCode,
asnNumber: params.result.asn.number,
isAnonymous: params.result.anonymous.isAnonymous,
isTor: params.result.anonymous.isTor,
isVpn: params.result.anonymous.isVpn,
isProxy: params.result.anonymous.isProxy,
isResidentialProxy: params.result.anonymous.isResidentialProxy,
})
.catch(() => {});
return params.result;
};
const performLookup = async (): Promise<IpInfoLookupResult> => {
const finalizeFailure = async (params: {
result: IpInfoLookupResult;
outcome: CachedIpInfoFailure['failureOutcome'];
httpStatus: number | null;
}): Promise<IpInfoLookupResult> => {
const entry: CachedIpInfoFailure = {
...params.result,
cachedFailure: true,
failureOutcome: params.outcome,
failureHttpStatus: params.httpStatus,
cachedAtMs: Date.now(),
};
await ctx.cache
.set(cacheKey, entry, failureCacheTtlSeconds(params.outcome, params.httpStatus))
.catch(() => {});
return finalize(params);
};
const controller = new AbortController();
const timer = setTimeout(() => {
controller.abort(new DOMException('The operation was aborted due to timeout', 'TimeoutError'));
}, FETCH_TIMEOUT_MS);
timer.unref();
let payload: unknown;
try {
const res = await fetch(fetchUrl, {
signal: controller.signal,
headers: {Accept: 'application/json'},
});
if (!res.ok) {
return finalizeFailure({
result: unavailable(ip, `IPInfo HTTP ${res.status}`),
outcome: 'http_error',
httpStatus: res.status,
});
}
payload = await res.json();
} catch (err) {
const detail = err instanceof Error ? err.message : String(err);
return finalizeFailure({
result: unavailable(ip, `IPInfo request failed: ${detail}`),
outcome: 'request_failed',
httpStatus: null,
});
} finally {
clearTimeout(timer);
controller.abort();
}
const parsedResponse = RawIpInfoResponseSchema.safeParse(payload);
if (!parsedResponse.success) {
return finalizeFailure({
result: unavailable(ip, formatSchemaMismatch(parsedResponse.error)),
outcome: 'schema_mismatch',
httpStatus: 200,
});
}
const result = parseIpInfoResponse(parsedResponse.data);
const ttl = result.anonymous.isAnonymous ? POSITIVE_CACHE_TTL_SECONDS : NEGATIVE_CACHE_TTL_SECONDS;
await ctx.cache.set(cacheKey, result, ttl).catch(() => {});
return finalize({
result,
outcome: 'http_success',
httpStatus: 200,
});
};
const promise: Promise<IpInfoLookupResult> = performLookup().finally(() => {
if (inflight.get(cacheKey) === promise) {
inflight.delete(cacheKey);
}
});
inflight.set(cacheKey, promise);
return promise;
},
};
}
export function createUnavailableIpInfoService(reason = 'IPInfo not configured'): IpInfoService {
return {
async lookup(ip: string): Promise<IpInfoLookupResult> {
return unavailable(ip, reason);
},
};
}
function unavailable(ip: string, reason: string): IpInfoLookupResult {
return {
ip,
available: false,
note: reason,
geo: emptyGeo(),
asn: emptyAsn(),
mobile: emptyMobile(),
anonymous: emptyAnonymous(),
flags: emptyFlags(),
};
}
function emptyGeo(): IpInfoGeoBlock {
return {
countryCode: null,
countryName: null,
continent: null,
continentCode: null,
region: null,
regionCode: null,
city: null,
postalCode: null,
timezone: null,
latitude: null,
longitude: null,
accuracyRadiusKm: null,
};
}
function emptyAsn(): IpInfoAsnBlock {
return {asn: null, number: null, name: null, domain: null, type: null};
}
function emptyMobile(): IpInfoMobileBlock {
return {name: null, mcc: null, mnc: null};
}
function emptyAnonymous(): IpInfoAnonymousBlock {
return {
isAnonymous: false,
providerName: null,
isVpn: false,
isProxy: false,
isResidentialProxy: false,
isTor: false,
isRelay: false,
percentDaysSeen: null,
};
}
function emptyFlags(): IpInfoFlags {
return {isAnycast: false, isHosting: false, isMobile: false, isSatellite: false};
}
function parseIpInfoResponse(raw: RawIpInfoResponse): IpInfoLookupResult {
const geo = raw.geo;
const anon = raw.anonymous;
const isAnonymous =
raw.is_anonymous === true ||
anon.is_res_proxy === true ||
anon.is_vpn === true ||
anon.is_proxy === true ||
anon.is_tor === true ||
anon.is_relay === true;
return {
ip: raw.ip,
available: true,
note: describeAnonymity(isAnonymous, anon),
geo: {
countryCode: normalizeCountryCode(geo.country_code),
countryName: geo.country ?? null,
continent: geo?.continent ?? null,
continentCode: normalizeContinentCode(geo.continent_code),
region: geo.region ?? null,
regionCode: normalizeRegionCode(geo.region_code),
city: geo.city ?? null,
postalCode: geo.postal_code ?? null,
timezone: geo.timezone ?? null,
latitude: normalizeCoordinate(geo.latitude),
longitude: normalizeCoordinate(geo.longitude),
accuracyRadiusKm: typeof geo?.radius === 'number' && Number.isFinite(geo.radius) ? geo.radius : null,
},
asn: parseAsnBlock(raw.as),
mobile: {
name: raw.mobile?.name ?? null,
mcc: raw.mobile?.mcc ?? null,
mnc: raw.mobile?.mnc ?? null,
},
anonymous: {
isAnonymous,
providerName: anon?.name ?? null,
isVpn: anon?.is_vpn === true,
isProxy: anon?.is_proxy === true,
isResidentialProxy: anon?.is_res_proxy === true,
isTor: anon?.is_tor === true,
isRelay: anon?.is_relay === true,
percentDaysSeen: typeof anon?.percent_days_seen === 'number' ? anon.percent_days_seen : null,
},
flags: {
isAnycast: raw.is_anycast === true,
isHosting: raw.is_hosting === true,
isMobile: raw.is_mobile === true,
isSatellite: raw.is_satellite === true,
},
};
}
function formatSchemaMismatch(error: z.ZodError): string {
const issue = error.issues[0];
if (!issue) {
return 'IPInfo response schema mismatch';
}
const path = issue.path.length > 0 ? issue.path.join('.') : '<root>';
return `IPInfo response schema mismatch at ${path}: ${issue.message}`;
}
function parseAsnBlock(as: RawIpInfoResponse['as']): IpInfoAsnBlock {
const raw = as?.asn ?? null;
const numeric = raw ? Number(raw.replace(/^AS/i, '')) : Number.NaN;
return {
asn: raw,
number: Number.isFinite(numeric) ? numeric : null,
name: as?.name ?? null,
domain: as?.domain ?? null,
type: as?.type ?? null,
};
}
function describeAnonymity(isAnonymous: boolean, anon: RawIpInfoResponse['anonymous']): string {
if (!isAnonymous) {
return 'IPInfo: IP is not anonymous';
}
if (!anon) {
return 'IPInfo: anonymous IP';
}
const flags: Array<string> = [];
if (anon.is_res_proxy) flags.push('residential proxy');
if (anon.is_vpn) flags.push('VPN');
if (anon.is_proxy) flags.push('proxy');
if (anon.is_tor) flags.push('Tor');
if (anon.is_relay) flags.push('relay');
const provider = anon.name ? ` (provider: ${anon.name})` : '';
const seen = anon.percent_days_seen != null ? `, seen ${anon.percent_days_seen}% of days` : '';
return `IPInfo: anonymous IP${provider} — ${flags.join(', ')}${seen}`;
}
function normalizeCountryCode(value: string | undefined): string | null {
if (!value) {
return null;
}
const normalized = value.trim().toUpperCase();
return /^[A-Z]{2}$/u.test(normalized) ? normalized : null;
}
function normalizeContinentCode(value: string | undefined): string | null {
if (!value) {
return null;
}
const normalized = value.trim().toUpperCase();
return /^[A-Z]{2}$/u.test(normalized) ? normalized : null;
}
function normalizeRegionCode(value: string | undefined): string | null {
if (!value) {
return null;
}
const normalized = value.trim().toUpperCase();
return normalized.length > 0 ? normalized : null;
}
function normalizeCoordinate(value: number | undefined): number | null {
return typeof value === 'number' && Number.isFinite(value) ? value : null;
}
@@ -1,153 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {randomUUID} from 'node:crypto';
import type {IpInfoCache, IpInfoRequestAuditEvent, IpInfoRequestAuditLogger} from '@pkgs/geoip/src/IpInfoService';
import {type IPostgresClient, quoteIdentifier} from '@pkgs/postgres/src/Client';
interface PostgresIpInfoOptions {
client?: IPostgresClient;
getClient?: () => IPostgresClient;
onError?: (error: unknown, operation: string) => void;
}
const VALUE_SEPARATOR = '\u001f';
export const IPINFO_CACHE_TTL_SECONDS = 14 * 24 * 60 * 60;
export const IPINFO_REQUEST_AUDIT_TTL_SECONDS = 90 * 24 * 60 * 60;
function getClient(options: PostgresIpInfoOptions): IPostgresClient | null {
return options.client ?? options.getClient?.() ?? null;
}
function valueKey(value: unknown): string {
return JSON.stringify(value);
}
function rowKey(values: ReadonlyArray<unknown>): string {
return values.map(valueKey).join(VALUE_SEPARATOR);
}
function table(client: IPostgresClient): string {
return quoteIdentifier(client.kvTable());
}
async function upsertKvRow(
client: IPostgresClient,
tableName: string,
partitionKey: string,
key: string,
row: Record<string, unknown>,
ttlSeconds: number,
): Promise<void> {
const expiresAt = new Date(Date.now() + ttlSeconds * 1000);
await client.query(
`INSERT INTO ${table(client)} (table_name, partition_key, row_key, row_data, expires_at, updated_at)
VALUES ($1, $2, $3, $4::jsonb, $5, now())
ON CONFLICT (table_name, row_key)
DO UPDATE SET partition_key = EXCLUDED.partition_key, row_data = EXCLUDED.row_data, expires_at = EXCLUDED.expires_at, updated_at = now()`,
[tableName, partitionKey, key, JSON.stringify(row), expiresAt],
);
}
export function createPostgresIpInfoCache(options: PostgresIpInfoOptions): IpInfoCache {
return {
async get<T>(key: string): Promise<T | null> {
try {
const client = getClient(options);
if (!client) return null;
const result = await client.query<{row_data: {payload?: string}}>(
`SELECT row_data FROM ${table(client)} WHERE table_name = $1 AND row_key = $2 AND (expires_at IS NULL OR expires_at > now()) LIMIT 1`,
['ipinfo_cache', rowKey([key])],
);
const payload = result.rows[0]?.row_data?.payload;
return typeof payload === 'string' ? (JSON.parse(payload) as T) : null;
} catch (error) {
options.onError?.(error, 'ipinfo_cache_get');
return null;
}
},
async set<T>(key: string, value: T, ttlSeconds?: number): Promise<void> {
let payload: string;
try {
payload = JSON.stringify(value);
} catch (error) {
options.onError?.(error, 'ipinfo_cache_serialize');
return;
}
try {
const client = getClient(options);
if (!client) return;
await upsertKvRow(
client,
'ipinfo_cache',
rowKey([key]),
rowKey([key]),
{cache_key: key, payload},
ttlSeconds != null && Number.isFinite(ttlSeconds) && ttlSeconds > 0 ? ttlSeconds : IPINFO_CACHE_TTL_SECONDS,
);
} catch (error) {
options.onError?.(error, 'ipinfo_cache_set');
}
},
};
}
export function createPostgresIpInfoRequestAuditLogger(options: PostgresIpInfoOptions): IpInfoRequestAuditLogger {
return {
async record(event: IpInfoRequestAuditEvent): Promise<void> {
try {
const client = getClient(options);
if (!client) return;
const bucketDate = formatUtcDate(event.requestedAt);
const bucketHour = event.requestedAt.getUTCHours();
const eventId = randomUUID();
await upsertKvRow(
client,
'ipinfo_requests_by_hour',
rowKey([bucketDate, bucketHour]),
rowKey([bucketDate, bucketHour, event.requestedAt.toISOString(), eventId]),
{
bucket_date: bucketDate,
bucket_hour: bucketHour,
requested_at: event.requestedAt.toISOString(),
event_id: eventId,
source: event.source,
reason: event.reason,
ip: event.ip,
cache_key: event.cacheKey,
request_url: event.requestUrl,
http_status: event.httpStatus,
outcome: event.outcome,
available: event.available,
risk_note: event.note,
latency_ms: event.latencyMs,
response_ip: event.responseIp,
country_code: event.countryCode,
asn: event.asnNumber,
is_anonymous: event.isAnonymous,
is_tor: event.isTor,
is_vpn: event.isVpn,
is_proxy: event.isProxy,
is_residential_proxy: event.isResidentialProxy,
metadata_json: serializeMetadata(event.metadata),
},
IPINFO_REQUEST_AUDIT_TTL_SECONDS,
);
} catch (error) {
options.onError?.(error, 'ipinfo_request_audit_record');
}
},
};
}
function formatUtcDate(value: Date): string {
return value.toISOString().slice(0, 10);
}
function serializeMetadata(metadata: IpInfoRequestAuditEvent['metadata']): string | null {
if (!metadata || Object.keys(metadata).length === 0) return null;
try {
return JSON.stringify(metadata);
} catch {
return null;
}
}
@@ -1,35 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {IpInfoCache} from '@pkgs/geoip/src/IpInfoService';
const DEFAULT_HOT_TTL_SECONDS = 10 * 60;
interface TieredIpInfoCacheOptions {
hot: IpInfoCache;
cold: IpInfoCache;
hotTtlSeconds?: number;
skipColdWrite?: (value: unknown) => boolean;
}
export function createTieredIpInfoCache(opts: TieredIpInfoCacheOptions): IpInfoCache {
const hotTtl = opts.hotTtlSeconds ?? DEFAULT_HOT_TTL_SECONDS;
return {
async get<T>(key: string): Promise<T | null> {
const hit = await opts.hot.get<T>(key).catch(() => null);
if (hit !== null) return hit;
const cold = await opts.cold.get<T>(key).catch(() => null);
if (cold === null) return null;
if (opts.skipColdWrite?.(cold) === true) return cold;
void opts.hot.set(key, cold, hotTtl).catch(() => {});
return cold;
},
async set<T>(key: string, value: T, ttlSeconds?: number): Promise<void> {
const effectiveHotTtl = Math.max(1, Math.min(hotTtl, ttlSeconds ?? hotTtl));
const writes: Array<Promise<void>> = [opts.hot.set(key, value, effectiveHotTtl).catch(() => {})];
if (opts.skipColdWrite?.(value) !== true) {
writes.push(opts.cold.set(key, value, ttlSeconds).catch(() => {}));
}
await Promise.all(writes);
},
};
}
-3
View File
@@ -259,9 +259,6 @@ export function buildAPIConfigFromMaster(master: MasterConfig): APIConfig {
}
: undefined,
},
ipinfo: {
apiKey: master.integrations.ipinfo.api_key || undefined,
},
blocklistFeeds: {
enabled: master.integrations.blocklist_feeds.enabled ?? !master.instance.self_hosted,
},
+7 -10
View File
@@ -41,7 +41,6 @@ import type {StoreEntitlementService} from '@app/api/store_billing/StoreEntitlem
import type {UserService} from '@app/api/user/services/UserService';
import type {VoiceRepository} from '@app/api/voice/VoiceRepository';
import type {SendSystemDmResponse} from '@fluxer/schema/src/domains/admin/AdminSchemas';
import type {IpInfoService} from '@pkgs/geoip/src/IpInfoService';
import type Stripe from 'stripe';
export class AdminService {
@@ -81,7 +80,6 @@ export class AdminService {
private readonly applicationRepository: IApplicationRepository,
private readonly stripe: Stripe | null = null,
private readonly jobLedger: IJobLedgerRepository,
private readonly ipInfoService: IpInfoService,
private readonly storeEntitlementService: StoreEntitlementService,
) {
const {users, gateway, worker, snowflake} = this.apiContext.services;
@@ -94,7 +92,6 @@ export class AdminService {
apiContext: this.apiContext,
adminRepository: this.adminRepository,
auditService: this.auditService,
ipInfoService: this.ipInfoService,
});
this.userService = new AdminUserService({
apiContext: this.apiContext,
@@ -181,20 +178,20 @@ export class AdminService {
}
async sendSystemDm(
data: {content: string; userIds: Array<string>},
data: {content: string; recipients: {kind: 'all'} | {kind: 'list'; userIds: Array<string>}},
adminUserId: UserID,
auditLogReason: string | null,
): Promise<SendSystemDmResponse> {
const recipientCount = data.recipients.kind === 'all' ? null : data.recipients.userIds.length;
await this.apiContext.services.worker.addJob(
'sendSystemDm',
{
content: data.content,
user_ids: data.userIds,
},
data.recipients.kind === 'all'
? {content: data.content, all_users: true}
: {content: data.content, user_ids: data.recipients.userIds},
{requireLedger: true},
);
const metadata = new Map<string, string>([
['recipient_count', data.userIds.length.toString()],
['recipient_count', recipientCount === null ? 'all' : recipientCount.toString()],
['content_length', data.content.length.toString()],
]);
await this.auditService.createAuditLog({
@@ -205,6 +202,6 @@ export class AdminService {
auditLogReason,
metadata,
});
return {recipient_count: data.userIds.length};
return {recipient_count: recipientCount};
}
}
@@ -338,7 +338,7 @@ export function BanAdminController(app: HonoApp) {
tags: ['Admin'],
requestSchema: AdminBlocklistEntryCreateRequest,
description:
'Add a value to a blocklist. The request body is the shape the blocklist named by list_type accepts, and the value is validated and canonicalized for that blocklist. Adding an IP address that is on the instance exemption list, or that IPInfo reports as a high blast-radius carrier NAT, is refused with 400 IP_BAN_DECLINED and recorded in the audit log.',
'Add a value to a blocklist. The request body is the shape the blocklist named by list_type accepts, and the value is validated and canonicalized for that blocklist. Adding an IP address that is on the instance exemption list is refused with 400 IP_BAN_DECLINED and recorded in the audit log.',
}),
async (ctx) => {
const adminService = ctx.get('adminService');
@@ -124,6 +124,7 @@ async function buildInstanceConfigResponse(): Promise<InstanceConfigResponse> {
single_community_guild_id: policy.single_community_guild_id,
direct_messages_disabled: policy.direct_messages_disabled,
direct_messages_locked: policy.direct_messages_locked,
guild_create_access: policy.guild_create_access,
premium_mode: policy.premium_mode,
services: {
gif_enabled: policy.gif_enabled,
@@ -831,6 +832,9 @@ function planInstancePolicyPatch(
patch.direct_messages_locked = true;
}
}
if (policy.guild_create_access !== undefined && policy.guild_create_access !== current.guild_create_access) {
patch.guild_create_access = policy.guild_create_access;
}
if (policy.services) {
if (policy.services.gif_enabled !== undefined) {
patch.gif_enabled = policy.services.gif_enabled ?? null;
@@ -23,7 +23,7 @@ export function SystemDmAdminController(app: HonoApp) {
security: 'adminApiKey',
tags: 'Admin',
description:
'Queue a worker job that delivers the same content to every listed user as a direct message from the system account. Progress is observable through the Jobs admin resource (task_type=sendSystemDm), and an in-flight broadcast is stopped by cancelling that job. Requires SYSTEM_DM_SEND permission.',
'Queue a worker job that delivers the same content to every listed user, or to every user when all_users is set, as a direct message from the system account. Progress is observable through the Jobs admin resource (task_type=sendSystemDm), and an in-flight broadcast is stopped by cancelling that job. Requires SYSTEM_DM_SEND permission.',
}),
async (ctx) => {
const adminService = ctx.get('adminService');
@@ -31,7 +31,12 @@ export function SystemDmAdminController(app: HonoApp) {
const auditLogReason = ctx.get('auditLogReason');
const payload = ctx.req.valid('json');
const result = await adminService.sendSystemDm(
{content: payload.content, userIds: payload.user_ids.map((id) => id.toString())},
{
content: payload.content,
recipients: payload.all_users
? {kind: 'all'}
: {kind: 'list', userIds: (payload.user_ids ?? []).map((id) => id.toString())},
},
adminUserId,
auditLogReason,
);
@@ -4,7 +4,6 @@ import type {ApiContext} from '@app/api/ApiContext';
import type {IAdminRepository} from '@app/api/admin/IAdminRepository';
import type {AdminAuditService} from '@app/api/admin/services/AdminAuditService';
import {createUserID, type UserID} from '@app/api/BrandedTypes';
import {getIpBanBlastRadiusVerdict, isSingleIpBanCandidate} from '@app/api/ban/IpBanCgnatGuard';
import {isIpBanExempt} from '@app/api/ban/IpBanExemptions';
import {
BANNED_AVATAR_HASHES_REFRESH_CHANNEL,
@@ -18,7 +17,6 @@ import {
} from '@app/api/constants/ContentModeration';
import {IP_BAN_REFRESH_CHANNEL} from '@app/api/constants/IpBan';
import type {BannedProfileSubstringScope} from '@app/api/database/types/AdminArchiveTypes';
import {Logger} from '@app/api/Logger';
import {bannedAvatarHashCache} from '@app/api/middleware/BannedAvatarHashCache';
import {fileShaCache} from '@app/api/middleware/FileShaCache';
import {ipBanCache} from '@app/api/middleware/IpBanMiddleware';
@@ -34,13 +32,11 @@ import {InputValidationError} from '@fluxer/errors/src/domains/core/InputValidat
import {NotFoundError} from '@fluxer/errors/src/domains/core/NotFoundError';
import {UnknownUserError} from '@fluxer/errors/src/domains/user/UnknownUserError';
import type {AdminBlocklistListType} from '@fluxer/schema/src/domains/admin/AdminBlocklistSchemas';
import type {IpInfoService} from '@pkgs/geoip/src/IpInfoService';
interface AdminBanManagementServiceDeps {
apiContext: ApiContext;
adminRepository: IAdminRepository;
auditService: AdminAuditService;
ipInfoService: IpInfoService;
}
interface AdminBlocklistEntry {
@@ -146,20 +142,6 @@ export class AdminBanManagementService {
message: 'This IP address is on the instance exemption list',
});
}
if (await this.shouldSkipIpBanForCgnat(data.ip)) {
await auditService.createAuditLog({
adminUserId,
targetType: 'ip',
targetId: BigInt(0),
action: 'ban_ip_skipped_cgnat',
auditLogReason,
metadata: new Map([['ip', data.ip]]),
});
throw new BadRequestError({
code: APIErrorCodes.IP_BAN_DECLINED,
message: 'This IP address is a high blast-radius carrier network',
});
}
await adminRepository.banIp(data.ip);
ipBanCache.ban(data.ip);
await cacheService.publish(IP_BAN_REFRESH_CHANNEL, 'refresh');
@@ -200,25 +182,6 @@ export class AdminBanManagementService {
return {banned};
}
private async shouldSkipIpBanForCgnat(ip: string): Promise<boolean> {
if (!isSingleIpBanCandidate(ip)) {
return false;
}
try {
const {cgnat: highRisk} = await getIpBanBlastRadiusVerdict(ip, this.deps.ipInfoService, {
source: 'admin.ip_ban',
reason: 'pre_write_cgnat_guard',
});
if (highRisk) {
Logger.warn({ip}, 'Skipping IP ban because IPInfo indicates high CGNAT blast-radius risk');
}
return highRisk;
} catch (error) {
Logger.warn({error, ip}, 'IPInfo CGNAT guard failed while adding IP ban');
return false;
}
}
async banEmail(
data: {
email: string;
@@ -1,170 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {ApiContext} from '@app/api/ApiContext';
import type {IAdminRepository} from '@app/api/admin/IAdminRepository';
import type {AdminAuditService} from '@app/api/admin/services/AdminAuditService';
import {AdminBanManagementService} from '@app/api/admin/services/AdminBanManagementService';
import {createUserID} from '@app/api/BrandedTypes';
import {resetIpBanExemptionsForTesting} from '@app/api/ban/IpBanExemptions';
import {getConfig} from '@app/api/Config';
import {ipBanCache} from '@app/api/middleware/IpBanMiddleware';
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
import {BadRequestError} from '@fluxer/errors/src/domains/core/BadRequestError';
import type {IpInfoLookupResult, IpInfoService} from '@pkgs/geoip/src/IpInfoService';
import {afterEach, beforeEach, describe, expect, it} from 'vitest';
const ADMIN_ID = createUserID(42n);
const EXEMPT_IP = '10.0.0.1';
const CARRIER_IP = '198.51.100.7';
const LOOKUP_FAILURE_IP = '203.0.113.9';
interface AuditCall {
action: string;
metadata: Map<string, string> | undefined;
}
function ipInfoResult(overrides: Partial<IpInfoLookupResult> = {}): IpInfoLookupResult {
return {
ip: CARRIER_IP,
available: true,
note: 'test',
geo: {
countryCode: 'US',
countryName: 'United States',
continent: 'North America',
continentCode: 'NA',
region: null,
regionCode: null,
city: null,
postalCode: null,
timezone: null,
latitude: null,
longitude: null,
accuracyRadiusKm: null,
},
asn: {
asn: 'AS64500',
number: 64500,
name: 'Test Carrier',
domain: null,
type: null,
},
mobile: {
name: null,
mcc: null,
mnc: null,
},
anonymous: {
isAnonymous: false,
providerName: null,
isVpn: false,
isProxy: false,
isResidentialProxy: false,
isTor: false,
isRelay: false,
percentDaysSeen: null,
},
flags: {
isAnycast: false,
isHosting: false,
isMobile: false,
isSatellite: false,
},
...overrides,
};
}
function createBanManagementService(lookup: (ip: string) => Promise<IpInfoLookupResult>) {
const bannedIps: Array<string> = [];
const auditCalls: Array<AuditCall> = [];
const adminRepository = {
banIp: async (ip: string) => {
bannedIps.push(ip);
},
};
const auditService = {
createAuditLog: async ({action, metadata}: AuditCall) => {
auditCalls.push({action, metadata});
},
};
const ipInfoService = {lookup: (ip: string) => lookup(ip)};
const apiContext = {
services: {
cache: {
publish: async () => {},
},
},
};
const service = new AdminBanManagementService({
apiContext: apiContext as unknown as ApiContext,
adminRepository: adminRepository as unknown as IAdminRepository,
auditService: auditService as unknown as AdminAuditService,
ipInfoService: ipInfoService as unknown as IpInfoService,
});
return {service, bannedIps, auditCalls};
}
describe('AdminBanManagementService banIp guards', () => {
let originalExemptIps: Array<string>;
beforeEach(() => {
const config = getConfig();
originalExemptIps = config.ipBanExemptIps;
config.ipBanExemptIps = [EXEMPT_IP];
resetIpBanExemptionsForTesting();
});
afterEach(() => {
ipBanCache.unban(LOOKUP_FAILURE_IP);
getConfig().ipBanExemptIps = originalExemptIps;
resetIpBanExemptionsForTesting();
});
it('refuses an exempt address with IP_BAN_DECLINED and writes no ban row', async () => {
const {service, bannedIps, auditCalls} = createBanManagementService(async () => ipInfoResult());
const error = await service.banIp({ip: EXEMPT_IP}, ADMIN_ID, null).then(
() => null,
(caught: unknown) => caught,
);
expect(error).toBeInstanceOf(BadRequestError);
expect((error as BadRequestError).code).toBe(APIErrorCodes.IP_BAN_DECLINED);
expect((error as BadRequestError).status).toBe(400);
expect(bannedIps).toEqual([]);
expect(auditCalls.map((call) => call.action)).toEqual(['ban_ip_skipped_exempt']);
expect(auditCalls[0].metadata?.get('ip')).toBe(EXEMPT_IP);
});
it('refuses a high blast-radius carrier address with IP_BAN_DECLINED and writes no ban row', async () => {
const {service, bannedIps, auditCalls} = createBanManagementService(async () =>
ipInfoResult({
mobile: {name: 'Example Mobile', mcc: '001', mnc: '01'},
flags: {isAnycast: false, isHosting: false, isMobile: true, isSatellite: false},
}),
);
const error = await service.banIp({ip: CARRIER_IP}, ADMIN_ID, null).then(
() => null,
(caught: unknown) => caught,
);
expect(error).toBeInstanceOf(BadRequestError);
expect((error as BadRequestError).code).toBe(APIErrorCodes.IP_BAN_DECLINED);
expect((error as BadRequestError).status).toBe(400);
expect(bannedIps).toEqual([]);
expect(auditCalls.map((call) => call.action)).toEqual(['ban_ip_skipped_cgnat']);
expect(auditCalls[0].metadata?.get('ip')).toBe(CARRIER_IP);
});
it('still writes the ban when the IPInfo lookup fails', async () => {
const {service, bannedIps, auditCalls} = createBanManagementService(async () => {
throw new Error('ipinfo is unreachable');
});
await expect(service.banIp({ip: LOOKUP_FAILURE_IP}, ADMIN_ID, null)).resolves.toBeUndefined();
expect(bannedIps).toEqual([LOOKUP_FAILURE_IP]);
expect(auditCalls.map((call) => call.action)).toEqual(['ban_ip']);
});
});
@@ -10,83 +10,24 @@ import {
type TestAccount,
} from '@app/api/auth/tests/AuthTestUtils';
import {createUserID} from '@app/api/BrandedTypes';
import {setInjectedIpInfoService} from '@app/api/middleware/ServiceMiddleware';
import {getAdminRepository} from '@app/api/middleware/ServiceSingletons';
import {type ApiTestHarness, createApiTestHarness} from '@app/api/test/ApiTestHarness';
import {HTTP_STATUS} from '@app/api/test/TestConstants';
import {createBuilder} from '@app/api/test/TestRequestBuilder';
import {UserRepository} from '@app/api/user/repositories/UserRepository';
import {DeletionReasons} from '@fluxer/constants/src/Core';
import type {IpInfoLookupResult} from '@pkgs/geoip/src/IpInfoService';
import {afterEach, beforeEach, describe, expect, test} from 'vitest';
function createUniqueTestIp(): string {
return `198.51.${randomInt(0, 256)}.${randomInt(1, 255)}`;
}
function ipInfoResult(ip: string, overrides: Partial<IpInfoLookupResult> = {}): IpInfoLookupResult {
return {
ip,
available: true,
note: 'test',
geo: {
countryCode: 'US',
countryName: 'United States',
continent: 'North America',
continentCode: 'NA',
region: null,
regionCode: null,
city: null,
postalCode: null,
timezone: null,
latitude: null,
longitude: null,
accuracyRadiusKm: null,
},
asn: {
asn: 'AS64500',
number: 64500,
name: 'Test ISP',
domain: null,
type: null,
},
mobile: {
name: null,
mcc: null,
mnc: null,
},
anonymous: {
isAnonymous: false,
providerName: null,
isVpn: false,
isProxy: false,
isResidentialProxy: false,
isTor: false,
isRelay: false,
percentDaysSeen: null,
},
flags: {
isAnycast: false,
isHosting: false,
isMobile: false,
isSatellite: false,
},
...overrides,
};
}
describe('Admin Deletion Queue', () => {
let harness: ApiTestHarness;
beforeEach(async () => {
harness = await createApiTestHarness();
setInjectedIpInfoService({
async lookup(ip: string) {
return ipInfoResult(ip);
},
});
});
afterEach(async () => {
setInjectedIpInfoService(undefined);
await harness?.shutdown();
});
test('admin scheduling queues deletion and rescheduling replaces the old Cassandra row', async () => {
@@ -0,0 +1,157 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {TestAccount} from '@app/api/auth/tests/AuthTestUtils';
import {createTestAccount, setUserACLs} from '@app/api/auth/tests/AuthTestUtils';
import {getConfig} from '@app/api/Config';
import type {ApiTestHarness} from '@app/api/test/ApiTestHarness';
import {createApiTestHarness} from '@app/api/test/ApiTestHarness';
import {HTTP_STATUS} from '@app/api/test/TestConstants';
import {createBuilder} from '@app/api/test/TestRequestBuilder';
import {AdminACLs} from '@fluxer/constants/src/AdminACLs';
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
import type {LimitConfigSnapshot} from '@fluxer/limits/src/LimitTypes';
import type {InstanceConfigResponse} from '@fluxer/schema/src/domains/admin/AdminSchemas';
import type {GuildResponse} from '@fluxer/schema/src/domains/guild/GuildResponseSchemas';
import {afterAll, beforeAll, beforeEach, describe, expect, it} from 'vitest';
const COMMUNITY_CREATOR_TRAIT = 'community_creator';
interface LimitConfigReadResponse {
limit_config: LimitConfigSnapshot;
}
describe('guild creation access on a self-hosted instance', () => {
let harness: ApiTestHarness;
beforeAll(async () => {
harness = await createApiTestHarness();
});
beforeEach(async () => {
await harness.reset();
});
afterAll(async () => {
await harness.shutdown();
});
const asSelfHosted = async <T>(run: () => Promise<T>): Promise<T> => {
const config = getConfig();
const originalSelfHosted = config.instance.selfHosted;
config.instance.selfHosted = true;
try {
return await run();
} finally {
config.instance.selfHosted = originalSelfHosted;
}
};
const createAdmin = async (): Promise<TestAccount> =>
await setUserACLs(harness, await createTestAccount(harness), [
AdminACLs.AUTHENTICATE,
AdminACLs.INSTANCE_CONFIG_VIEW,
AdminACLs.INSTANCE_CONFIG_UPDATE,
AdminACLs.INSTANCE_LIMIT_CONFIG_VIEW,
AdminACLs.INSTANCE_LIMIT_CONFIG_UPDATE,
AdminACLs.USER_UPDATE_TRAITS,
]);
const createMember = async (): Promise<TestAccount> =>
await setUserACLs(harness, await createTestAccount(harness), []);
const setGuildCreateAccess = async (admin: TestAccount, enabled: boolean): Promise<void> => {
const updated = await createBuilder<InstanceConfigResponse>(harness, admin.token)
.patch('/admin/instance/config')
.body({policy: {guild_create_access: enabled}})
.execute();
expect(updated.policy.guild_create_access).toBe(enabled);
};
const readInstanceConfig = async (admin: TestAccount): Promise<InstanceConfigResponse> =>
await createBuilder<InstanceConfigResponse>(harness, admin.token).get('/admin/instance/config').execute();
const createGuild = (account: TestAccount, name: string) =>
createBuilder<GuildResponse>(harness, account.token).post('/guilds').body({name});
const grantGuildCreateToTrait = async (admin: TestAccount, trait: string): Promise<void> => {
const current = await createBuilder<LimitConfigReadResponse>(harness, admin.token)
.get('/admin/limit-config')
.expect(HTTP_STATUS.OK)
.execute();
await createBuilder(harness, admin.token)
.put('/admin/limit-config')
.body({
limit_config: {
traitDefinitions: [...current.limit_config.traitDefinitions, trait],
rules: [
...current.limit_config.rules,
{id: `grant_${trait}`, filters: {traits: [trait]}, limits: {feature_guild_create: 1}},
],
},
})
.expect(HTTP_STATUS.OK)
.execute();
};
const grantTrait = async (admin: TestAccount, account: TestAccount, trait: string): Promise<void> => {
await createBuilder(harness, admin.token)
.put(`/admin/users/${account.userId}/traits`)
.body({traits: [trait]})
.expect(HTTP_STATUS.OK)
.execute();
};
it('allows guild creation while the community creation policy is at its default', async () => {
const admin = await createAdmin();
expect((await readInstanceConfig(admin)).policy.guild_create_access).toBe(true);
const member = await createMember();
await asSelfHosted(async () => {
const guild = await createGuild(member, 'Default policy community').execute();
expect(guild.id).toBeTruthy();
});
});
it('stores a disabled policy and rejects guild creation for a member without a grant', async () => {
const admin = await createAdmin();
await setGuildCreateAccess(admin, false);
expect((await readInstanceConfig(admin)).policy.guild_create_access).toBe(false);
const member = await createMember();
await asSelfHosted(async () => {
await createGuild(member, 'Denied community')
.expect(HTTP_STATUS.FORBIDDEN, APIErrorCodes.GUILD_CREATION_PERMISSION_REQUIRED)
.execute();
});
});
it('allows guild creation only once a member holds the trait the grant rule targets', async () => {
const admin = await createAdmin();
await setGuildCreateAccess(admin, false);
await grantGuildCreateToTrait(admin, COMMUNITY_CREATOR_TRAIT);
const member = await createMember();
await asSelfHosted(async () => {
await createGuild(member, 'Ungranted community')
.expect(HTTP_STATUS.FORBIDDEN, APIErrorCodes.GUILD_CREATION_PERMISSION_REQUIRED)
.execute();
});
await grantTrait(admin, member, COMMUNITY_CREATOR_TRAIT);
await asSelfHosted(async () => {
const guild = await createGuild(member, 'Granted community').execute();
expect(guild.id).toBeTruthy();
});
});
it('allows guild creation for a member holding a wildcard ACL while the policy is disabled', async () => {
const admin = await createAdmin();
await setGuildCreateAccess(admin, false);
const member = await setUserACLs(harness, await createTestAccount(harness), [AdminACLs.WILDCARD]);
await asSelfHosted(async () => {
const guild = await createGuild(member, 'Wildcard community').execute();
expect(guild.id).toBeTruthy();
});
});
});
+10 -11
View File
@@ -35,6 +35,7 @@ import * as FetchUtils from '@app/api/utils/FetchUtils';
import {isJsonRecord, parseJsonRecord, parseJsonWithGuard} from '@app/api/utils/JsonBoundaryUtils';
import {generateRandomUsername} from '@app/api/utils/UsernameGenerator';
import {deriveUsernameFromDisplayName} from '@app/api/utils/UsernameSuggestionUtils';
import {SSO_MOBILE_CALLBACK_URI, SSO_MOBILE_STATE_PREFIX} from '@fluxer/constants/src/SsoConstants';
import {ProfileFieldPrivacyFlags} from '@fluxer/constants/src/UserConstants';
import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes';
import {RegistrationClosedError} from '@fluxer/errors/src/domains/auth/RegistrationClosedError';
@@ -106,7 +107,6 @@ interface JwksCacheEntry {
const CODE_VERIFIER_BYTE_LENGTH = 32;
const STATE_BYTE_LENGTH = 16;
const NONCE_BYTE_LENGTH = 16;
const MOBILE_SSO_REDIRECT_URI = 'fluxer://auth/sso/callback';
let ssoLogger: ILogger | undefined;
@@ -136,11 +136,10 @@ function buildDiscoveryCacheKey(issuer: string): string {
return `sso:oidc-discovery:${key}`;
}
function resolveSsoRedirectUri(requestedRedirectUri: string | undefined, defaultRedirectUri: string): string {
if (!requestedRedirectUri) return defaultRedirectUri;
const trimmed = requestedRedirectUri.trim();
if (!trimmed) return defaultRedirectUri;
if (trimmed === defaultRedirectUri || trimmed === MOBILE_SSO_REDIRECT_URI) return trimmed;
function isMobileSsoRedirectUri(requestedRedirectUri: string | undefined, defaultRedirectUri: string): boolean {
const trimmed = requestedRedirectUri?.trim();
if (!trimmed || trimmed === defaultRedirectUri) return false;
if (trimmed === SSO_MOBILE_CALLBACK_URI) return true;
throw InputValidationError.fromCode('redirect_uri', ValidationErrorCodes.INVALID_URL_FORMAT);
}
@@ -285,16 +284,16 @@ export class SsoService {
redirect_uri: string;
}> {
const config = await this.requireReadyConfig();
const state = randomHexToken(STATE_BYTE_LENGTH);
const isMobile = isMobileSsoRedirectUri(redirectUri, config.redirectUri);
const state = `${isMobile ? SSO_MOBILE_STATE_PREFIX : ''}${randomHexToken(STATE_BYTE_LENGTH)}`;
const codeVerifier = randomBase64UrlToken(CODE_VERIFIER_BYTE_LENGTH);
const codeChallenge = buildCodeChallenge(codeVerifier);
const nonce = randomBase64UrlToken(NONCE_BYTE_LENGTH);
const ssoRedirectUri = resolveSsoRedirectUri(redirectUri, config.redirectUri);
const statePayload: SsoStatePayload = {
codeVerifier,
nonce,
redirectTo: sanitizeSsoRedirectTo(redirectTo),
redirectUri: ssoRedirectUri,
redirectUri: config.redirectUri,
createdAt: Date.now(),
};
const {cache} = this.apiContext.services;
@@ -302,7 +301,7 @@ export class SsoService {
const searchParams = new URLSearchParams({
response_type: 'code',
client_id: config.clientId ?? '',
redirect_uri: ssoRedirectUri,
redirect_uri: config.redirectUri,
scope: config.scope,
state,
code_challenge: codeChallenge,
@@ -324,7 +323,7 @@ export class SsoService {
throw new FeatureTemporarilyDisabledError();
}
}
return {authorization_url: authorizationUrlString, state, redirect_uri: ssoRedirectUri};
return {authorization_url: authorizationUrlString, state, redirect_uri: config.redirectUri};
}
async completeLogin({code, state, request}: {code: string; state: string; request: Request}): Promise<{
@@ -131,6 +131,7 @@ describe('Auth SSO flow', () => {
.body({redirect_to: '/me'})
.execute();
expect(startData.state).toBeTruthy();
expect(startData.state.startsWith('m.')).toBe(false);
expect(startData.authorization_url).toBeTruthy();
const authUrlString = startData.authorization_url;
expect(authUrlString).toContain(`state=${startData.state}`);
@@ -179,7 +180,8 @@ describe('Auth SSO flow', () => {
expect(startData.redirect_uri).not.toContain('evil.example');
expect(startData.authorization_url).toContain(encodeURIComponent(startData.redirect_uri));
});
it('uses the requested mobile SSO redirect URI without changing the post-login redirect', async () => {
it('routes mobile SSO through the default redirect URI without changing the post-login redirect', async () => {
const status = await createBuilderWithoutAuth<{redirect_uri: string}>(harness).get('/auth/sso/status').execute();
const startData = await createBuilderWithoutAuth<SsoStartResponse>(harness)
.post('/auth/sso/start')
.body({
@@ -187,8 +189,10 @@ describe('Auth SSO flow', () => {
redirect_uri: 'fluxer://auth/sso/callback',
})
.execute();
expect(startData.redirect_uri).toBe('fluxer://auth/sso/callback');
expect(getAuthorizationUrlParam(startData.authorization_url, 'redirect_uri')).toBe('fluxer://auth/sso/callback');
expect(startData.redirect_uri).toBe(status.redirect_uri);
expect(getAuthorizationUrlParam(startData.authorization_url, 'redirect_uri')).toBe(status.redirect_uri);
expect(startData.state.startsWith('m.')).toBe(true);
expect(getAuthorizationUrlParam(startData.authorization_url, 'state')).toBe(startData.state);
const email = `sso-mobile-redirect-${Date.now()}@example.com`;
const completeData = await createBuilderWithoutAuth<SsoCompleteResponse>(harness)
.post('/auth/sso/complete')
-80
View File
@@ -1,80 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {parseIpBanEntry} from '@app/api/utils/IpRangeUtils';
import {getSameIpDecisionKey} from '@fluxer/ip_utils/src/IpAddress';
import type {IpInfoLookupResult, IpInfoService} from '@pkgs/geoip/src/IpInfoService';
const VERDICT_CACHE_TTL_MS = 60 * 60 * 1000;
interface IpBanBlastRadiusVerdict {
cgnat: boolean;
sharedAccess: boolean;
}
interface CachedVerdict {
expiresAtMs: number;
verdict: IpBanBlastRadiusVerdict;
}
const verdictCache = new Map<string, CachedVerdict>();
function isAnonymousAccess(result: IpInfoLookupResult): boolean {
return (
result.anonymous.isAnonymous ||
result.anonymous.isVpn ||
result.anonymous.isProxy ||
result.anonymous.isResidentialProxy ||
result.anonymous.isTor ||
result.anonymous.isRelay
);
}
export function isHighCgnatBlastRadiusRisk(result: IpInfoLookupResult): boolean {
if (!result.available || result.flags.isHosting || isAnonymousAccess(result)) {
return false;
}
const asnType = result.asn.type?.trim().toLowerCase() ?? null;
return result.flags.isMobile || result.mobile.name !== null || asnType === 'mobile';
}
export function isHighSharedAccessBlastRadiusRisk(result: IpInfoLookupResult): boolean {
if (result.flags.isHosting || isAnonymousAccess(result)) {
return false;
}
const asnType = result.asn.type?.trim().toLowerCase() ?? null;
return result.flags.isAnycast || result.flags.isSatellite || asnType === 'education';
}
export function isSingleIpBanCandidate(value: string): boolean {
return parseIpBanEntry(value)?.type === 'single';
}
export async function getIpBanBlastRadiusVerdict(
ip: string,
ipInfoService: IpInfoService,
context: {
source: string;
reason: string;
},
): Promise<IpBanBlastRadiusVerdict> {
const now = Date.now();
const cacheKey = getSameIpDecisionKey(ip) ?? ip;
const cached = verdictCache.get(cacheKey);
if (cached && cached.expiresAtMs > now) {
return cached.verdict;
}
const result = await ipInfoService.lookup(ip, {
source: context.source,
reason: context.reason,
metadata: {policy: 'ip_ban_cgnat_guard'},
});
const verdict: IpBanBlastRadiusVerdict = {
cgnat: isHighCgnatBlastRadiusRisk(result),
sharedAccess: isHighSharedAccessBlastRadiusRisk(result),
};
verdictCache.set(cacheKey, {
verdict,
expiresAtMs: now + VERDICT_CACHE_TTL_MS,
});
return verdict;
}
@@ -1,45 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {Config} from '@app/api/Config';
import {Logger} from '@app/api/Logger';
import {getDefaultCassandraClient} from '@pkgs/cassandra/src/Client';
import {createCassandraIpInfoCache} from '@pkgs/geoip/src/CassandraIpInfoCache';
import {createCassandraIpInfoRequestAuditLogger} from '@pkgs/geoip/src/CassandraIpInfoRequestAudit';
import {type IpInfoCache, type IpInfoRequestAuditLogger, isCachedIpInfoFailure} from '@pkgs/geoip/src/IpInfoService';
import {createPostgresIpInfoCache, createPostgresIpInfoRequestAuditLogger} from '@pkgs/geoip/src/PostgresIpInfoKv';
import {createTieredIpInfoCache} from '@pkgs/geoip/src/TieredIpInfoCache';
import {getDefaultPostgresClient} from '@pkgs/postgres/src/Client';
interface BuildIpInfoCacheOptions {
hot: IpInfoCache;
}
export function buildIpInfoCache(options: BuildIpInfoCacheOptions): IpInfoCache {
if (Config.database.backend === 'postgres') {
return createTieredIpInfoCache({
hot: options.hot,
cold: createPostgresIpInfoCache({
getClient: getDefaultPostgresClient,
onError: (error, operation) => Logger.warn({error, operation}, 'Postgres IPInfo cache operation failed'),
}),
skipColdWrite: isCachedIpInfoFailure,
});
}
return createTieredIpInfoCache({
hot: options.hot,
cold: createCassandraIpInfoCache({getClient: getDefaultCassandraClient}),
skipColdWrite: isCachedIpInfoFailure,
});
}
export function buildIpInfoRequestAuditLogger(): IpInfoRequestAuditLogger {
if (Config.database.backend === 'postgres') {
return createPostgresIpInfoRequestAuditLogger({
getClient: getDefaultPostgresClient,
onError: (error, operation) => Logger.warn({error, operation}, 'Postgres IPInfo audit operation failed'),
});
}
return createCassandraIpInfoRequestAuditLogger({
getClient: getDefaultCassandraClient,
});
}
@@ -1,162 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {
isHighCgnatBlastRadiusRisk,
isHighSharedAccessBlastRadiusRisk,
isSingleIpBanCandidate,
} from '@app/api/ban/IpBanCgnatGuard';
import type {IpInfoLookupResult} from '@pkgs/geoip/src/IpInfoService';
import {describe, expect, it} from 'vitest';
function ipInfoResult(overrides: Partial<IpInfoLookupResult> = {}): IpInfoLookupResult {
return {
ip: '198.51.100.1',
available: true,
note: 'test',
geo: {
countryCode: 'US',
countryName: 'United States',
continent: 'North America',
continentCode: 'NA',
region: null,
regionCode: null,
city: null,
postalCode: null,
timezone: null,
latitude: null,
longitude: null,
accuracyRadiusKm: null,
},
asn: {
asn: 'AS64500',
number: 64500,
name: 'Test ISP',
domain: null,
type: null,
},
mobile: {
name: null,
mcc: null,
mnc: null,
},
anonymous: {
isAnonymous: false,
providerName: null,
isVpn: false,
isProxy: false,
isResidentialProxy: false,
isTor: false,
isRelay: false,
percentDaysSeen: null,
},
flags: {
isAnycast: false,
isHosting: false,
isMobile: false,
isSatellite: false,
},
...overrides,
};
}
describe('IpBanCgnatGuard', () => {
it('only treats single IP ban entries as CGNAT guard candidates', () => {
expect(isSingleIpBanCandidate('198.51.100.10')).toBe(true);
expect(isSingleIpBanCandidate('198.51.100.0/24')).toBe(false);
});
it('flags mobile carrier IPs as high blast-radius risk', () => {
expect(
isHighCgnatBlastRadiusRisk(
ipInfoResult({
mobile: {name: 'Example Mobile', mcc: '001', mnc: '01'},
flags: {isAnycast: false, isHosting: false, isMobile: true, isSatellite: false},
}),
),
).toBe(true);
});
it('does not exempt hosting or anonymous infrastructure', () => {
expect(
isHighCgnatBlastRadiusRisk(
ipInfoResult({
flags: {isAnycast: false, isHosting: true, isMobile: true, isSatellite: false},
}),
),
).toBe(false);
expect(
isHighCgnatBlastRadiusRisk(
ipInfoResult({
anonymous: {
isAnonymous: true,
providerName: 'Example VPN',
isVpn: true,
isProxy: false,
isResidentialProxy: false,
isTor: false,
isRelay: false,
percentDaysSeen: null,
},
flags: {isAnycast: false, isHosting: false, isMobile: true, isSatellite: false},
}),
),
).toBe(false);
});
it('flags satellite, anycast and education networks as high blast-radius risk', () => {
expect(
isHighSharedAccessBlastRadiusRisk(
ipInfoResult({
flags: {isAnycast: false, isHosting: false, isMobile: false, isSatellite: true},
}),
),
).toBe(true);
expect(
isHighSharedAccessBlastRadiusRisk(
ipInfoResult({
flags: {isAnycast: true, isHosting: false, isMobile: false, isSatellite: false},
}),
),
).toBe(true);
expect(
isHighSharedAccessBlastRadiusRisk(
ipInfoResult({asn: {asn: 'AS64500', number: 64500, name: 'Test University', domain: null, type: 'education'}}),
),
).toBe(true);
});
it('does not flag ordinary residential networks as shared-access risk', () => {
expect(isHighSharedAccessBlastRadiusRisk(ipInfoResult())).toBe(false);
});
it('does not treat shared-access networks as CGNAT risk', () => {
expect(
isHighCgnatBlastRadiusRisk(
ipInfoResult({
flags: {isAnycast: false, isHosting: false, isMobile: false, isSatellite: true},
}),
),
).toBe(false);
});
it('does not exempt hosting or anonymous shared-access infrastructure', () => {
expect(
isHighSharedAccessBlastRadiusRisk(
ipInfoResult({
flags: {isAnycast: true, isHosting: true, isMobile: false, isSatellite: false},
}),
),
).toBe(false);
expect(
isHighSharedAccessBlastRadiusRisk(
ipInfoResult({
anonymous: {
isAnonymous: true,
providerName: 'Example VPN',
isVpn: true,
isProxy: false,
isResidentialProxy: false,
isTor: false,
isRelay: false,
percentDaysSeen: null,
},
flags: {isAnycast: false, isHosting: false, isMobile: false, isSatellite: true},
}),
),
).toBe(false);
});
});
@@ -1,210 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {server} from '@app/api/test/msw/server';
import type {
CachedIpInfoFailure,
IpInfoCache,
IpInfoRequestAuditEvent,
IpInfoRequestAuditLogger,
} from '@pkgs/geoip/src/IpInfoService';
import {createIpInfoService} from '@pkgs/geoip/src/IpInfoService';
import {delay, HttpResponse, http} from 'msw';
import {describe, expect, it} from 'vitest';
interface RecordedSet {
key: string;
value: unknown;
ttlSeconds: number | undefined;
}
interface RecordingCache {
cache: IpInfoCache;
sets: Array<RecordedSet>;
}
function createRecordingCache(): RecordingCache {
const store = new Map<string, unknown>();
const sets: Array<RecordedSet> = [];
return {
sets,
cache: {
async get<T>(key: string): Promise<T | null> {
return (store.get(key) as T | undefined) ?? null;
},
async set<T>(key: string, value: T, ttlSeconds?: number): Promise<void> {
store.set(key, value);
sets.push({key, value, ttlSeconds});
},
},
};
}
function createRecordingAuditLogger(): {logger: IpInfoRequestAuditLogger; events: Array<IpInfoRequestAuditEvent>} {
const events: Array<IpInfoRequestAuditEvent> = [];
return {
events,
logger: {
async record(event: IpInfoRequestAuditEvent): Promise<void> {
events.push(event);
},
},
};
}
function useLookupHandler(handler: () => Response | Promise<Response>): {count: () => number} {
let calls = 0;
server.use(
http.get('https://api.ipinfo.io/lookup/:ip', async () => {
calls += 1;
return await handler();
}),
);
return {count: () => calls};
}
function successPayload(ip: string, anonymous: Record<string, boolean> = {}): Response {
return HttpResponse.json({
ip,
geo: {country_code: 'US', country: 'United States'},
as: {asn: 'AS64500', name: 'Test ISP'},
anonymous,
});
}
describe('IpInfoService caching', () => {
it('negative-caches an HTTP error and serves the second lookup without a request', async () => {
const requests = useLookupHandler(() => new HttpResponse(null, {status: 500}));
const {cache, sets} = createRecordingCache();
const service = createIpInfoService({apiKey: 'token', cache});
const first = await service.lookup('203.0.113.1');
const second = await service.lookup('203.0.113.1');
expect(first.available).toBe(false);
expect(second.available).toBe(false);
expect(requests.count()).toBe(1);
expect(sets).toHaveLength(1);
expect(sets[0]?.ttlSeconds).toBe(300);
});
it('negative-caches a request failure for a short window', async () => {
useLookupHandler(async () => {
await delay(5000);
return successPayload('203.0.113.2');
});
const {cache, sets} = createRecordingCache();
const service = createIpInfoService({apiKey: 'token', cache});
const result = await service.lookup('203.0.113.2');
expect(result.available).toBe(false);
expect(sets[0]?.ttlSeconds).toBe(60);
expect((sets[0]?.value as CachedIpInfoFailure)?.failureOutcome).toBe('request_failed');
});
it('negative-caches a schema mismatch', async () => {
useLookupHandler(() => HttpResponse.json({}));
const {cache, sets} = createRecordingCache();
const service = createIpInfoService({apiKey: 'token', cache});
const result = await service.lookup('203.0.113.3');
expect(result.available).toBe(false);
expect(sets[0]?.ttlSeconds).toBe(600);
expect((sets[0]?.value as CachedIpInfoFailure)?.failureOutcome).toBe('schema_mismatch');
expect((sets[0]?.value as CachedIpInfoFailure)?.failureHttpStatus).toBe(200);
});
it('negative-caches a quota rejection for longer', async () => {
useLookupHandler(() => new HttpResponse(null, {status: 429}));
const {cache, sets} = createRecordingCache();
const service = createIpInfoService({apiKey: 'token', cache});
await service.lookup('203.0.113.4');
expect(sets[0]?.ttlSeconds).toBe(900);
});
it('returns a cached failure as a clean unavailable result', async () => {
useLookupHandler(() => new HttpResponse(null, {status: 500}));
const {cache} = createRecordingCache();
const service = createIpInfoService({apiKey: 'token', cache});
await service.lookup('203.0.113.6');
const cached = await service.lookup('203.0.113.6');
expect(cached).not.toHaveProperty('cachedFailure');
expect(cached).not.toHaveProperty('failureOutcome');
expect(cached).not.toHaveProperty('failureHttpStatus');
expect(cached).not.toHaveProperty('cachedAtMs');
expect(cached.ip).toBe('203.0.113.6');
expect(cached.note).toBe('IPInfo HTTP 500');
});
it('writes a cached failure that older readers can still consume', async () => {
useLookupHandler(() => new HttpResponse(null, {status: 500}));
const {cache, sets} = createRecordingCache();
const service = createIpInfoService({apiKey: 'token', cache});
await service.lookup('203.0.113.7');
const entry = sets[0]?.value as CachedIpInfoFailure;
expect(entry.cachedFailure).toBe(true);
expect(entry.failureOutcome).toBe('http_error');
expect(entry.failureHttpStatus).toBe(500);
expect(typeof entry.cachedAtMs).toBe('number');
const legacyView = {...entry, ip: '203.0.113.7'};
expect(legacyView.available).toBe(false);
expect(legacyView.geo.countryCode).toBeNull();
expect(legacyView.asn.number).toBeNull();
expect(legacyView.mobile.name).toBeNull();
expect(legacyView.anonymous.isAnonymous).toBe(false);
expect(legacyView.flags.isMobile).toBe(false);
});
it('keeps the existing success TTL selection', async () => {
useLookupHandler(() => successPayload('203.0.113.8'));
const plain = createRecordingCache();
await createIpInfoService({apiKey: 'token', cache: plain.cache}).lookup('203.0.113.8');
useLookupHandler(() => successPayload('203.0.113.9', {is_vpn: true}));
const anonymous = createRecordingCache();
await createIpInfoService({apiKey: 'token', cache: anonymous.cache}).lookup('203.0.113.9');
expect(plain.sets[0]?.ttlSeconds).toBe(14 * 24 * 60 * 60);
expect(anonymous.sets[0]?.ttlSeconds).toBe(7 * 24 * 60 * 60);
});
it('coalesces concurrent lookups across a failure', async () => {
const requests = useLookupHandler(() => new HttpResponse(null, {status: 500}));
const {cache, sets} = createRecordingCache();
const service = createIpInfoService({apiKey: 'token', cache});
const [first, second] = await Promise.all([service.lookup('203.0.113.10'), service.lookup('203.0.113.10')]);
expect(requests.count()).toBe(1);
expect(sets).toHaveLength(1);
expect(first.available).toBe(false);
expect(second.available).toBe(false);
});
it('coalesces concurrent lookups from different sources into one audited request', async () => {
const requests = useLookupHandler(() => successPayload('203.0.113.13'));
const {cache} = createRecordingCache();
const {logger, events} = createRecordingAuditLogger();
const service = createIpInfoService({apiKey: 'token', cache, auditLogger: logger});
const results = await Promise.all([
service.lookup('203.0.113.13', {source: 'admin.ip_ban', reason: 'ban'}),
service.lookup('203.0.113.13', {source: 'test.b'}),
service.lookup('203.0.113.13', {source: 'test.c'}),
]);
expect(requests.count()).toBe(1);
expect(results.every((result) => result.available)).toBe(true);
expect(events).toHaveLength(1);
expect(events[0]?.source).toBe('admin.ip_ban');
expect(events[0]?.outcome).toBe('http_success');
expect(events[0]?.note).toBe('IPInfo: IP is not anonymous');
});
});
@@ -1,75 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {IpInfoRequestAuditEvent} from '@pkgs/geoip/src/IpInfoService';
import {
createPostgresIpInfoCache,
createPostgresIpInfoRequestAuditLogger,
IPINFO_CACHE_TTL_SECONDS,
IPINFO_REQUEST_AUDIT_TTL_SECONDS,
} from '@pkgs/geoip/src/PostgresIpInfoKv';
import type {IPostgresClient} from '@pkgs/postgres/src/Client';
import {describe, expect, it} from 'vitest';
function recordingClient(writes: Array<Array<unknown>>): IPostgresClient {
return {
async query(_text: string, values?: Array<unknown>) {
writes.push(values ?? []);
return {rows: [], rowCount: 1};
},
kvTable() {
return 'kv';
},
} as never;
}
function expectExpiresIn(values: Array<unknown> | undefined, ttlSeconds: number): void {
const expiresAt = values?.[4];
expect(expiresAt).toBeInstanceOf(Date);
const remainingSeconds = ((expiresAt as Date).getTime() - Date.now()) / 1000;
expect(remainingSeconds).toBeGreaterThan(ttlSeconds - 10);
expect(remainingSeconds).toBeLessThanOrEqual(ttlSeconds);
}
const EVENT: IpInfoRequestAuditEvent = {
requestedAt: new Date('2026-09-21T12:00:00.000Z'),
ip: '192.0.2.1',
cacheKey: 'ip:192.0.2.1',
source: 'test',
reason: null,
outcome: 'http_success',
httpStatus: 200,
available: true,
note: 'none',
latencyMs: 12,
requestUrl: 'https://ipinfo.test/192.0.2.1',
responseIp: '192.0.2.1',
countryCode: 'SE',
asnNumber: 64500,
isAnonymous: false,
isTor: false,
isVpn: false,
isProxy: false,
isResidentialProxy: false,
};
describe('Postgres ipinfo KV expiry', () => {
it('expires request audit rows after 90 days', async () => {
const writes: Array<Array<unknown>> = [];
await createPostgresIpInfoRequestAuditLogger({client: recordingClient(writes)}).record(EVENT);
expect(writes).toHaveLength(1);
expect(writes[0]?.[0]).toBe('ipinfo_requests_by_hour');
expectExpiresIn(writes[0], IPINFO_REQUEST_AUDIT_TTL_SECONDS);
});
it('falls back to the 14-day cache default', async () => {
const writes: Array<Array<unknown>> = [];
const cache = createPostgresIpInfoCache({client: recordingClient(writes)});
await cache.set('fallback', {ok: true});
await cache.set('zero', {ok: true}, 0);
await cache.set('short', {ok: true}, 60);
expect(writes.map((values) => values[0])).toEqual(['ipinfo_cache', 'ipinfo_cache', 'ipinfo_cache']);
expectExpiresIn(writes[0], IPINFO_CACHE_TTL_SECONDS);
expectExpiresIn(writes[1], IPINFO_CACHE_TTL_SECONDS);
expectExpiresIn(writes[2], 60);
});
});
@@ -1,130 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {IpInfoCache} from '@pkgs/geoip/src/IpInfoService';
import {createTieredIpInfoCache} from '@pkgs/geoip/src/TieredIpInfoCache';
import {describe, expect, it} from 'vitest';
interface RecordedSet {
key: string;
value: unknown;
ttlSeconds: number | undefined;
}
interface RecordingCache {
cache: IpInfoCache;
store: Map<string, unknown>;
sets: Array<RecordedSet>;
}
function createRecordingCache(): RecordingCache {
const store = new Map<string, unknown>();
const sets: Array<RecordedSet> = [];
return {
store,
sets,
cache: {
async get<T>(key: string): Promise<T | null> {
return (store.get(key) as T | undefined) ?? null;
},
async set<T>(key: string, value: T, ttlSeconds?: number): Promise<void> {
store.set(key, value);
sets.push({key, value, ttlSeconds});
},
},
};
}
describe('TieredIpInfoCache', () => {
it('clamps the hot TTL to the requested TTL and passes the raw TTL to the cold tier', async () => {
const hot = createRecordingCache();
const cold = createRecordingCache();
const tiered = createTieredIpInfoCache({hot: hot.cache, cold: cold.cache});
await tiered.set('a', {available: false}, 60);
expect(hot.sets).toEqual([{key: 'a', value: {available: false}, ttlSeconds: 60}]);
expect(cold.sets).toEqual([{key: 'a', value: {available: false}, ttlSeconds: 60}]);
});
it('caps the hot TTL at the configured hot window', async () => {
const hot = createRecordingCache();
const cold = createRecordingCache();
const tiered = createTieredIpInfoCache({hot: hot.cache, cold: cold.cache});
await tiered.set('a', {available: true}, 100000);
expect(hot.sets[0]?.ttlSeconds).toBe(600);
expect(cold.sets[0]?.ttlSeconds).toBe(100000);
});
it('uses the hot window when no TTL is supplied', async () => {
const hot = createRecordingCache();
const cold = createRecordingCache();
const tiered = createTieredIpInfoCache({hot: hot.cache, cold: cold.cache});
await tiered.set('a', {available: true});
expect(hot.sets[0]?.ttlSeconds).toBe(600);
expect(cold.sets[0]?.ttlSeconds).toBeUndefined();
});
it('skips the cold write when skipColdWrite matches', async () => {
const hot = createRecordingCache();
const cold = createRecordingCache();
const tiered = createTieredIpInfoCache({
hot: hot.cache,
cold: cold.cache,
skipColdWrite: (value) => (value as {available?: unknown}).available === false,
});
await tiered.set('a', {available: false}, 60);
await tiered.set('b', {available: true}, 60);
expect(hot.sets.map((entry) => entry.key)).toEqual(['a', 'b']);
expect(cold.sets.map((entry) => entry.key)).toEqual(['b']);
});
it('promotes a cold hit into the hot tier', async () => {
const hot = createRecordingCache();
const cold = createRecordingCache();
cold.store.set('a', {available: true});
const tiered = createTieredIpInfoCache({hot: hot.cache, cold: cold.cache});
const hit = await tiered.get('a');
expect(hit).toEqual({available: true});
expect(hot.sets).toEqual([{key: 'a', value: {available: true}, ttlSeconds: 600}]);
});
it('never promotes a cold hit that skipColdWrite matches', async () => {
const hot = createRecordingCache();
const cold = createRecordingCache();
cold.store.set('a', {available: false});
const tiered = createTieredIpInfoCache({
hot: hot.cache,
cold: cold.cache,
skipColdWrite: (value) => (value as {available?: unknown}).available === false,
});
const hit = await tiered.get('a');
expect(hit).toEqual({available: false});
expect(hot.sets).toEqual([]);
});
it('never writes a zero TTL', async () => {
const hot = createRecordingCache();
const cold = createRecordingCache();
const tiered = createTieredIpInfoCache({hot: hot.cache, cold: cold.cache});
await tiered.set('a', {available: false}, 60);
await tiered.set('b', {available: true}, 100000);
await tiered.set('c', {available: true});
cold.store.set('d', {available: true});
await tiered.get('d');
for (const entry of [...hot.sets, ...cold.sets]) {
expect(entry.ttlSeconds === undefined || entry.ttlSeconds > 0).toBe(true);
}
});
});
@@ -9,6 +9,7 @@ import {
deleteChannel,
getChannel,
updateChannel,
updateGuild,
} from '@app/api/channel/tests/ChannelTestUtils';
import {type ApiTestHarness, createApiTestHarness} from '@app/api/test/ApiTestHarness';
import {HTTP_STATUS} from '@app/api/test/TestConstants';
@@ -58,6 +59,34 @@ describe('Channel Operation Permissions', () => {
.expect(HTTP_STATUS.FORBIDDEN)
.execute();
});
it('should gate channels created before a guild becomes adult-only', async () => {
const owner = await createTestAccount(harness);
const minor = await createTestAccount(harness, {dateOfBirth: '2010-01-01'});
const guild = await createGuild(harness, owner.token, 'Later Mature Guild');
const category = await createChannel(harness, owner.token, guild.id, 'category', 4);
const child = await createBuilder<{id: string; nsfw_override?: boolean | null}>(harness, owner.token)
.post(`/guilds/${guild.id}/channels`)
.body({name: 'child', type: 0, parent_id: category.id})
.execute();
const opened = await createBuilder<{id: string}>(harness, owner.token)
.post(`/guilds/${guild.id}/channels`)
.body({name: 'opened', type: 0, nsfw_override: false})
.execute();
const systemChannel = await getChannel(harness, owner.token, guild.system_channel_id!);
expect(systemChannel.nsfw_override ?? null).toBeNull();
expect(category.nsfw_override ?? null).toBeNull();
expect(child.nsfw_override ?? null).toBeNull();
const invite = await createChannelInvite(harness, owner.token, systemChannel.id);
await acceptInvite(harness, minor.token, invite.code);
await updateGuild(harness, owner.token, guild.id, {nsfw: true});
for (const channelId of [systemChannel.id, child.id]) {
await createBuilder(harness, minor.token)
.get(`/channels/${channelId}/messages`)
.expect(HTTP_STATUS.FORBIDDEN)
.execute();
}
await createBuilder(harness, minor.token).get(`/channels/${opened.id}/messages`).expect(HTTP_STATUS.OK).execute();
});
it('should reject member from updating channel without MANAGE_CHANNELS', async () => {
const owner = await createTestAccount(harness);
const member = await createTestAccount(harness);
-3
View File
@@ -164,9 +164,6 @@ export interface APIConfig {
secure: boolean;
};
};
ipinfo: {
apiKey?: string;
};
blocklistFeeds: {
enabled: boolean;
};
@@ -6,7 +6,6 @@ import {fileURLToPath} from 'node:url';
import {DEFAULT_TTL_TABLES} from '@app/api/database/PostgresKvDefaultTtlExpiry';
import * as DonationTables from '@app/api/donation/DonationTables';
import * as Tables from '@app/api/Tables';
import {IPINFO_CACHE_TTL_SECONDS, IPINFO_REQUEST_AUDIT_TTL_SECONDS} from '@pkgs/geoip/src/PostgresIpInfoKv';
import {describe, expect, it} from 'vitest';
const THIS_DIR = path.dirname(fileURLToPath(import.meta.url));
@@ -32,8 +31,6 @@ const DSL_TABLES = [...Object.values(Tables), ...Object.values(DonationTables)];
const DSL_NAMES = new Set<string>(DSL_TABLES.map((table) => table.name));
const NON_DSL_DEFAULTS: Record<string, number | null> = {
ipinfo_cache: IPINFO_CACHE_TTL_SECONDS,
ipinfo_requests_by_hour: IPINFO_REQUEST_AUDIT_TTL_SECONDS,
billing_webhook_events: null,
forensic_identifier_by_key_day: null,
forensic_identifier_by_request: null,
@@ -333,7 +333,7 @@ RETURNING updated_at::text`,
await seed('attachment_upload_traces_by_key', 'at-29', '29 days');
await seed('oauth2_access_tokens', 'oa-8', '8 days');
await seed('donor_magic_link_tokens', 'dm-hour', '1 hour');
await seed('ipinfo_requests_by_hour', 'ip-day', '1 day');
await seed('push_subscriptions', 'ps-day', '1 day');
await seed('jobs_by_id', 'job', '100 days');
await seed('users', 'user', '100 days');
await seed('recent_mentions', 'rm-forever', '1 day', 'infinity');
@@ -346,8 +346,8 @@ RETURNING updated_at::text`,
});
expect(await remaining()).toEqual([
{table_name: 'attachment_upload_traces_by_key', row_key: 'at-29'},
{table_name: 'ipinfo_requests_by_hour', row_key: 'ip-day'},
{table_name: 'jobs_by_id', row_key: 'job'},
{table_name: 'push_subscriptions', row_key: 'ps-day'},
{table_name: 'recent_mentions', row_key: 'rm-day'},
{table_name: 'recent_mentions', row_key: 'rm-forever'},
{table_name: 'recent_mentions', row_key: 'rm-hour'},
@@ -359,13 +359,13 @@ RETURNING updated_at::text`,
expires_at = updated_at + CASE table_name WHEN 'recent_mentions' THEN interval '7 days' WHEN 'attachment_upload_traces_by_key' THEN interval '30 days' ELSE interval '90 days' END AS exact,
CASE WHEN row_key = 'rm-day' THEN updated_at = $1::timestamptz END AS unchanged
FROM ${KV_TABLE}
WHERE row_key IN ('rm-day', 'at-29', 'ip-day')
WHERE row_key IN ('rm-day', 'at-29', 'ps-day')
ORDER BY row_key`,
[mentionWrittenAt],
);
expect(exact.rows).toEqual([
{row_key: 'at-29', exact: true, unchanged: null},
{row_key: 'ip-day', exact: true, unchanged: null},
{row_key: 'ps-day', exact: true, unchanged: null},
{row_key: 'rm-day', exact: true, unchanged: true},
]);
const untouched = await raw.query<{row_key: string; state: string}>(
@@ -449,18 +449,18 @@ FROM generate_series(1, 2300) g`,
});
it('saves where a run stopped and starts the next run there', async () => {
const first = DEFAULT_TTL_TABLES[0]!.name;
const last = DEFAULT_TTL_TABLES.at(-1)!.name;
await seed(first, 'a', '1 hour');
await seed(first, 'z', '1 hour');
await seed(last, 'k', '1 hour');
const first = DEFAULT_TTL_TABLES[0]!;
const last = DEFAULT_TTL_TABLES.at(-1)!;
await seed(first.name, 'a', `${first.defaultTtlSeconds / 2} seconds`);
await seed(first.name, 'z', `${first.defaultTtlSeconds / 2} seconds`);
await seed(last.name, 'k', `${last.defaultTtlSeconds / 2} seconds`);
expect(await expireLegacyDefaultTtlRows(raw, Date.now() - 1)).toEqual({deleted: 0, expiring: 0, complete: false});
expect(await resumePoint()).toEqual({table: first, row_key: '', unset: 0});
expect(await resumePoint()).toEqual({table: first.name, row_key: '', unset: 0});
await raw.query(
`UPDATE ${KV_TABLE} SET row_data = jsonb_build_object('table', $1::text, 'row_key', 'm', 'unset', 0) WHERE table_name = '__fluxer_schema_migrations' AND row_key = $2`,
[first, DEFAULT_TTL_EXPIRY_RESUME],
[first.name, DEFAULT_TTL_EXPIRY_RESUME],
);
expect(await expireLegacyDefaultTtlRows(raw, Date.now() + 60_000)).toEqual({
deleted: 0,
@@ -469,7 +469,7 @@ FROM generate_series(1, 2300) g`,
});
const untouched = await raw.query<{expires_at: Date | null}>(
`SELECT expires_at FROM ${KV_TABLE} WHERE table_name = $1 AND row_key = 'a'`,
[first],
[first.name],
);
expect(untouched.rows).toEqual([{expires_at: null}]);
expect(await resumePoint()).toBeNull();
@@ -7,7 +7,6 @@ import {
} from '@app/api/database/PostgresKvQueryExecutor';
import * as DonationTables from '@app/api/donation/DonationTables';
import * as Tables from '@app/api/Tables';
import {IPINFO_CACHE_TTL_SECONDS, IPINFO_REQUEST_AUDIT_TTL_SECONDS} from '@pkgs/geoip/src/PostgresIpInfoKv';
import {type IPostgresClient, quoteIdentifier} from '@pkgs/postgres/src/Client';
import {ms} from 'itty-time';
@@ -23,8 +22,6 @@ export const DEFAULT_TTL_TABLES: ReadonlyArray<{name: string; defaultTtlSeconds:
? []
: [{name: table.name, defaultTtlSeconds: table.defaultTtlSeconds}],
),
{name: 'ipinfo_cache', defaultTtlSeconds: IPINFO_CACHE_TTL_SECONDS},
{name: 'ipinfo_requests_by_hour', defaultTtlSeconds: IPINFO_REQUEST_AUDIT_TTL_SECONDS},
];
export interface LegacyDefaultTtlExpiryResult {
@@ -3,6 +3,9 @@
import {requireEmailVerified} from '@app/api/auth/EmailVerificationUtils';
import {requireSudoMode} from '@app/api/auth/services/SudoVerificationService';
import {createGuildID} from '@app/api/BrandedTypes';
import {Config} from '@app/api/Config';
import {resolveLimitSafe} from '@app/api/limits/LimitConfigUtils';
import {createLimitMatchContext} from '@app/api/limits/LimitMatchContextBuilder';
import {LoginRequired} from '@app/api/middleware/AuthMiddleware';
import {requireOAuth2ScopeForBearer} from '@app/api/middleware/OAuth2ScopeMiddleware';
import {RateLimitMiddleware} from '@app/api/middleware/RateLimitMiddleware';
@@ -11,6 +14,8 @@ import {SudoModeMiddleware} from '@app/api/middleware/SudoModeMiddleware';
import {RateLimitConfigs} from '@app/api/RateLimitConfig';
import type {HonoApp} from '@app/api/types/HonoEnv';
import {Validator} from '@app/api/Validator';
import {AdminACLs} from '@fluxer/constants/src/AdminACLs';
import {GuildCreationPermissionRequiredError} from '@fluxer/errors/src/domains/guild/GuildCreationPermissionRequiredError';
import {SingleCommunityCannotCreateGuildsError} from '@fluxer/errors/src/domains/guild/SingleCommunityCannotCreateGuildsError';
import {SingleCommunityCannotDeleteError} from '@fluxer/errors/src/domains/guild/SingleCommunityCannotDeleteError';
import {SingleCommunityCannotLeaveError} from '@fluxer/errors/src/domains/guild/SingleCommunityCannotLeaveError';
@@ -40,7 +45,8 @@ export function GuildBaseController(app: HonoApp) {
OpenAPI({
operationId: 'create_guild',
summary: 'Create guild',
description: 'Only claimed, email-verified non-bot users can create guilds.',
description:
'Only claimed, email-verified non-bot users can create guilds. A self-hosted instance can restrict creation to admins and users granted the feature_guild_create limit.',
responseSchema: GuildResponse,
statusCode: 200,
security: ['bearerToken', 'sessionToken'],
@@ -56,6 +62,19 @@ export function GuildBaseController(app: HonoApp) {
if (!user.isUnclaimedAccount()) {
requireEmailVerified(user, 'guild_creation');
}
if (Config.instance.selfHosted && !policy.guild_create_access) {
const granted =
user.acls.has(AdminACLs.WILDCARD) ||
resolveLimitSafe(
ctx.get('limitConfigService').getConfigSnapshot(),
createLimitMatchContext({user}),
'feature_guild_create',
0,
) > 0;
if (!granted) {
throw new GuildCreationPermissionRequiredError();
}
}
const auditLogReason = ctx.get('auditLogReason') ?? null;
const locale = ctx.get('requestLocale') ?? null;
return ctx.json(await ctx.get('guildService').data.createGuild({user, data, locale}, auditLogReason));
@@ -24,7 +24,6 @@ import type {JoinSourceType} from '@fluxer/constants/src/GuildConstants';
import {UnknownGuildMemberError} from '@fluxer/errors/src/domains/guild/UnknownGuildMemberError';
import type {GuildMemberResponse} from '@fluxer/schema/src/domains/guild/GuildMemberSchemas';
import type {GuildMemberUpdateRequest} from '@fluxer/schema/src/domains/guild/GuildRequestSchemas';
import type {IpInfoService} from '@pkgs/geoip/src/IpInfoService';
import type {IRateLimitService} from '@pkgs/rate_limit/src/IRateLimitService';
export class GuildMemberService {
@@ -47,11 +46,10 @@ export class GuildMemberService {
rateLimitService: IRateLimitService,
private readonly guildAuditLogService: GuildAuditLogService,
limitConfigService: LimitConfigService,
ipInfoService: IpInfoService,
) {
this.userRepository = userRepository;
this.authService = new GuildMemberAuthService(gatewayService, userRepository);
this.validationService = new GuildMemberValidationService(guildRepository, userRepository, ipInfoService);
this.validationService = new GuildMemberValidationService(guildRepository, userRepository);
this.auditService = new GuildMemberAuditService(guildAuditLogService);
this.eventService = new GuildMemberEventService(gatewayService, userCacheService);
this.searchIndexService = new GuildMemberSearchIndexService();
@@ -1,7 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {GuildID, UserID} from '@app/api/BrandedTypes';
import {getIpBanBlastRadiusVerdict, isSingleIpBanCandidate} from '@app/api/ban/IpBanCgnatGuard';
import {isIpBanExempt} from '@app/api/ban/IpBanExemptions';
import type {GuildAuditLogService} from '@app/api/guild/GuildAuditLogService';
import type {GuildAuditLogChange} from '@app/api/guild/GuildAuditLogTypes';
@@ -27,7 +26,6 @@ import {UnknownGuildMemberError} from '@fluxer/errors/src/domains/guild/UnknownG
import {UnknownUserError} from '@fluxer/errors/src/domains/user/UnknownUserError';
import {isSameIpDecisionMatch} from '@fluxer/ip_utils/src/IpAddress';
import type {GuildBanResponse} from '@fluxer/schema/src/domains/guild/GuildMemberSchemas';
import type {IpInfoService} from '@pkgs/geoip/src/IpInfoService';
import type {IWorkerService} from '@pkgs/worker/src/contracts/IWorkerService';
const SECONDS_PER_DAY = 86_400;
@@ -42,7 +40,6 @@ export class GuildModerationService {
private readonly userCacheService: UserCacheService,
private readonly workerService: IWorkerService<WorkerTaskName>,
private readonly guildAuditLogService: GuildAuditLogService,
private readonly ipInfoService: IpInfoService,
) {
this.searchIndexService = new GuildMemberSearchIndexService();
}
@@ -218,7 +215,7 @@ export class GuildModerationService {
const userEmail = user?.email?.toLowerCase();
for (const ban of bans) {
if (ban.userId === userId) throw new BannedFromGuildError();
if (isSameIpDecisionMatch(userIp, ban.ipAddress) && (await this.shouldEnforceIpBan(userIp, ban.ipAddress))) {
if (isSameIpDecisionMatch(userIp, ban.ipAddress) && !isIpBanExempt(userIp)) {
throw new IpBannedFromGuildError();
}
}
@@ -228,35 +225,6 @@ export class GuildModerationService {
}
}
private async shouldEnforceIpBan(
userIp: string | null | undefined,
bannedIp: string | null | undefined,
): Promise<boolean> {
if (isIpBanExempt(userIp)) {
return false;
}
if (!userIp || !bannedIp || !isSingleIpBanCandidate(bannedIp)) {
return true;
}
try {
const {cgnat, sharedAccess} = await getIpBanBlastRadiusVerdict(userIp, this.ipInfoService, {
source: 'guild.ip_ban',
reason: 'join_cgnat_guard',
});
const highRisk = cgnat || sharedAccess;
if (highRisk) {
Logger.warn(
{userIp, bannedIp},
'Skipping guild IP ban match because IPInfo indicates high shared-network blast-radius risk',
);
}
return !highRisk;
} catch (error) {
Logger.warn({error, userIp, bannedIp}, 'IPInfo blast-radius guard failed while checking guild IP ban');
return true;
}
}
private serializeBanForAudit(ban: GuildBan): Record<string, unknown> {
return {
user_id: ban.userId.toString(),
@@ -58,7 +58,6 @@ import type {
import type {GuildUpdateRequest} from '@fluxer/schema/src/domains/guild/GuildRequestSchemas';
import type {GuildResponse} from '@fluxer/schema/src/domains/guild/GuildResponseSchemas';
import type {ICacheService} from '@pkgs/cache/src/ICacheService';
import type {IpInfoService} from '@pkgs/geoip/src/IpInfoService';
interface StoredAuditLogWebhookResponse extends Omit<AuditLogWebhookResponse, 'type'> {
type: number;
@@ -113,7 +112,6 @@ export class GuildService {
webhookRepository: IWebhookRepository,
guildAuditLogService: GuildAuditLogService,
limitConfigService: LimitConfigService,
ipInfoService: IpInfoService,
) {
const {
cache: cacheService,
@@ -153,7 +151,6 @@ export class GuildService {
rateLimitService,
guildAuditLogService,
limitConfigService,
ipInfoService,
);
this.roles = new GuildRoleService(
guildRepository,
@@ -171,7 +168,6 @@ export class GuildService {
userCacheService,
workerService,
guildAuditLogService,
ipInfoService,
);
this.content = new GuildContentService(
guildRepository,
@@ -134,7 +134,7 @@ export class ChannelOperationsService {
}
}
const requestedNsfwOverride =
params.data.nsfw_override !== undefined ? params.data.nsfw_override : (params.data.nsfw ?? null);
params.data.nsfw_override !== undefined ? params.data.nsfw_override : params.data.nsfw === true ? true : null;
const requestedContentWarningLevel =
params.data.content_warning_level === ContentWarningLevel.CONTENT_WARNING
? ContentWarningLevel.CONTENT_WARNING
@@ -828,7 +828,7 @@ export class GuildOperationsService {
position,
owner_id: null,
recipient_ids: null,
nsfw: false,
nsfw: null,
content_warning_level: null,
content_warning_text: null,
rate_limit_per_user: 0,
@@ -1048,7 +1048,7 @@ export class GuildOperationsService {
position: channel.position,
owner_id: null,
recipient_ids: null,
nsfw: channel.nsfw ?? false,
nsfw: channel.nsfw === true ? true : null,
content_warning_level: null,
content_warning_text: null,
rate_limit_per_user: channel.rate_limit_per_user ?? 0,
@@ -1100,7 +1100,7 @@ export class GuildOperationsService {
position: 0,
owner_id: null,
recipient_ids: null,
nsfw: false,
nsfw: null,
content_warning_level: null,
content_warning_text: null,
rate_limit_per_user: 0,
@@ -2,10 +2,8 @@
import type {GuildID, RoleID, UserID} from '@app/api/BrandedTypes';
import {guildIdToRoleId} from '@app/api/BrandedTypes';
import {getIpBanBlastRadiusVerdict, isSingleIpBanCandidate} from '@app/api/ban/IpBanCgnatGuard';
import {isIpBanExempt} from '@app/api/ban/IpBanExemptions';
import type {IGuildRepositoryAggregate} from '@app/api/guild/repositories/IGuildRepositoryAggregate';
import {Logger} from '@app/api/Logger';
import type {GuildMember} from '@app/api/models/GuildMember';
import type {IUserRepository} from '@app/api/user/IUserRepository';
import {Permissions} from '@fluxer/constants/src/ChannelConstants';
@@ -17,7 +15,6 @@ import {IpBannedFromGuildError} from '@fluxer/errors/src/domains/guild/IpBannedF
import {UnknownGuildRoleError} from '@fluxer/errors/src/domains/guild/UnknownGuildRoleError';
import {isSameIpDecisionMatch} from '@fluxer/ip_utils/src/IpAddress';
import type {GuildResponse} from '@fluxer/schema/src/domains/guild/GuildResponseSchemas';
import type {IpInfoService} from '@pkgs/geoip/src/IpInfoService';
function ensureNotEveryoneRole(roleId: RoleID, guildId: GuildID, path: string): void {
if (roleId === guildIdToRoleId(guildId)) {
@@ -29,7 +26,6 @@ export class GuildMemberValidationService {
constructor(
private readonly guildRepository: IGuildRepositoryAggregate,
private readonly userRepository: IUserRepository,
private readonly ipInfoService: IpInfoService,
) {}
async validateAndGetRoleIds(params: {
@@ -102,38 +98,9 @@ export class GuildMemberValidationService {
if (ban.userId === userId) {
throw new BannedFromGuildError();
}
if (isSameIpDecisionMatch(userIp, ban.ipAddress) && (await this.shouldEnforceIpBan(userIp, ban.ipAddress))) {
if (isSameIpDecisionMatch(userIp, ban.ipAddress) && !isIpBanExempt(userIp)) {
throw new IpBannedFromGuildError();
}
}
}
private async shouldEnforceIpBan(
userIp: string | null | undefined,
bannedIp: string | null | undefined,
): Promise<boolean> {
if (isIpBanExempt(userIp)) {
return false;
}
if (!userIp || !bannedIp || !isSingleIpBanCandidate(bannedIp)) {
return true;
}
try {
const {cgnat, sharedAccess} = await getIpBanBlastRadiusVerdict(userIp, this.ipInfoService, {
source: 'guild.member_ip_ban',
reason: 'join_cgnat_guard',
});
const highRisk = cgnat || sharedAccess;
if (highRisk) {
Logger.warn(
{userIp, bannedIp},
'Skipping guild member IP ban match because IPInfo indicates high shared-network blast-radius risk',
);
}
return !highRisk;
} catch (error) {
Logger.warn({error, userIp, bannedIp}, 'IPInfo CGNAT guard failed while checking guild member IP ban');
return true;
}
}
}
@@ -2,6 +2,7 @@
export const GatewayRpcMethodErrorCodes = {
OVERLOADED: 'overloaded',
GUILD_OVERLOADED: 'guild_overloaded',
INTERNAL_ERROR: 'internal_error',
TIMEOUT: 'timeout',
NO_RESPONDERS: 'no_responders',
@@ -296,6 +296,9 @@ export class GatewayService {
if (error.code === GatewayRpcMethodErrorCodes.TIMEOUT) {
return new GatewayTimeoutError();
}
if (error.code === GatewayRpcMethodErrorCodes.GUILD_OVERLOADED) {
return new ServiceUnavailableError({headers: {'Retry-After': '1'}});
}
if (error.code === GatewayRpcMethodErrorCodes.OVERLOADED) {
return new ServiceUnavailableError();
}
@@ -8,6 +8,7 @@ import type {IGatewayRpcTransport} from '@app/api/infrastructure/IGatewayRpcTran
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
import {BadGatewayError} from '@fluxer/errors/src/domains/core/BadGatewayError';
import {BadRequestError} from '@fluxer/errors/src/domains/core/BadRequestError';
import {ServiceUnavailableError} from '@fluxer/errors/src/domains/core/ServiceUnavailableError';
import {UnknownGuildError} from '@fluxer/errors/src/domains/guild/UnknownGuildError';
import {afterEach, describe, expect, it} from 'vitest';
@@ -70,4 +71,30 @@ describe('GatewayService gateway error mapping', () => {
expect((error as BadRequestError).status).toBe(400);
expect((error as BadRequestError).code).toBe(APIErrorCodes.INVALID_FORM_BODY);
});
it('returns 503 with Retry-After for an overloaded guild', async () => {
const service = serviceRaising(GatewayRpcMethodErrorCodes.GUILD_OVERLOADED);
const error = await service
.getUserPermissions({guildId: createGuildID(1n), userId: createUserID(2n)})
.catch((raised: unknown) => raised);
expect(error).toBeInstanceOf(ServiceUnavailableError);
const response = (error as ServiceUnavailableError).getResponse();
expect(response.status).toBe(503);
expect(response.headers.get('Retry-After')).toBe('1');
});
it('does not retry a guild overload response', async () => {
let calls = 0;
const client = GatewayRpcClient.createForTests({
async call(): Promise<unknown> {
calls += 1;
throw new GatewayRpcMethodError(GatewayRpcMethodErrorCodes.GUILD_OVERLOADED);
},
async destroy(): Promise<void> {},
});
await expect(client.call('guild.dispatch', {guild_id: '1'})).rejects.toMatchObject({
code: GatewayRpcMethodErrorCodes.GUILD_OVERLOADED,
});
expect(calls).toBe(1);
});
});
@@ -173,6 +173,7 @@ export interface InstancePolicyConfig {
direct_messages_disabled: boolean;
direct_messages_locked: boolean;
premium_mode: InstancePremiumMode;
guild_create_access: boolean;
gif_enabled: boolean | null;
youtube_enabled: boolean | null;
bluesky_enabled: boolean | null;
@@ -647,6 +648,7 @@ const StoredInstancePolicySchema = z.object({
direct_messages_disabled: InstancePolicyUpdateSchema.shape.direct_messages_disabled.default(false),
direct_messages_locked: z.boolean().default(false),
premium_mode: InstancePolicyUpdateSchema.shape.premium_mode.default('everyone'),
guild_create_access: InstancePolicyUpdateSchema.shape.guild_create_access.default(true),
gif_enabled: InstancePolicyServiceUpdateSchema.shape.gif_enabled.default(null),
youtube_enabled: InstancePolicyServiceUpdateSchema.shape.youtube_enabled.default(null),
bluesky_enabled: InstancePolicyServiceUpdateSchema.shape.bluesky_enabled.default(null),
@@ -1773,6 +1775,7 @@ export class InstanceConfigRepository {
single_community: policy.single_community_enabled,
single_community_guild_id: policy.single_community_enabled ? policy.single_community_guild_id : null,
direct_messages_disabled: policy.direct_messages_disabled,
guild_create_access: policy.guild_create_access,
};
}
@@ -24,7 +24,6 @@ import type {ReadStateService} from '@app/api/read_state/ReadStateService';
import type {IUserRepository} from '@app/api/user/IUserRepository';
import type {VoiceAvailabilityService} from '@app/api/voice/VoiceAvailabilityService';
import type {IWebhookRepository} from '@app/api/webhook/IWebhookRepository';
import type {IpInfoService} from '@pkgs/geoip/src/IpInfoService';
import type {IVirusScanService} from '@pkgs/virus_scan/src/IVirusScanService';
interface GuildStackServiceFactoryDependencies {
@@ -50,7 +49,6 @@ interface GuildStackServiceFactoryDependencies {
voiceRoomStore: IVoiceRoomStore;
liveKitService: ILiveKitService;
voiceAvailabilityService: VoiceAvailabilityService | null;
ipInfoService: IpInfoService;
}
export interface GuildStackServices {
@@ -106,7 +104,6 @@ class LazyGuildStackServices implements GuildStackServices {
this.dependencies.webhookRepository,
this.dependencies.guildAuditLogService,
this.dependencies.limitConfigService,
this.dependencies.ipInfoService,
);
return this.cachedGuildService;
}
@@ -6,7 +6,6 @@ import {AdminService} from '@app/api/admin/AdminService';
import {AuthRequestService} from '@app/api/auth/AuthRequestService';
import {DesktopHandoffService} from '@app/api/auth/services/DesktopHandoffService';
import {SsoService} from '@app/api/auth/services/SsoService';
import {buildIpInfoCache, buildIpInfoRequestAuditLogger} from '@app/api/ban/IpInfoCacheFactory';
import type {IBlueskyOAuthService} from '@app/api/bluesky/IBlueskyOAuthService';
import {Config} from '@app/api/Config';
import {createApiContext} from '@app/api/CreateApiContext';
@@ -138,7 +137,6 @@ import {getRequestClientIp} from '@app/api/utils/RequestClientIp';
import {VoiceService} from '@app/api/voice/VoiceService';
import {WebhookRequestService} from '@app/api/webhook/WebhookRequestService';
import {WebhookService} from '@app/api/webhook/WebhookService';
import {createIpInfoService, createUnavailableIpInfoService, type IpInfoService} from '@pkgs/geoip/src/IpInfoService';
import {createMiddleware} from 'hono/factory';
export {initializeServiceSingletons} from '@app/api/middleware/ServiceSingletons';
@@ -172,33 +170,6 @@ export function shutdownReportService(): void {
}
}
let _ipInfoService: IpInfoService | null = null;
let _injectedIpInfoService: IpInfoService | undefined;
export function setInjectedIpInfoService(service: IpInfoService | undefined): void {
_injectedIpInfoService = service;
}
export function getIpInfoService(): IpInfoService {
if (_injectedIpInfoService) {
return _injectedIpInfoService;
}
if (_ipInfoService) return _ipInfoService;
if (!Config.ipinfo.apiKey) {
_ipInfoService = createUnavailableIpInfoService('IPInfo API key not configured');
return _ipInfoService;
}
const cache = buildIpInfoCache({
hot: getCacheService(),
});
_ipInfoService = createIpInfoService({
apiKey: Config.ipinfo.apiKey,
cache,
auditLogger: buildIpInfoRequestAuditLogger(),
});
return _ipInfoService;
}
let _liveKitWebhookService: LiveKitWebhookService | null = null;
function getLiveKitWebhookService(): LiveKitWebhookService | null {
@@ -349,7 +320,6 @@ class RequestServices implements RequestScopedServices {
voiceRoomStore: this.voiceRooms,
liveKitService: this.liveKit,
voiceAvailabilityService: getVoiceAvailabilityService(),
ipInfoService: getIpInfoService(),
});
return this.cachedGuildStack;
}
@@ -544,7 +514,6 @@ class RequestServices implements RequestScopedServices {
getApplicationRepository(),
this.stripeService.getStripe(),
new JobLedgerRepository(),
getIpInfoService(),
this.storeEntitlementService,
);
return this.cachedAdminService;
@@ -931,6 +900,5 @@ export const ServiceMiddleware = createMiddleware<HonoEnv>(async (ctx, next) =>
export function resetServiceMiddlewareForTesting(): void {
shutdownReportService();
_ipInfoService = null;
_liveKitWebhookService = null;
}
+12 -3
View File
@@ -6236,7 +6236,7 @@
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
}
},
"description": "Only claimed, email-verified non-bot users can create guilds.",
"description": "Only claimed, email-verified non-bot users can create guilds. A self-hosted instance can restrict creation to admins and users granted the feature_guild_create limit.",
"security": [{"sessionToken": []}],
"requestBody": {
"required": true,
@@ -22729,7 +22729,7 @@
"description": "The sequence number for lifetime premium subscribers"
},
"premium_grace_ends_at": {
"description": "ISO8601 timestamp at which the post-cancel grace period ends. Set when the subscription is fully canceled in Stripe; perks remain active and the original premium_since is restored on resubscribe until this timestamp passes. Null when not in grace.",
"description": "ISO8601 timestamp at which grace access ends after premium_until passes: after a failed renewal payment (7 days from the renewal for monthly plans, 14 for yearly), after a subscription ends (3 days), or during an App Store or Google Play grace period. Perks stay active and the original premium_since is kept on resubscribe until this timestamp passes. Null when no grace is recorded, in which case access lasts 3 days after premium_until.",
"type": ["string", "null"]
},
"premium_discriminator": {
@@ -30259,9 +30259,18 @@
"direct_messages_disabled": {
"type": "boolean",
"description": "Whether direct messages and friend requests are disabled instance-wide"
},
"guild_create_access": {
"type": "boolean",
"description": "Whether every account can create communities. When false, only admins and accounts granted the feature_guild_create limit can"
}
},
"required": ["single_community", "single_community_guild_id", "direct_messages_disabled"],
"required": [
"single_community",
"single_community_guild_id",
"direct_messages_disabled",
"guild_create_access"
],
"additionalProperties": false,
"description": "Community topology and direct-message policy for this instance"
},
@@ -384,7 +384,7 @@ describe('Message Search Permissions', () => {
const guild = await createGuild(harness, owner.token, 'Age Restricted Override Guild');
const channel = await createBuilder<{id: string; nsfw_override?: boolean | null}>(harness, owner.token)
.post(`/guilds/${guild.id}/channels`)
.body({name: 'override-channel', type: ChannelTypes.GUILD_TEXT, nsfw: false})
.body({name: 'override-channel', type: ChannelTypes.GUILD_TEXT, nsfw_override: false})
.execute();
expect(channel.nsfw_override).toBe(false);
await sendChannelMessage(harness, owner.token, channel.id, 'age restricted override searchable message');
@@ -30,7 +30,7 @@ import type {StripePremiumService} from '@app/api/stripe/services/StripePremiumS
import type {StripeSubscriptionReconciler} from '@app/api/stripe/services/StripeSubscriptionReconciler';
import type {IUserRepository} from '@app/api/user/IUserRepository';
import {PaymentRepository} from '@app/api/user/repositories/PaymentRepository';
import {PREMIUM_GRACE_PERIOD_MS} from '@app/api/user/UserHelpers';
import {getPremiumPaymentRecoveryGraceMs} from '@app/api/user/UserHelpers';
import {mapUserToPrivateResponse} from '@app/api/user/UserMappers';
import {UserPremiumTypes} from '@fluxer/constants/src/UserConstants';
import {StripeError} from '@fluxer/errors/src/domains/payment/StripeError';
@@ -369,7 +369,7 @@ export class StripeSubscriptionWebhookHandler {
);
return;
}
const updatedUser = await this.markSubscriptionAsGraceDisabled(targetUser, {
const updatedUser = await this.markSubscriptionPaymentIssue(targetUser, {
subscriptionId,
customerId: this.reconciler.getCustomerIdFromInvoice(invoice),
failedInvoiceServicePeriod: this.getInvoiceServicePeriod(invoice),
@@ -507,10 +507,10 @@ export class StripeSubscriptionWebhookHandler {
}
const willCancel = getPremiumWillCancelFromSubscription(canonicalSubscription);
if (!canProvisionPremiumFromSubscriptionStatus(canonicalSubscription.status)) {
const updatedUser = await this.markSubscriptionAsGraceDisabled(targetUser, {
const updatedUser = await this.markSubscriptionPaymentIssue(targetUser, {
subscriptionId: canonicalSubscription.id,
customerId: extractId(canonicalSubscription.customer),
failedInvoiceServicePeriod: null,
failedInvoiceServicePeriod: this.getUnpaidRenewalPeriod(targetUser, canonicalSubscription),
});
await this.enqueuePremiumStateReconciliation(updatedUser.id, {
reason: 'subscription_updated_non_provisionable',
@@ -523,7 +523,7 @@ export class StripeSubscriptionWebhookHandler {
status: canonicalSubscription.status,
willCancel,
},
'Subscription updated in non-provisionable state; preserved local expiry and disabled grace period',
'Subscription updated in non-provisionable state; preserved local expiry',
);
return;
}
@@ -663,11 +663,22 @@ export class StripeSubscriptionWebhookHandler {
if (!alreadyAppliedEarlyCancellation) {
const cancelledBeforePeriodEnd =
targetUser.premiumUntil != null && subscriptionEndedAt.getTime() < targetUser.premiumUntil.getTime();
if (cancelledBeforePeriodEnd) {
updates.premium_until = subscriptionEndedAt;
updates.premium_grace_ends_at = subscriptionEndedAt;
if (subscription.cancellation_details?.reason === 'payment_failed') {
const lapseStart =
targetUser.premiumUntil && targetUser.premiumUntil.getTime() < subscriptionEndedAt.getTime()
? targetUser.premiumUntil
: subscriptionEndedAt;
const billingCycle =
targetUser.premiumBillingCycle ?? this.reconciler.getBillingCycleFromSubscription(subscription);
updates.premium_until = lapseStart;
updates.premium_grace_ends_at = new Date(
lapseStart.getTime() + getPremiumPaymentRecoveryGraceMs(billingCycle),
);
} else {
updates.premium_grace_ends_at = new Date(subscriptionEndedAt.getTime() + PREMIUM_GRACE_PERIOD_MS);
if (cancelledBeforePeriodEnd) {
updates.premium_until = subscriptionEndedAt;
}
updates.premium_grace_ends_at = subscriptionEndedAt;
}
}
}
@@ -740,7 +751,7 @@ export class StripeSubscriptionWebhookHandler {
);
}
private async markSubscriptionAsGraceDisabled(
private async markSubscriptionPaymentIssue(
user: User,
context: {
subscriptionId: string | null;
@@ -770,6 +781,17 @@ export class StripeSubscriptionWebhookHandler {
) {
patch.premium_until = context.failedInvoiceServicePeriod.start;
}
const lapseStart = patch.premium_until ?? user.premiumUntil;
if (
user.premiumType === UserPremiumTypes.SUBSCRIPTION &&
context.failedInvoiceServicePeriod &&
lapseStart?.getTime() === context.failedInvoiceServicePeriod.start.getTime()
) {
const graceEndsAt = new Date(lapseStart.getTime() + getPremiumPaymentRecoveryGraceMs(user.premiumBillingCycle));
if (user.premiumGraceEndsAt?.getTime() !== graceEndsAt.getTime()) {
patch.premium_grace_ends_at = graceEndsAt;
}
}
if (Object.keys(patch).length === 0) {
return user;
}
@@ -778,6 +800,27 @@ export class StripeSubscriptionWebhookHandler {
return (await this.restoreStoreEntitlement(updatedUser.id)) ?? updatedUser;
}
private getUnpaidRenewalPeriod(
user: User,
subscription: Stripe.Subscription,
): {
start: Date;
end: Date;
} | null {
if (subscription.status !== 'past_due' && subscription.status !== 'unpaid') {
return null;
}
const item = getPrimarySubscriptionItem(subscription);
if (!item?.current_period_start || !item.current_period_end || !user.premiumUntil) {
return null;
}
const start = new Date(item.current_period_start * 1000);
if (user.premiumUntil.getTime() !== start.getTime()) {
return null;
}
return {start, end: new Date(item.current_period_end * 1000)};
}
private getInvoiceServicePeriod(invoice: Stripe.Invoice): {
start: Date;
end: Date;
@@ -43,6 +43,7 @@ const MOCK_PRICES = {
gift1YearEur: 'price_gift_1_year_eur',
};
const DAY_MS = 24 * 60 * 60 * 1000;
const LEGACY_MONTHLY_BRL_PRICE = 'price_legacy_monthly_brl';
const LEGACY_YEARLY_BRL_PRICE = 'price_legacy_yearly_brl';
const UNMAPPED_PRICE = 'price_retired_unmapped_brl';
@@ -147,6 +148,7 @@ describe('Stripe Webhook - Invoice Events', () => {
customerId: string;
premiumUntil: Date;
premiumWillCancel?: boolean;
billingCycle?: 'monthly' | 'yearly';
}): Promise<void> {
const {UserRepository} = await import('@app/api/user/repositories/UserRepository');
const userRepository = new UserRepository();
@@ -157,12 +159,44 @@ describe('Stripe Webhook - Invoice Events', () => {
premium_type: UserPremiumTypes.SUBSCRIPTION,
premium_until: params.premiumUntil,
premium_will_cancel: params.premiumWillCancel ?? false,
...(params.billingCycle ? {premium_billing_cycle: params.billingCycle} : {}),
stripe_subscription_id: params.subscriptionId,
stripe_customer_id: params.customerId,
},
(await userRepository.findUnique(userId))!.toRow(),
);
}
function createRenewalFailureEvent(params: {
invoiceId: string;
customerId: string;
subscriptionId: string;
periodStart: Date;
periodEnd: Date;
}): StripeWebhookEventData {
const eventData = createInvoicePaymentFailedEvent({
invoiceId: params.invoiceId,
customerId: params.customerId,
subscriptionId: params.subscriptionId,
amountDue: 2500,
});
eventData.data.object.billing_reason = 'subscription_cycle';
eventData.data.object.lines = {
data: [
{
period: {
start: Math.floor(params.periodStart.getTime() / 1000),
end: Math.floor(params.periodEnd.getTime() / 1000),
},
parent: {
subscription_item_details: {
subscription: params.subscriptionId,
},
},
},
],
};
return eventData;
}
describe('invoice.payment_succeeded', () => {
test('processes recurring subscription payment successfully', async () => {
const account = await createTestAccount(harness);
@@ -559,6 +593,7 @@ describe('Stripe Webhook - Invoice Events', () => {
premium_type: UserPremiumTypes.SUBSCRIPTION,
premium_until: existingPremiumUntil,
premium_will_cancel: false,
premium_billing_cycle: 'monthly',
stripe_subscription_id: subscriptionId,
stripe_customer_id: 'cus_test_failed_invoice',
},
@@ -592,12 +627,96 @@ describe('Stripe Webhook - Invoice Events', () => {
premium_type: number | null;
premium_until: string | null;
premium_will_cancel: boolean;
premium_grace_ends_at: string | null;
}>(harness, account.token)
.get('/users/@me')
.execute();
const failedPeriodStartMs = Math.floor(failedPeriodStart.getTime() / 1000) * 1000;
expect(me.premium_type).toBe(UserPremiumTypes.SUBSCRIPTION);
expect(me.premium_until).toBe(new Date(failedPeriodStartMs).toISOString());
expect(me.premium_will_cancel).toBe(true);
expect(me.premium_grace_ends_at).toBe(new Date(failedPeriodStartMs + 7 * DAY_MS).toISOString());
});
test('records a 14-day recovery deadline for a yearly subscription', async () => {
const account = await createTestAccount(harness);
const subscriptionId = `sub_failed_yearly_${Date.now()}`;
const failedPeriodStart = new Date(Math.floor((Date.now() - 60 * 60 * 1000) / 1000) * 1000);
const failedPeriodEnd = new Date(failedPeriodStart.getTime() + 365 * DAY_MS);
await createPaymentRecord({
userId: account.userId,
subscriptionId,
priceId: MOCK_PRICES.yearlyUsd,
productType: ProductType.YEARLY_SUBSCRIPTION,
});
await setSubscriptionUserState({
accountUserId: account.userId,
subscriptionId,
customerId: 'cus_test_failed_yearly',
premiumUntil: failedPeriodStart,
billingCycle: 'yearly',
});
const result = await sendWebhook(
createRenewalFailureEvent({
invoiceId: `in_failed_yearly_${Date.now()}`,
customerId: 'cus_test_failed_yearly',
subscriptionId,
periodStart: failedPeriodStart,
periodEnd: failedPeriodEnd,
}),
);
expect(result.received).toBe(true);
const me = await createBuilder<{
premium_type: number | null;
premium_until: string | null;
premium_grace_ends_at: string | null;
}>(harness, account.token)
.get('/users/@me')
.execute();
expect(me.premium_type).toBe(UserPremiumTypes.SUBSCRIPTION);
expect(me.premium_until).toBe(new Date(Math.floor(failedPeriodStart.getTime() / 1000) * 1000).toISOString());
expect(me.premium_will_cancel).toBe(true);
expect(me.premium_until).toBe(failedPeriodStart.toISOString());
expect(me.premium_grace_ends_at).toBe(new Date(failedPeriodStart.getTime() + 14 * DAY_MS).toISOString());
});
test('does not move the recovery deadline on a repeated failure for the same period', async () => {
const account = await createTestAccount(harness);
const subscriptionId = `sub_failed_repeat_${Date.now()}`;
const failedPeriodStart = new Date(Math.floor((Date.now() - 2 * DAY_MS) / 1000) * 1000);
const failedPeriodEnd = new Date(failedPeriodStart.getTime() + 30 * DAY_MS);
await createPaymentRecord({
userId: account.userId,
subscriptionId,
priceId: MOCK_PRICES.monthlyUsd,
productType: ProductType.MONTHLY_SUBSCRIPTION,
});
await setSubscriptionUserState({
accountUserId: account.userId,
subscriptionId,
customerId: 'cus_test_failed_repeat',
premiumUntil: failedPeriodStart,
billingCycle: 'monthly',
});
const invoiceId = `in_failed_repeat_${Date.now()}`;
const expectedDeadline = new Date(failedPeriodStart.getTime() + 7 * DAY_MS).toISOString();
for (const attempt of [1, 2]) {
const eventData = createRenewalFailureEvent({
invoiceId,
customerId: 'cus_test_failed_repeat',
subscriptionId,
periodStart: failedPeriodStart,
periodEnd: failedPeriodEnd,
});
eventData.id = `evt_failed_repeat_${attempt}_${Date.now()}`;
eventData.data.object.attempt_count = attempt;
const result = await sendWebhook(eventData);
expect(result.received).toBe(true);
const me = await createBuilder<{
premium_until: string | null;
premium_grace_ends_at: string | null;
}>(harness, account.token)
.get('/users/@me')
.execute();
expect(me.premium_until).toBe(failedPeriodStart.toISOString());
expect(me.premium_grace_ends_at).toBe(expectedDeadline);
}
});
test('ignores non-renewal invoice payment failures', async () => {
const account = await createTestAccount(harness);
@@ -688,11 +807,14 @@ describe('Stripe Webhook - Invoice Events', () => {
const me = await createBuilder<{
premium_until: string | null;
premium_will_cancel: boolean;
premium_grace_ends_at: string | null;
}>(harness, account.token)
.get('/users/@me')
.execute();
expect(me.premium_until).toBe(new Date(Math.floor(failedPeriodStart.getTime() / 1000) * 1000).toISOString());
const failedPeriodStartMs = Math.floor(failedPeriodStart.getTime() / 1000) * 1000;
expect(me.premium_until).toBe(new Date(failedPeriodStartMs).toISOString());
expect(me.premium_will_cancel).toBe(true);
expect(me.premium_grace_ends_at).toBe(new Date(failedPeriodStartMs + 7 * DAY_MS).toISOString());
});
test('handles invoice.finalization_failed like a recurring access issue', async () => {
const account = await createTestAccount(harness);
@@ -19,6 +19,7 @@ import {server} from '@app/api/test/msw/server';
import {createBuilder} from '@app/api/test/TestRequestBuilder';
import {PaymentRepository} from '@app/api/user/repositories/PaymentRepository';
import {UserRepository} from '@app/api/user/repositories/UserRepository';
import {getPremiumPaymentRecoveryGraceMs, PREMIUM_GRACE_PERIOD_MS} from '@app/api/user/UserHelpers';
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
import {UserPremiumTypes} from '@fluxer/constants/src/UserConstants';
import {HttpResponse, http} from 'msw';
@@ -26,6 +27,7 @@ import {afterAll, afterEach, beforeAll, beforeEach, describe, expect, test} from
const MOCK_PRICES = {
monthlyUsd: 'price_monthly_usd',
yearlyUsd: 'price_yearly_usd',
};
describe('Stripe Webhook Subscription Lifecycle', () => {
@@ -259,6 +261,80 @@ describe('Stripe Webhook Subscription Lifecycle', () => {
expect(updatedUser?.stripeSubscriptionId).toBe(subscriptionId);
expect(updatedUser?.stripeCustomerId).toBe('cus_test_past_due');
});
async function seedPastDueUser(subscriptionId: string, premiumUntil: Date) {
const account = await createTestAccount(harness);
const userId = createUserID(BigInt(account.userId));
const sessionId = `cs_${subscriptionId}_${Date.now()}`;
await paymentRepository.createPayment({
checkout_session_id: sessionId,
user_id: userId,
price_id: MOCK_PRICES.monthlyUsd,
product_type: ProductType.MONTHLY_SUBSCRIPTION,
status: 'completed',
is_gift: false,
created_at: new Date(),
});
await paymentRepository.updatePayment({
checkout_session_id: sessionId,
subscription_id: subscriptionId,
stripe_customer_id: 'cus_test_past_due',
status: 'completed',
});
await userRepository.patchUpsert(
userId,
{
premium_type: UserPremiumTypes.SUBSCRIPTION,
premium_until: premiumUntil,
premium_will_cancel: false,
premium_billing_cycle: 'monthly',
stripe_subscription_id: subscriptionId,
stripe_customer_id: 'cus_test_past_due',
premium_since: new Date(),
},
(await userRepository.findUnique(userId))!.toRow(),
);
return userId;
}
function pastDueEvent(subscriptionId: string, periodStartSeconds: number, periodEndSeconds: number) {
const eventData = createSubscriptionUpdatedEvent({
subscriptionId,
customerId: 'cus_test_past_due',
status: 'past_due',
cancelAtPeriodEnd: false,
});
eventData.data.object.cancel_at = null;
eventData.data.object.items = {
data: [{current_period_start: periodStartSeconds, current_period_end: periodEndSeconds}],
};
return eventData;
}
test('records the payment recovery deadline when past_due arrives before the failed invoice', async () => {
const subscriptionId = 'sub_test_past_due_first';
const renewalSeconds = Math.floor(Date.now() / 1000) - 60 * 60;
const renewalAt = new Date(renewalSeconds * 1000);
const userId = await seedPastDueUser(subscriptionId, renewalAt);
const result = await sendWebhook(
pastDueEvent(subscriptionId, renewalSeconds, renewalSeconds + 30 * 24 * 60 * 60),
);
expect(result.received).toBe(true);
const updatedUser = await userRepository.findUnique(userId);
expect(updatedUser?.premiumUntil?.getTime()).toBe(renewalAt.getTime());
expect(updatedUser?.premiumGraceEndsAt?.getTime()).toBe(
renewalAt.getTime() + getPremiumPaymentRecoveryGraceMs('monthly'),
);
});
test('does not move premium back to the period start for a mid-period past_due', async () => {
const subscriptionId = 'sub_test_past_due_mid_period';
const periodStartSeconds = Math.floor(Date.now() / 1000) - 20 * 24 * 60 * 60;
const periodEndSeconds = periodStartSeconds + 30 * 24 * 60 * 60;
const paidThrough = new Date(periodEndSeconds * 1000);
const userId = await seedPastDueUser(subscriptionId, paidThrough);
const result = await sendWebhook(pastDueEvent(subscriptionId, periodStartSeconds, periodEndSeconds));
expect(result.received).toBe(true);
const updatedUser = await userRepository.findUnique(userId);
expect(updatedUser?.premiumUntil?.getTime()).toBe(paidThrough.getTime());
expect(updatedUser?.premiumGraceEndsAt).toBeNull();
});
test('does not clear premium when period end is missing', async () => {
const account = await createTestAccount(harness);
const userId = createUserID(BigInt(account.userId));
@@ -501,7 +577,7 @@ describe('Stripe Webhook Subscription Lifecycle', () => {
const {checkHasActivePaidPremium} = await import('@app/api/user/UserHelpers');
expect(checkHasActivePaidPremium(afterUser!)).toBe(false);
});
test('grants standard grace when the subscription is cancelled at the end of its paid period', async () => {
test('ends premium without grace when the subscription is cancelled at the end of its paid period', async () => {
const account = await createTestAccount(harness);
const userId = createUserID(BigInt(account.userId));
const subscriptionId = 'sub_test_cancel_natural';
@@ -523,8 +599,113 @@ describe('Stripe Webhook Subscription Lifecycle', () => {
expect(result.received).toBe(true);
const afterUser = await userRepository.findUnique(userId);
expect(afterUser?.premiumUntil?.getTime()).toBe(premiumUntil.getTime());
expect(afterUser?.premiumGraceEndsAt).not.toBeNull();
expect(afterUser!.premiumGraceEndsAt!.getTime()).toBe(endedAt * 1000 + 3 * 24 * 60 * 60 * 1000);
expect(afterUser?.premiumGraceEndsAt?.getTime()).toBe(endedAt * 1000);
const {checkHasActivePaidPremium} = await import('@app/api/user/UserHelpers');
expect(checkHasActivePaidPremium(afterUser!)).toBe(false);
});
test('keeps the payment recovery deadline when Stripe cancels for non-payment', async () => {
const account = await createTestAccount(harness);
const userId = createUserID(BigInt(account.userId));
const subscriptionId = 'sub_test_cancel_non_payment';
const sessionId = `cs_test_non_payment_${Date.now()}`;
await paymentRepository.createPayment({
checkout_session_id: sessionId,
user_id: userId,
price_id: MOCK_PRICES.monthlyUsd,
product_type: ProductType.MONTHLY_SUBSCRIPTION,
status: 'completed',
is_gift: false,
created_at: new Date(),
});
await paymentRepository.updatePayment({
checkout_session_id: sessionId,
subscription_id: subscriptionId,
stripe_customer_id: 'cus_test_1',
status: 'completed',
});
const lapseStart = new Date(Math.floor((Date.now() - 5 * 24 * 60 * 60 * 1000) / 1000) * 1000);
const recoveryDeadline = new Date(lapseStart.getTime() + 7 * 24 * 60 * 60 * 1000);
await userRepository.patchUpsert(
userId,
{
premium_type: UserPremiumTypes.SUBSCRIPTION,
stripe_subscription_id: subscriptionId,
stripe_customer_id: 'cus_test_1',
premium_since: new Date(),
premium_until: lapseStart,
premium_billing_cycle: 'monthly',
premium_will_cancel: true,
premium_grace_ends_at: recoveryDeadline,
},
(await userRepository.findUnique(userId))!.toRow(),
);
const endedAt = Math.floor(Date.now() / 1000);
const eventData = createSubscriptionDeletedEvent({
subscriptionId,
endedAt,
cancellationReason: 'payment_failed',
interval: 'month',
});
const result = await sendWebhook(eventData);
expect(result.received).toBe(true);
const afterUser = await userRepository.findUnique(userId);
expect(afterUser?.premiumType).toBe(UserPremiumTypes.SUBSCRIPTION);
expect(afterUser?.premiumUntil?.getTime()).toBe(lapseStart.getTime());
expect(afterUser?.premiumGraceEndsAt?.getTime()).toBe(recoveryDeadline.getTime());
expect(afterUser?.premiumGraceEndsAt?.getTime()).not.toBe(endedAt * 1000 + PREMIUM_GRACE_PERIOD_MS);
expect(afterUser?.stripeSubscriptionId).toBeNull();
const {checkHasActivePaidPremium} = await import('@app/api/user/UserHelpers');
expect(checkHasActivePaidPremium(afterUser!)).toBe(true);
});
test('records the recovery deadline from the payload cycle when none was recorded', async () => {
const account = await createTestAccount(harness);
const userId = createUserID(BigInt(account.userId));
const subscriptionId = 'sub_test_cancel_non_payment_yearly';
const sessionId = `cs_test_non_payment_yearly_${Date.now()}`;
await paymentRepository.createPayment({
checkout_session_id: sessionId,
user_id: userId,
price_id: MOCK_PRICES.yearlyUsd,
product_type: ProductType.YEARLY_SUBSCRIPTION,
status: 'completed',
is_gift: false,
created_at: new Date(),
});
await paymentRepository.updatePayment({
checkout_session_id: sessionId,
subscription_id: subscriptionId,
stripe_customer_id: 'cus_test_1',
status: 'completed',
});
const lapseStart = new Date(Math.floor((Date.now() - 10 * 24 * 60 * 60 * 1000) / 1000) * 1000);
await userRepository.patchUpsert(
userId,
{
premium_type: UserPremiumTypes.SUBSCRIPTION,
stripe_subscription_id: subscriptionId,
stripe_customer_id: 'cus_test_1',
premium_since: new Date(),
premium_until: lapseStart,
premium_billing_cycle: null,
premium_grace_ends_at: null,
},
(await userRepository.findUnique(userId))!.toRow(),
);
const endedAt = Math.floor(Date.now() / 1000);
const eventData = createSubscriptionDeletedEvent({
subscriptionId,
endedAt,
cancellationReason: 'payment_failed',
interval: 'year',
});
const result = await sendWebhook(eventData);
expect(result.received).toBe(true);
const afterUser = await userRepository.findUnique(userId);
expect(afterUser?.premiumUntil?.getTime()).toBe(lapseStart.getTime());
expect(afterUser?.premiumGraceEndsAt?.getTime()).toBe(
lapseStart.getTime() + getPremiumPaymentRecoveryGraceMs('yearly'),
);
expect(afterUser?.premiumGraceEndsAt?.getTime()).toBe(lapseStart.getTime() + 14 * 24 * 60 * 60 * 1000);
const {checkHasActivePaidPremium} = await import('@app/api/user/UserHelpers');
expect(checkHasActivePaidPremium(afterUser!)).toBe(true);
});
@@ -10,7 +10,6 @@ import {
import {resetSharedListsForTests} from '@app/api/infrastructure/activity/SharedLists';
import {NullSearchProvider} from '@app/api/infrastructure/NullSearchProvider';
import {ipBanCache} from '@app/api/middleware/IpBanMiddleware';
import {setInjectedIpInfoService} from '@app/api/middleware/ServiceMiddleware';
import {
setInjectedBlueskyOAuthService,
setInjectedGatewayService,
@@ -106,7 +105,6 @@ export async function createApiTestHarness(options: CreateApiTestHarnessOptions
getInstanceConfigRepository().clearCacheForTesting();
kvProvider.reset();
mockBlueskyOAuthService.reset();
setInjectedIpInfoService(undefined);
setInjectedUnfurlerService(undefined);
resetSharedListsForTests();
}
@@ -134,7 +132,6 @@ export async function createApiTestHarness(options: CreateApiTestHarnessOptions
setInjectedWorkerService(new NoopWorkerService());
setInjectedGatewayService(new NoopGatewayService());
setInjectedKVProvider(new MockKVProvider());
setInjectedIpInfoService(undefined);
setInjectedUnfurlerService(undefined);
resetSharedListsForTests();
const fallbackStorageService = new MockStorageService();
@@ -1,28 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {HttpResponse, http} from 'msw';
export function createIpInfoLookupHandler() {
return http.get('https://api.ipinfo.io/lookup/:ip', ({params}) => {
const ip = typeof params.ip === 'string' ? params.ip : '198.51.100.1';
return HttpResponse.json({
ip,
geo: {
city: 'Ashburn',
region: 'Virginia',
region_code: 'VA',
country: 'United States',
country_code: 'US',
continent: 'North America',
continent_code: 'NA',
},
as: {
asn: 'AS64500',
name: 'Test ISP',
domain: 'example.com',
type: 'isp',
},
anonymous: {},
});
});
}
@@ -1857,6 +1857,8 @@ export function createSubscriptionDeletedEvent(options: {
subscriptionId?: string;
customerId?: string;
endedAt?: number;
cancellationReason?: 'cancellation_requested' | 'payment_disputed' | 'payment_failed';
interval?: 'month' | 'year';
}): StripeWebhookEventData {
const nowSeconds = Math.floor(Date.now() / 1000);
return {
@@ -1869,6 +1871,15 @@ export function createSubscriptionDeletedEvent(options: {
status: 'canceled',
canceled_at: nowSeconds,
ended_at: options.endedAt ?? nowSeconds,
...(options.cancellationReason ? {cancellation_details: {reason: options.cancellationReason}} : {}),
...(options.interval
? {
items: {
object: 'list',
data: [{object: 'subscription_item', price: {recurring: {interval: options.interval}}}],
},
}
: {}),
},
},
};
-2
View File
@@ -1,6 +1,5 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {createIpInfoLookupHandler} from '@app/api/test/msw/handlers/IpInfoHandlers';
import {createNcmecHandlers} from '@app/api/test/msw/handlers/NcmecHandlers';
import {createOnionooDetailsHandler} from '@app/api/test/msw/handlers/OnionooHandlers';
import {createOpenNsfwHandlers} from '@app/api/test/msw/handlers/OpenNsfwHandlers';
@@ -10,7 +9,6 @@ import {setupServer} from 'msw/node';
export const server = setupServer(
...createNcmecHandlers(),
...createOpenNsfwHandlers(),
createIpInfoLookupHandler(),
createOnionooDetailsHandler(),
createPwnedPasswordsRangeHandler(),
);
@@ -7,9 +7,11 @@ import {
checkIsPremium,
getEffectivePremiumUntil,
getEffectiveSuspiciousFlags,
getPremiumPaymentRecoveryGraceMs,
getRequiredActions,
isSignInRefused,
isTemporarilyBanned,
PREMIUM_GRACE_PERIOD_MS,
} from '@app/api/user/UserHelpers';
import {
DEFERRED_PHONE_ON_COMMUNITY_JOIN,
@@ -289,3 +291,17 @@ describe('account standing', () => {
expect(canOwnerRunBots(standing(0n))).toBe(true);
});
});
describe('premium grace lengths', () => {
it('maps billing cycles to payment recovery grace', () => {
const day = 24 * 60 * 60 * 1000;
expect(getPremiumPaymentRecoveryGraceMs('monthly')).toBe(7 * day);
expect(getPremiumPaymentRecoveryGraceMs('yearly')).toBe(14 * day);
expect(getPremiumPaymentRecoveryGraceMs(null)).toBe(7 * day);
expect(getPremiumPaymentRecoveryGraceMs(undefined)).toBe(7 * day);
});
it('keeps the fallback grace at 3 days', () => {
expect(PREMIUM_GRACE_PERIOD_MS).toBe(3 * 24 * 60 * 60 * 1000);
});
});
+12 -2
View File
@@ -9,12 +9,14 @@ import {extractEmailDomain} from '@app/api/utils/EmailDomainUtils';
import {
DEFERRABLE_PHONE_FLAGS,
DEFERRED_PHONE_ON_COMMUNITY_JOIN,
PREMIUM_GRACE_PERIOD_DAYS,
PREMIUM_PAYMENT_RECOVERY_GRACE_DAYS,
PremiumFlags,
SuspiciousActivityFlags,
UserFlags,
} from '@fluxer/constants/src/UserConstants';
import {MS_PER_DAY} from '@fluxer/date_utils/src/DateConstants';
import type {RequiredAction} from '@fluxer/schema/src/domains/user/UserResponseSchemas';
import {ms} from 'itty-time';
type ClauseAction = Exclude<RequiredAction, 'REQUIRE_INBOUND_PHONE_VERIFICATION'>;
type VerificationChannel = 'email' | 'phone';
@@ -214,7 +216,15 @@ interface PremiumCheckable {
premiumFlags: number;
}
export const PREMIUM_GRACE_PERIOD_MS = ms('3 days');
export const PREMIUM_GRACE_PERIOD_MS = PREMIUM_GRACE_PERIOD_DAYS * MS_PER_DAY;
export function getPremiumPaymentRecoveryGraceMs(billingCycle: string | null | undefined): number {
const days =
billingCycle === 'yearly'
? PREMIUM_PAYMENT_RECOVERY_GRACE_DAYS.yearly
: PREMIUM_PAYMENT_RECOVERY_GRACE_DAYS.monthly;
return days * MS_PER_DAY;
}
export function getEffectivePremiumUntil(
user: Pick<PremiumCheckable, 'premiumUntil' | 'premiumGiftExtensionEndsAt'>,
@@ -38,7 +38,6 @@ import type {InviteService} from '@app/api/invite/InviteService';
import {Logger} from '@app/api/Logger';
import type {LimitConfigService} from '@app/api/limits/LimitConfigService';
import {createGuildStackServices} from '@app/api/middleware/GuildStackServiceFactory';
import {getIpInfoService} from '@app/api/middleware/ServiceMiddleware';
import {
ensureVoiceResourcesInitialized,
getGatewayService,
@@ -237,7 +236,6 @@ export async function initializeWorkerDependencies(snowflakeService: ISnowflakeS
}
const inviteRepository = getInviteRepository();
const webhookRepository = getWebhookRepository();
const ipInfoService = getIpInfoService();
const contactChangeLogService = getContactChangeLogService();
const apiContext = createApiContext();
const {channelService, guildService, inviteService} = createGuildStackServices({
@@ -263,7 +261,6 @@ export async function initializeWorkerDependencies(snowflakeService: ISnowflakeS
voiceRoomStore,
liveKitService,
voiceAvailabilityService,
ipInfoService,
});
const billingRepository = new BillingRepository(snowflakeService, kvClient);
const storeEntitlementService = createStoreEntitlementService({
@@ -75,6 +75,9 @@ function buildStripePremiumRepairPatch(user: User, subscription: Stripe.Subscrip
if (user.premiumWillCancel !== premiumWillCancel) {
patch.premium_will_cancel = premiumWillCancel;
}
if (user.premiumGraceEndsAt != null) {
patch.premium_grace_ends_at = null;
}
if (premiumBillingCycle && user.premiumBillingCycle !== premiumBillingCycle) {
patch.premium_billing_cycle = premiumBillingCycle;
}
@@ -293,7 +296,9 @@ async function reconcileUserPremiumStateFromStripe(params: {userId: UserID; stri
const patch: Partial<UserRow> = {};
let effectivePremiumUntil = getEffectivePremiumUntil(user);
const paidThrough = await getPaidThroughFromSubscriptionInvoices(stripe, mostRecentTerminalSubscription);
if (mostRecentTerminalSubscription?.ended_at && user.premiumUntil) {
const terminalSubscriptionIsCurrent =
user.stripeSubscriptionId == null || mostRecentTerminalSubscription?.id === user.stripeSubscriptionId;
if (mostRecentTerminalSubscription?.ended_at && user.premiumUntil && terminalSubscriptionIsCurrent) {
const subscriptionEndedAt = new Date(mostRecentTerminalSubscription.ended_at * 1000);
const entitlementEnd =
paidThrough && paidThrough.getTime() > subscriptionEndedAt.getTime() ? paidThrough : subscriptionEndedAt;
@@ -314,7 +319,10 @@ async function reconcileUserPremiumStateFromStripe(params: {userId: UserID; stri
premiumGiftExtensionEndsAt: user.premiumGiftExtensionEndsAt,
});
}
const hasFutureLocalEntitlement = effectivePremiumUntil != null && Date.now() <= effectivePremiumUntil.getTime();
const graceEndsAt = patch.premium_grace_ends_at ?? user.premiumGraceEndsAt;
const hasFutureLocalEntitlement =
(effectivePremiumUntil != null && Date.now() <= effectivePremiumUntil.getTime()) ||
(graceEndsAt != null && Date.now() <= graceEndsAt.getTime());
if (hasFutureLocalEntitlement) {
if (user.premiumWillCancel !== true) {
patch.premium_will_cancel = true;
+59 -13
View File
@@ -2,19 +2,65 @@
import {createUserID, type UserID} from '@app/api/BrandedTypes';
import {createRequestCache} from '@app/api/middleware/RequestCacheMiddleware';
import type {User} from '@app/api/models/User';
import {UserChannelService} from '@app/api/user/services/UserChannelService';
import {getWorkerDependencies} from '@app/api/worker/WorkerContext';
import {UserFlags} from '@fluxer/constants/src/UserConstants';
import {JobCancelledError, type WorkerTaskHelpers} from '@pkgs/worker/src/contracts/WorkerTask';
import {z} from 'zod';
const SYSTEM_USER_ID: UserID = createUserID(0n);
const PayloadSchema = z.object({
content: z.string().min(1).max(4000),
user_ids: z.array(z.string().regex(/^\d+$/)).min(1),
});
const ALL_USERS_PAGE_SIZE = 100;
const CURSOR_TTL_SECONDS = 7 * 24 * 60 * 60;
const INELIGIBLE_FLAGS = UserFlags.DELETED | UserFlags.SELF_DELETED | UserFlags.DISABLED;
const PayloadSchema = z.union([
z.object({
content: z.string().min(1).max(4000),
user_ids: z.array(z.string().regex(/^\d+$/)).min(1),
}),
z.object({
content: z.string().min(1).max(4000),
all_users: z.literal(true),
}),
]);
function isEligibleRecipient(user: User): boolean {
return user.id !== SYSTEM_USER_ID && !user.isBot && !user.isSystem && (user.flags & INELIGIBLE_FLAGS) === 0n;
}
async function* allUserRecipients(helpers: WorkerTaskHelpers): AsyncGenerator<UserID> {
const {userRepository, kvClient} = getWorkerDependencies();
const cursorKey = `system_dm:all_users_cursor:${helpers.jobId}`;
let pageState = await kvClient.get(cursorKey);
if (pageState !== null) {
helpers.logger.info('Resuming system DM broadcast from saved cursor');
}
do {
const page = await userRepository.scanAllUsersPage(ALL_USERS_PAGE_SIZE, pageState);
for (const user of page.users) {
if (isEligibleRecipient(user)) {
yield user.id;
}
}
pageState = page.pageState;
if (pageState !== null) {
await kvClient.setex(cursorKey, CURSOR_TTL_SECONDS, pageState);
}
} while (pageState !== null);
await kvClient.del(cursorKey);
}
async function* listedRecipients(userIds: Array<string>): AsyncGenerator<UserID> {
for (const raw of userIds) {
yield createUserID(BigInt(raw));
}
}
export async function sendSystemDm(payload: unknown, helpers: WorkerTaskHelpers): Promise<void> {
const {content, user_ids} = PayloadSchema.parse(payload);
const parsed = PayloadSchema.parse(payload);
const {content} = parsed;
const total = 'user_ids' in parsed ? parsed.user_ids.length : null;
const recipients = 'user_ids' in parsed ? listedRecipients(parsed.user_ids) : allUserRecipients(helpers);
const deps = getWorkerDependencies();
const systemUser = await deps.userRepository.findUniqueAssert(SYSTEM_USER_ID);
const userChannelService = new UserChannelService(
@@ -29,16 +75,12 @@ export async function sendSystemDm(payload: unknown, helpers: WorkerTaskHelpers)
const requestCache = createRequestCache();
let sent = 0;
let failed = 0;
for (const raw of user_ids) {
for await (const recipientId of recipients) {
if (await helpers.shouldCancel()) {
helpers.logger.info(
{sent, failed, remaining: user_ids.length - sent - failed},
'System DM job cancelled mid-flight',
);
helpers.logger.info({sent, failed, total}, 'System DM job cancelled mid-flight');
requestCache.clear();
throw new JobCancelledError();
}
const recipientId = createUserID(BigInt(raw));
try {
const channel = await userChannelService.ensureDmOpenForBothUsers({
userId: SYSTEM_USER_ID,
@@ -55,9 +97,13 @@ export async function sendSystemDm(payload: unknown, helpers: WorkerTaskHelpers)
sent += 1;
} catch (error) {
failed += 1;
helpers.logger.warn({recipientId: raw, error}, 'System DM send failed for recipient');
helpers.logger.warn({recipientId: recipientId.toString(), error}, 'System DM send failed for recipient');
}
if ((sent + failed) % ALL_USERS_PAGE_SIZE === 0) {
requestCache.clear();
await helpers.reportProgress(sent + failed, total, `${sent} sent, ${failed} failed`);
}
}
requestCache.clear();
helpers.logger.info({sent, failed, total: user_ids.length}, 'System DM job complete');
helpers.logger.info({sent, failed, total: sent + failed}, 'System DM job complete');
}
@@ -5,7 +5,7 @@ import {AdminBanManagementService} from '@app/api/admin/services/AdminBanManagem
import {AdminGuildService} from '@app/api/admin/services/AdminGuildService';
import {AdminUserService} from '@app/api/admin/services/AdminUserService';
import {createApiContext} from '@app/api/CreateApiContext';
import {getIpInfoService, getReportServiceInstance} from '@app/api/middleware/ServiceMiddleware';
import {getReportServiceInstance} from '@app/api/middleware/ServiceMiddleware';
import {
getDiscriminatorService,
getEntityAssetService,
@@ -28,7 +28,6 @@ export function createAdminBulkServices(deps: WorkerDependencies): AdminBulkServ
apiContext,
adminRepository: deps.adminRepository,
auditService,
ipInfoService: getIpInfoService(),
});
const userService = new AdminUserService({
apiContext,
@@ -10,7 +10,6 @@ import {DisabledLiveKitService} from '@app/api/infrastructure/DisabledLiveKitSer
import {InMemoryVoiceRoomStore} from '@app/api/infrastructure/InMemoryVoiceRoomStore';
import {getMessages} from '@app/api/message/tests/MessageTestUtils';
import {createGuildStackServices} from '@app/api/middleware/GuildStackServiceFactory';
import {getIpInfoService} from '@app/api/middleware/ServiceMiddleware';
import {getGatewayService, getSnowflakeService, getVoiceAvailabilityService} from '@app/api/middleware/ServiceRegistry';
import {
getAdminRepository,
@@ -97,7 +96,6 @@ function installWorkerDependencies(): void {
voiceRoomStore: new InMemoryVoiceRoomStore(),
liveKitService: new DisabledLiveKitService(),
voiceAvailabilityService: getVoiceAvailabilityService(),
ipInfoService: getIpInfoService(),
});
setWorkerDependenciesForTest({
adminRepository: getAdminRepository(),
@@ -62,7 +62,9 @@ describe('queueBlocklistFeedStartupJobs', () => {
expect(await kv.exists(INITIAL_SYNC_KEY)).toBe(0);
await queueBlocklistFeedStartupJobs(kv, createWorkerService(), true);
expect(await kv.ttl(INITIAL_SYNC_KEY)).toBe(21600);
const ttl = await kv.ttl(INITIAL_SYNC_KEY);
expect(ttl).toBeGreaterThanOrEqual(21599);
expect(ttl).toBeLessThanOrEqual(21600);
});
it('a full jobs stream drops the job without failing startup', async () => {
@@ -71,6 +71,28 @@ function createActiveSubscription(periodEndMs: number): Stripe.Subscription {
} as unknown as Stripe.Subscription;
}
function createPastDueSubscription(): Stripe.Subscription {
return {
id: 'sub_test',
status: 'past_due',
customer: 'cus_test',
ended_at: null,
canceled_at: null,
cancel_at: null,
cancel_at_period_end: false,
trial_end: null,
start_date: Math.floor((Date.now() - 200 * ONE_DAY_MS) / 1000),
items: {
data: [
{
current_period_end: Math.floor((Date.now() + 29 * ONE_DAY_MS) / 1000),
price: {recurring: {interval: 'month'}},
},
],
},
} as unknown as Stripe.Subscription;
}
function createPaidInvoice(periodEndMs: number): Stripe.Invoice {
return {
id: 'in_test',
@@ -344,6 +366,153 @@ describe('processPremiumStateReconciliationQueue', () => {
expect(patches[0].premium_until).toBeNull();
expect(patches[0].premium_since).toBeNull();
});
test('keeps premium for a past_due subscription until the recorded payment recovery deadline', async () => {
const queueService = createQueueService();
await queueService.enqueueUser(USER_ID, new Date(Date.now() - 1000));
const lapseStartMs = Math.floor((Date.now() - ONE_HOUR_MS) / 1000) * 1000;
const user = createPremiumUser({
premium_until: new Date(lapseStartMs),
premium_will_cancel: true,
premium_billing_cycle: 'monthly',
premium_grace_ends_at: new Date(lapseStartMs + 7 * ONE_DAY_MS),
});
const {userRepository, patches, extras} = createCapturingDeps(user);
setWorkerDependenciesForTest({
premiumStateReconciliationQueueService: queueService,
stripe: createStripeStub(createPastDueSubscription(), [createPaidInvoice(lapseStartMs)]),
userRepository,
...extras,
});
await processPremiumStateReconciliationQueue({}, createHelpers());
expect(patches).toHaveLength(0);
expect(await queueService.getQueueSize()).toBe(0);
});
test('keeps the recovery deadline when an older cancelled subscription shares the customer', async () => {
const queueService = createQueueService();
await queueService.enqueueUser(USER_ID, new Date(Date.now() - 1000));
const lapseStartMs = Math.floor((Date.now() - ONE_HOUR_MS) / 1000) * 1000;
const recoveryDeadline = new Date(lapseStartMs + 7 * ONE_DAY_MS);
const user = createPremiumUser({
premium_until: new Date(lapseStartMs),
premium_will_cancel: true,
premium_billing_cycle: 'monthly',
premium_grace_ends_at: recoveryDeadline,
stripe_subscription_id: 'sub_current',
});
const {userRepository, patches, extras} = createCapturingDeps(user);
const currentSubscription = {...createPastDueSubscription(), id: 'sub_current'} as Stripe.Subscription;
const olderSubscription = {
...createCancelledSubscription(Date.now() - 90 * ONE_DAY_MS),
id: 'sub_older',
} as Stripe.Subscription;
setWorkerDependenciesForTest({
premiumStateReconciliationQueueService: queueService,
stripe: {
subscriptions: {
retrieve: async () => currentSubscription,
list: async () => ({data: [olderSubscription, currentSubscription]}),
},
invoices: {
list: async () => ({data: [createPaidInvoice(Date.now() - 90 * ONE_DAY_MS)]}),
},
} as unknown as Stripe,
userRepository,
...extras,
});
await processPremiumStateReconciliationQueue({}, createHelpers());
expect(patches).toHaveLength(0);
});
test('clears a leftover recovery deadline once the subscription is active again', async () => {
const queueService = createQueueService();
await queueService.enqueueUser(USER_ID, new Date(Date.now() - 1000));
const periodEndMs = Math.floor((Date.now() + 29 * ONE_DAY_MS) / 1000) * 1000;
const user = createPremiumUser({
premium_until: new Date(periodEndMs),
premium_billing_cycle: 'monthly',
premium_grace_ends_at: new Date(Date.now() + 6 * ONE_DAY_MS),
});
const {userRepository, patches, extras} = createCapturingDeps(user);
setWorkerDependenciesForTest({
premiumStateReconciliationQueueService: queueService,
stripe: createStripeStub(createActiveSubscription(periodEndMs), []),
userRepository,
...extras,
});
await processPremiumStateReconciliationQueue({}, createHelpers());
expect(patches).toHaveLength(1);
expect(patches[0].premium_grace_ends_at).toBeNull();
expect(patches[0].premium_until).toBeUndefined();
});
test('strips a past_due subscription once the recovery deadline has passed', async () => {
const queueService = createQueueService();
await queueService.enqueueUser(USER_ID, new Date(Date.now() - 1000));
const lapseStartMs = Math.floor((Date.now() - 7 * ONE_DAY_MS - 60_000) / 1000) * 1000;
const user = createPremiumUser({
premium_until: new Date(lapseStartMs),
premium_will_cancel: true,
premium_billing_cycle: 'monthly',
premium_grace_ends_at: new Date(lapseStartMs + 7 * ONE_DAY_MS),
});
const {userRepository, patches, extras} = createCapturingDeps(user);
setWorkerDependenciesForTest({
premiumStateReconciliationQueueService: queueService,
stripe: createStripeStub(createPastDueSubscription(), [createPaidInvoice(lapseStartMs)]),
userRepository,
...extras,
});
await processPremiumStateReconciliationQueue({}, createHelpers());
expect(patches).toHaveLength(1);
expect(patches[0].premium_type).toBeNull();
expect(patches[0].premium_grace_ends_at).toBeNull();
});
test('honours the voluntary-cancel grace written by the delete webhook', async () => {
const queueService = createQueueService();
await queueService.enqueueUser(USER_ID, new Date(Date.now() - 1000));
const endedAtMs = Math.floor((Date.now() - 60_000) / 1000) * 1000;
const user = createPremiumUser({
premium_until: new Date(endedAtMs),
premium_will_cancel: false,
premium_billing_cycle: null,
stripe_subscription_id: null,
premium_grace_ends_at: new Date(endedAtMs + 3 * ONE_DAY_MS),
});
const {userRepository, patches, extras} = createCapturingDeps(user);
setWorkerDependenciesForTest({
premiumStateReconciliationQueueService: queueService,
stripe: createStripeStub(createCancelledSubscription(endedAtMs), [createPaidInvoice(endedAtMs)]),
userRepository,
...extras,
});
await processPremiumStateReconciliationQueue({}, createHelpers());
expect(patches.every((patch) => patch.premium_type === undefined)).toBe(true);
expect(patches.every((patch) => patch.premium_until === undefined)).toBe(true);
expect(patches.every((patch) => patch.premium_grace_ends_at === undefined)).toBe(true);
});
test('hands a user with an active App Store subscription and a stale Stripe customer to the store', async () => {
const queueService = createQueueService();
await queueService.enqueueUser(USER_ID, new Date(Date.now() - 1000));
@@ -8,6 +8,7 @@ import type {UserRepository} from '@app/api/user/repositories/UserRepository';
import {sendSystemDm} from '@app/api/worker/tasks/SendSystemDm';
import {clearWorkerDependencies, setWorkerDependenciesForTest} from '@app/api/worker/WorkerContext';
import {WorkerRunner} from '@app/api/worker/WorkerRunner';
import {UserFlags} from '@fluxer/constants/src/UserConstants';
import type {JsMsg} from '@nats-io/jetstream';
import {afterEach, beforeAll, describe, expect, it, vi} from 'vitest';
@@ -71,11 +72,11 @@ function createWorkerDependencies() {
return {sentChannelIds, sentUserIds};
}
function createJobMessage() {
function createJobMessage(recipients: Record<string, unknown> = {user_ids: ['11', '12', '13']}) {
const envelope = {
payload: {
content: 'scheduled maintenance tonight',
user_ids: ['11', '12', '13'],
...recipients,
__jobId: LEDGER_JOB_ID.toString(),
},
max_attempts: 5,
@@ -164,4 +165,61 @@ describe('System DM cancellation', () => {
expect(deps.sentUserIds).toEqual([0n, 0n, 0n]);
});
it('broadcasts to every eligible user when all_users is set', async () => {
const user = (id: bigint, extra: Record<string, unknown> = {}) => ({
id,
isBot: false,
isSystem: false,
flags: 0n,
...extra,
});
const pages = [
{users: [user(0n, {isSystem: true}), user(21n), user(22n, {isBot: true})], pageState: 'page-2'},
{
users: [user(23n, {flags: UserFlags.DELETED}), user(24n), user(25n, {flags: UserFlags.DISABLED})],
pageState: null,
},
];
const kv = new Map<string, string>();
const kvClient = {
get: async (key: string) => kv.get(key) ?? null,
setex: async (key: string, _ttl: number, value: string) => {
kv.set(key, value);
},
del: async (key: string) => (kv.delete(key) ? 1 : 0),
};
const recipientIds: Array<bigint> = [];
const systemUser = {id: 0n, username: 'Fluxer', bot: true, system: true};
const userRepository = {
findUnique: async () => systemUser,
findUniqueAssert: async () => systemUser,
findExistingDmState: async (_userId: bigint, recipientId: bigint) => {
recipientIds.push(recipientId);
return {id: 500n};
},
isDmChannelOpen: async () => true,
scanAllUsersPage: async (_limit: number, pageState: string | null) =>
pageState === 'page-2' ? pages[1] : pages[0],
} as unknown as UserRepository;
const channelService = {
messages: {send: {sendMessage: async () => {}}},
} as unknown as ChannelService;
setWorkerDependenciesForTest({userRepository, channelService, kvClient} as never);
const {ledger, markSucceeded} = createLedgerStub(Number.POSITIVE_INFINITY);
const runner = new TestWorkerRunner({
tasks: {[TASK_TYPE]: sendSystemDm},
queue: queueStub,
consumerName: 'workers_batch',
laneName: 'batch',
ledger,
concurrency: 1,
});
await expect(runner.runJob(TASK_TYPE, createJobMessage({all_users: true}) as unknown as JsMsg)).resolves.toBe(true);
expect(recipientIds).toEqual([21n, 24n]);
expect(markSucceeded).toHaveBeenCalledTimes(1);
expect(kv.size).toBe(0);
});
});
@@ -8,6 +8,7 @@ import type {TrackBitrateInfo} from './PCTransport.ts';
import {
applyVideoStartBitrate,
collectStereoMids,
conformBundledCodecFmtp,
ensureAudioNackAndStereo,
ensureOpusFmtp,
ensureVideoDDExtension,
@@ -282,15 +283,63 @@ describe('placeholderMidsFromTransceivers', () => {
return {mid, currentDirection, sender: {track}} as unknown as RTCRtpTransceiver;
}
it('keeps the trackless recvonly sections that still hold an m-line', () => {
it('keeps unused trackless sections that still hold an m-line', () => {
const mids = placeholderMidsFromTransceivers([
transceiver('3', null, 'recvonly'),
transceiver('3', null, 'inactive'),
transceiver('4', null, null),
transceiver('7', {} as MediaStreamTrack, 'sendonly'),
]);
expect(mids).toEqual(new Set(['3']));
expect(mids).toEqual(new Set(['3', '4']));
});
it('drops a transceiver that unpublish stopped so its recycled m-section is not fmtp-conformed', () => {
expect(placeholderMidsFromTransceivers([transceiver('7', null, 'stopped')])).toEqual(new Set());
});
it.each(['recvonly', 'sendrecv'] as const)(
'preserves a negotiated %s receiver without a local sender',
(direction) => {
expect(placeholderMidsFromTransceivers([transceiver('0', null, direction)])).toEqual(new Set());
},
);
it.each(['sendonly', 'sendrecv', undefined] as const)(
'preserves a receiver activated by a remote %s answer before currentDirection updates',
(direction) => {
const incoming = opusMedia('useinbandfec=1');
incoming.direction = direction;
expect(placeholderMidsFromTransceivers([transceiver('0', null, null)], [incoming])).toEqual(new Set());
},
);
it('keeps inactive and rejected remote sections eligible for placeholder conformance', () => {
const inactive = opusMedia('useinbandfec=1', '0');
inactive.direction = 'inactive';
const rejected = opusMedia('useinbandfec=1', '1');
rejected.direction = 'sendonly';
rejected.port = 0;
expect(
placeholderMidsFromTransceivers(
[transceiver('0', null, 'inactive'), transceiver('1', null, null)],
[inactive, rejected],
),
).toEqual(new Set(['0', '1']));
});
it('starting a stereo share preserves incoming microphone fmtp while conforming unused placeholders', () => {
const microphone = opusMedia('useinbandfec=1;maxaveragebitrate=64000', '0');
const placeholder = opusMedia('useinbandfec=1;usedtx=1', '1');
const screenShare = opusMedia('useinbandfec=1', '2');
ensureOpusFmtp(screenShare, 128000, true);
const microphoneConfig = opusConfig(microphone);
const mids = placeholderMidsFromTransceivers([
transceiver('0', null, 'recvonly'),
transceiver('1', null, 'inactive'),
transceiver('2', {} as MediaStreamTrack, 'sendonly'),
]);
conformBundledCodecFmtp([microphone, placeholder, screenShare], (media) => mids.has(String(media.mid)));
expect(opusConfig(microphone)).toBe(microphoneConfig);
expect(opusConfig(screenShare)).toContain('stereo=1');
expect(opusConfig(placeholder)).toBe(opusConfig(screenShare));
});
});
@@ -257,7 +257,7 @@ export default class PCTransport extends (EventEmitter as new () => TypedEmitter
}
}
});
const placeholderMids = this.getPlaceholderMids();
const placeholderMids = this.getPlaceholderMids(sdpParsed.media);
if (placeholderMids.size > 0) {
conformBundledCodecFmtp(sdpParsed.media, (media) => placeholderMids.has(getMidString(media.mid!)));
}
@@ -461,8 +461,11 @@ export default class PCTransport extends (EventEmitter as new () => TypedEmitter
}
}
private getPlaceholderMids(): Set<string> {
return placeholderMidsFromTransceivers(this._pc?.getTransceivers() ?? []);
private getPlaceholderMids(remoteMedia?: ReadonlyArray<MediaDescription>): Set<string> {
return placeholderMidsFromTransceivers(
this._pc?.getTransceivers() ?? [],
remoteMedia ?? parse(this._pc?.remoteDescription?.sdp ?? '').media,
);
}
createDataChannel(label: string, dataChannelDict: RTCDataChannelInit) {
@@ -853,10 +856,28 @@ export function collectStereoMids(
return stereoMids;
}
export function placeholderMidsFromTransceivers(transceivers: ReadonlyArray<RTCRtpTransceiver>): Set<string> {
export function placeholderMidsFromTransceivers(
transceivers: ReadonlyArray<RTCRtpTransceiver>,
remoteMedia: ReadonlyArray<MediaDescription> = [],
): Set<string> {
const receivingMids = new Set(
remoteMedia
.filter(
(media) =>
media.mid !== undefined &&
media.port !== 0 &&
(media.direction === 'sendonly' || media.direction === 'sendrecv' || media.direction === undefined),
)
.map((media) => getMidString(media.mid!)),
);
const mids = new Set<string>();
for (const transceiver of transceivers) {
if (transceiver.currentDirection === 'stopped') {
if (
transceiver.currentDirection === 'stopped' ||
transceiver.currentDirection === 'recvonly' ||
transceiver.currentDirection === 'sendrecv' ||
(transceiver.mid !== null && receivingMids.has(transceiver.mid))
) {
continue;
}
if (transceiver.mid && !transceiver.sender.track) {
@@ -0,0 +1,127 @@
import {type AddTrackRequest, AudioTrackFeature, ParticipantPermission, TrackInfo} from '@livekit/protocol';
import {EventEmitter} from 'events';
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
import {SignalConnectionState} from '../../api/SignalClient.ts';
import {publishDefaults, roomOptionDefaults} from '../defaults.ts';
import {TrackEvent} from '../events.ts';
import type RTCEngine from '../RTCEngine.ts';
import LocalAudioTrack from '../track/LocalAudioTrack.ts';
import {Track} from '../track/Track.ts';
import LocalParticipant from './LocalParticipant.ts';
class TestMediaStreamTrack extends EventTarget {
readonly id = 'microphone-input';
readonly kind = 'audio';
enabled = true;
readyState: MediaStreamTrackState = 'live';
constructor(private readonly channelCount: number) {
super();
}
getSettings(): MediaTrackSettings {
return {channelCount: this.channelCount, echoCancellation: true};
}
getConstraints(): MediaTrackConstraints {
return {};
}
stop() {
this.readyState = 'ended';
}
}
class TestMediaStream {
constructor(private readonly tracks: Array<MediaStreamTrack>) {}
getTracks() {
return this.tracks;
}
}
function createPublisher() {
const setTrackCodecBitrate = vi.fn();
const sendUpdateLocalAudioTrack = vi.fn();
const sender = {replaceTrack: vi.fn(async () => undefined)} as unknown as RTCRtpSender;
const transceiver = {sender} as RTCRtpTransceiver;
const addTrack = vi.fn(
async (request: AddTrackRequest) =>
new TrackInfo({
sid: 'TR_microphone',
name: request.name,
type: request.type,
source: request.source,
audioFeatures: request.audioFeatures,
}),
);
const engine = Object.assign(new EventEmitter(), {
isClosed: false,
logContext: {},
client: {currentState: SignalConnectionState.CONNECTED, sendUpdateLocalAudioTrack},
pcManager: {publisher: {getTransceivers: () => [transceiver], setTrackCodecBitrate}},
createSender: vi.fn(async () => sender),
negotiate: vi.fn(async () => undefined),
addTrack,
});
type ParticipantArgs = ConstructorParameters<typeof LocalParticipant>;
const participant = new LocalParticipant(
'PA_publisher',
'publisher',
engine as unknown as RTCEngine,
{...roomOptionDefaults, publishDefaults},
{} as ParticipantArgs[4],
{} as ParticipantArgs[5],
{} as ParticipantArgs[6],
{} as ParticipantArgs[7],
);
participant.permissions = new ParticipantPermission({canPublish: true});
return {participant, addTrack, setTrackCodecBitrate, sendUpdateLocalAudioTrack};
}
describe('microphone publication stereo metadata', () => {
beforeEach(() => {
vi.stubGlobal('MediaStreamTrack', TestMediaStreamTrack);
vi.stubGlobal('MediaStream', TestMediaStream);
});
afterEach(() => {
vi.unstubAllGlobals();
});
it.each([
{name: 'mono policy on a two-channel device', channelCount: 2, forceStereo: false, stereo: false},
{name: 'automatic stereo on a two-channel device', channelCount: 2, forceStereo: undefined, stereo: true},
{name: 'forced stereo on a one-channel device', channelCount: 1, forceStereo: true, stereo: true},
{name: 'automatic mono on a one-channel device', channelCount: 1, forceStereo: undefined, stereo: false},
])(
'keeps the request, SDP policy, and feature updates consistent for $name',
async ({channelCount, forceStereo, stereo}) => {
const {participant, addTrack, setTrackCodecBitrate, sendUpdateLocalAudioTrack} = createPublisher();
const source = new TestMediaStreamTrack(channelCount);
const track = new LocalAudioTrack(source as unknown as MediaStreamTrack);
track.source = Track.Source.Microphone;
await track.runWithTrackChangeLock(async () => undefined);
const publication = await participant.publishTrack(track, {
audioPreset: {maxBitrate: 64000},
forceStereo,
});
expect(addTrack).toHaveBeenCalledTimes(1);
const request = addTrack.mock.calls[0]![0];
expect(request.stereo).toBe(stereo);
expect(request.audioFeatures.includes(AudioTrackFeature.TF_STEREO)).toBe(stereo);
expect(request.audioFeatures).toContain(AudioTrackFeature.TF_ECHO_CANCELLATION);
expect(setTrackCodecBitrate).toHaveBeenCalledWith(expect.objectContaining({codec: 'opus', maxbr: 64, stereo}));
expect(publication.getTrackFeatures().includes(AudioTrackFeature.TF_STEREO)).toBe(stereo);
track.emit(TrackEvent.AudioTrackFeatureUpdate, track, AudioTrackFeature.TF_ECHO_CANCELLATION, true);
expect(sendUpdateLocalAudioTrack).toHaveBeenCalledTimes(1);
const [sid, features] = sendUpdateLocalAudioTrack.mock.calls[0]!;
expect(sid).toBe('TR_microphone');
expect(features.includes(AudioTrackFeature.TF_STEREO)).toBe(stereo);
expect(features).toContain(AudioTrackFeature.TF_ECHO_CANCELLATION);
track.stop();
},
);
});
@@ -966,7 +966,7 @@ export default class LocalParticipant extends Participant {
if (settings.noiseSuppression) {
audioFeatures.push(AudioTrackFeature.TF_NOISE_SUPPRESSION);
}
if (settings.channelCount && settings.channelCount > 1) {
if (isStereo) {
audioFeatures.push(AudioTrackFeature.TF_STEREO);
}
if (disableDtx) {
@@ -90,7 +90,7 @@ export default class LocalTrackPublication extends TrackPublication {
if (settings.noiseSuppression) {
features.add(AudioTrackFeature.TF_NOISE_SUPPRESSION);
}
if (settings.channelCount && settings.channelCount > 1) {
if (this.options?.forceStereo ?? (settings.channelCount !== undefined && settings.channelCount > 1)) {
features.add(AudioTrackFeature.TF_STEREO);
}
if (!this.options?.dtx) {
@@ -135,8 +135,7 @@ export const PlutoniumContent = observer(({defaultGiftMode = false}: PlutoniumCo
title={<Trans>Cancel subscription?</Trans>}
description={
<Trans>
You keep your perks until your next renewal date, then have a 3-day grace period to resubscribe and keep
your subscriber history.
You keep your perks until your next renewal date. Reactivate before then to keep your subscriber history.
</Trans>
}
primaryText={<Trans>Cancel subscription</Trans>}
@@ -463,6 +463,22 @@ export const SubscriptionCard: React.FC<SubscriptionCardProps> = observer(
</Trans>
);
})()
) : gracePeriodInfo.isPaymentRecovery ? (
(() => {
const graceDate = graceEndDate ? getFormattedLongDate(graceEndDate, locale) : undefined;
return (
<Trans comment="Plutonium subscription card text shown while a failed renewal payment is being retried. {graceDate} is a date already formatted and localized by code; never write a date into the translation.">
Your renewal payment failed but{' '}
<PerksButton
onClick={scrollToPerks}
data-flx="app.plutonium.subscription-card.perks-button.scroll-to-perks--9"
/>{' '}
stay active until{' '}
<strong data-flx="app.plutonium.subscription-card.strong--16">{graceDate}</strong>. Update your
payment method before then to keep your subscription.
</Trans>
);
})()
) : isInGracePeriod ? (
(() => {
const graceDate = graceEndDate ? getFormattedLongDate(graceEndDate, locale) : undefined;
@@ -844,7 +860,7 @@ export const SubscriptionCard: React.FC<SubscriptionCardProps> = observer(
<Trans comment="Billing button that starts subscription cancellation.">Cancel subscription</Trans>
</Button>
)}
{isInGracePeriod && (
{isInGracePeriod && !gracePeriodInfo.isPaymentRecovery && (
<Button
variant="danger"
onClick={handleEndPremiumGracePeriod}
@@ -1,6 +1,7 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import DeveloperOptions from '@app/features/devtools/state/DeveloperOptions';
import {getPremiumGraceEndDate} from '@app/features/premium/utils/PremiumGrace';
import type {User} from '@app/features/user/models/User';
import {UserPremiumTypes} from '@fluxer/constants/src/UserConstants';
import {MS_PER_DAY} from '@fluxer/date_utils/src/DateConstants';
@@ -12,6 +13,7 @@ export interface GracePeriodInfo {
isExpired: boolean;
graceEndDate: Date | null;
showExpiredState: boolean;
isPaymentRecovery: boolean;
}
export interface SubscriptionStatusInfo {
@@ -82,13 +84,25 @@ export const useSubscriptionStatus = (
const isGiftSubscription = Boolean(!billingCycle && hasPaidPremium && !isVisionary && premiumUntil);
const gracePeriodInfo = useMemo((): GracePeriodInfo => {
if (isVisionary) {
return {isInGracePeriod: false, isExpired: false, graceEndDate: null, showExpiredState: false};
return {
isInGracePeriod: false,
isExpired: false,
graceEndDate: null,
showExpiredState: false,
isPaymentRecovery: false,
};
}
const explicitGraceEnd = premiumGraceEndsAt ?? null;
const expiryDate = premiumUntil ? new Date(premiumUntil) : null;
const graceEndDate = explicitGraceEnd ?? (expiryDate ? new Date(expiryDate.getTime() + 3 * MS_PER_DAY) : null);
const graceEndDate = expiryDate ? getPremiumGraceEndDate(expiryDate, explicitGraceEnd) : explicitGraceEnd;
if (!graceEndDate) {
return {isInGracePeriod: false, isExpired: false, graceEndDate: null, showExpiredState: false};
return {
isInGracePeriod: false,
isExpired: false,
graceEndDate: null,
showExpiredState: false,
isPaymentRecovery: false,
};
}
const now = new Date();
const anchorDate = expiryDate ?? graceEndDate;
@@ -96,8 +110,9 @@ export const useSubscriptionStatus = (
const isInGracePeriod = (!expiryDate || now > expiryDate) && now <= graceEndDate;
const isExpired = now > graceEndDate;
const showExpiredState = isExpired && now <= expiredStateEndDate;
return {isInGracePeriod, isExpired, graceEndDate, showExpiredState};
}, [premiumUntil, premiumGraceEndsAt, isVisionary]);
const isPaymentRecovery = isInGracePeriod && explicitGraceEnd != null && billingCycle != null;
return {isInGracePeriod, isExpired, graceEndDate, showExpiredState, isPaymentRecovery};
}, [premiumUntil, premiumGraceEndsAt, isVisionary, billingCycle]);
const {isInGracePeriod, isExpired: isFullyExpired, showExpiredState} = gracePeriodInfo;
const isPremium = useDeveloperOverride
? hasPaidPremium && !isFullyExpired && !perksDisabled
@@ -21,6 +21,7 @@ import * as NotificationUtils from '@app/features/notification/utils/Notificatio
import NativePermission from '@app/features/permissions/system/state/NativePermission';
import {resolvePriceAnnouncementCampaign} from '@app/features/premium/config/PriceAnnouncementCampaign';
import PremiumState from '@app/features/premium/state/PremiumState';
import {getPremiumGraceEndDate} from '@app/features/premium/utils/PremiumGrace';
import {canServiceStripeSubscriptions, shouldShowPremiumFeatures} from '@app/features/premium/utils/PremiumUtils';
import StreamerMode from '@app/features/streamer_mode/state/StreamerMode';
import Nagbar from '@app/features/ui/state/Nagbar';
@@ -117,14 +118,11 @@ export const useNagbarConditions = (): NagbarConditions => {
if (!showPremium || !canServiceSubscription) return false;
if (nagbarState.forceHidePremiumGracePeriod) return false;
if (nagbarState.forcePremiumGracePeriod) return true;
if (!user?.premiumUntil || user.premiumType === 2 || premiumWillCancel) return false;
if (!user?.premiumUntil || !user.premiumGraceEndsAt || !user.premiumBillingCycle || user.premiumType === 2) {
return false;
}
const now = new Date();
const expiryDate = new Date(user.premiumUntil);
const gracePeriodMs = 3 * MS_PER_DAY;
const graceEndDate = user.premiumGraceEndsAt
? new Date(user.premiumGraceEndsAt)
: new Date(expiryDate.getTime() + gracePeriodMs);
const isInGracePeriod = now > expiryDate && now <= graceEndDate;
const isInGracePeriod = now > new Date(user.premiumUntil) && now <= new Date(user.premiumGraceEndsAt);
return isInGracePeriod && !nagbarState.premiumGracePeriodDismissed;
})();
const canShowPremiumExpired = (() => {
@@ -134,11 +132,8 @@ export const useNagbarConditions = (): NagbarConditions => {
if (!user?.premiumUntil || user.premiumType === 2 || premiumWillCancel) return false;
const now = new Date();
const expiryDate = new Date(user.premiumUntil);
const gracePeriodMs = 3 * MS_PER_DAY;
const expiredStateDurationMs = 30 * MS_PER_DAY;
const graceEndDate = user.premiumGraceEndsAt
? new Date(user.premiumGraceEndsAt)
: new Date(expiryDate.getTime() + gracePeriodMs);
const graceEndDate = getPremiumGraceEndDate(expiryDate, user.premiumGraceEndsAt);
const expiredStateEndDate = new Date(graceEndDate.getTime() + expiredStateDurationMs);
const isExpired = now > graceEndDate;
const showExpiredState = isExpired && now <= expiredStateEndDate;
@@ -8,12 +8,12 @@ import {NAGBAR_TONES, NagbarToneKind} from '@app/features/app/components/layout/
import {PREMIUM_PRODUCT_NAME} from '@app/features/app/config/I18nDisplayConstants';
import {Logger} from '@app/features/platform/utils/AppLogger';
import * as PremiumCommands from '@app/features/premium/commands/PremiumCommands';
import {getPremiumGraceEndDate} from '@app/features/premium/utils/PremiumGrace';
import {MANAGE_SUBSCRIPTION_DESCRIPTOR} from '@app/features/premium/utils/PremiumMessageDescriptors';
import * as NagbarCommands from '@app/features/ui/commands/NagbarCommands';
import {openExternalUrl} from '@app/features/ui/utils/NativeUtils';
import Users from '@app/features/user/state/Users';
import * as LocaleUtils from '@app/features/user/utils/LocaleUtils';
import {MS_PER_DAY} from '@fluxer/date_utils/src/DateConstants';
import {getFormattedLongDate} from '@fluxer/date_utils/src/DateFormatting';
import {msg} from '@lingui/core/macro';
import {useLingui} from '@lingui/react/macro';
@@ -62,10 +62,8 @@ export const PremiumGracePeriodNagbar = observer(({isMobile}: {isMobile: boolean
const handleDismiss = () => {
NagbarCommands.dismissNagbar('premiumGracePeriodDismissed');
};
if (!user?.premiumUntil || user?.premiumWillCancel) return null;
const expiryDate = new Date(user.premiumUntil);
const gracePeriodMs = 3 * MS_PER_DAY;
const graceEndDate = new Date(expiryDate.getTime() + gracePeriodMs);
if (!user?.premiumUntil) return null;
const graceEndDate = getPremiumGraceEndDate(new Date(user.premiumUntil), user.premiumGraceEndsAt);
const locale = LocaleUtils.getCurrentLocale();
const formattedGraceDate = getFormattedLongDate(graceEndDate, locale);
return (
@@ -111,6 +111,7 @@ export const DEFAULT_INSTANCE_COMMUNITY: InstanceCommunity = {
single_community: false,
single_community_guild_id: null,
direct_messages_disabled: false,
guild_create_access: true,
};
export function normalizeInstanceCommunity(community?: InstanceCommunity | null): InstanceCommunity {
@@ -402,6 +403,7 @@ class RuntimeConfig {
? config.policy.single_community_guild_id
: null,
direct_messages_disabled: config.policy.direct_messages_disabled,
guild_create_access: config.policy.guild_create_access,
});
this.services = normalizeInstanceServices({
gif_enabled: config.policy.services_resolved.gif_enabled,
@@ -368,6 +368,7 @@
}
.ssoRetryButton {
display: inline-block;
padding: 0.75rem 1.5rem;
border-radius: 0.625rem;
border: none;
@@ -376,6 +377,7 @@
font-weight: 600;
font-size: 0.95rem;
cursor: pointer;
text-decoration: none;
transition: background 120ms ease;
}
@@ -1,5 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {PRODUCT_NAME} from '@app/features/app/config/I18nDisplayConstants';
import * as AuthenticationCommands from '@app/features/auth/commands/AuthenticationCommands';
import styles from '@app/features/auth/components/pages/LoginPage.module.css';
import {
@@ -12,6 +13,7 @@ import {safeRedirectTarget} from '@app/features/auth/utils/SafeRedirect';
import {BACK_TO_SIGN_IN_DESCRIPTOR, TRY_AGAIN_DESCRIPTOR} from '@app/features/i18n/utils/CommonMessageDescriptors';
import * as RouterUtils from '@app/features/navigation/utils/RouterUtils';
import * as FormUtils from '@app/lib/forms';
import {SSO_MOBILE_CALLBACK_URI, SSO_MOBILE_STATE_PREFIX} from '@fluxer/constants/src/SsoConstants';
import {msg} from '@lingui/core/macro';
import {Trans, useLingui} from '@lingui/react/macro';
import {observer} from 'mobx-react-lite';
@@ -29,6 +31,10 @@ const FAILED_TO_COMPLETE_SSO_SIGN_IN_DESCRIPTOR = msg({
message: 'Failed to complete SSO sign-in',
comment: 'Short label in the authentication SSO callback page. Keep the tone plain and specific.',
});
const OPEN_PRODUCT_DESCRIPTOR = msg({
message: 'Open {productName}',
comment: 'Button that hands SSO sign-in back to the mobile app. productName is the app name.',
});
const SSO_TIMEOUT_MS = 30_000;
const SsoCallbackPage = observer(function SsoCallbackPage() {
const {i18n} = useLingui();
@@ -37,6 +43,9 @@ const SsoCallbackPage = observer(function SsoCallbackPage() {
const state = params['get']('state');
const providerError = params['get']('error');
const providerErrorDescription = params['get']('error_description');
const mobileCallbackUrl = state?.startsWith(SSO_MOBILE_STATE_PREFIX)
? `${SSO_MOBILE_CALLBACK_URI}${window.location.search}`
: null;
const [error, setError] = useState<string | null>(null);
const [isProcessing, setIsProcessing] = useState(true);
const abortControllerRef = useRef<AbortController | null>(null);
@@ -54,6 +63,10 @@ const SsoCallbackPage = observer(function SsoCallbackPage() {
}
}, []);
useEffect(() => {
if (mobileCallbackUrl) {
window.location.replace(mobileCallbackUrl);
return;
}
const controller = new AbortController();
abortControllerRef.current = controller;
const timeoutId = setTimeout(() => {
@@ -97,7 +110,28 @@ const SsoCallbackPage = observer(function SsoCallbackPage() {
clearTimeout(timeoutId);
controller.abort();
};
}, [code, state, providerError, providerErrorDescription, i18n]);
}, [code, state, providerError, providerErrorDescription, mobileCallbackUrl, i18n]);
if (mobileCallbackUrl) {
return (
<div className={styles.loginContainer} data-flx="auth.sso-callback-page.login-container--mobile">
<h1 className={styles.title} data-flx="auth.sso-callback-page.title--mobile">
<Trans>Completing sign-in…</Trans>
</h1>
<p className={styles.ssoProcessingHint} data-flx="auth.sso-callback-page.sso-processing-hint--mobile">
<Trans>Jump straight to the app to continue.</Trans>
</p>
<div className={styles.ssoCallbackActions} data-flx="auth.sso-callback-page.sso-callback-actions--mobile">
<a
href={mobileCallbackUrl}
className={styles.ssoRetryButton}
data-flx="auth.sso-callback-page.sso-open-app-button"
>
{i18n._(OPEN_PRODUCT_DESCRIPTOR, {productName: PRODUCT_NAME})}
</a>
</div>
</div>
);
}
if (error) {
return (
<div className={styles.loginContainer} data-flx="auth.sso-callback-page.login-container">
@@ -20,6 +20,7 @@ import {handleGuildCountsUpdate} from '@app/features/guild/events/GuildCountsUpd
import {handleGuildCreate} from '@app/features/guild/events/GuildCreate';
import {handleGuildDelete} from '@app/features/guild/events/GuildDelete';
import {handleGuildEmojisUpdate} from '@app/features/guild/events/GuildEmojisUpdate';
import {handleGuildHealthUpdate} from '@app/features/guild/events/GuildHealthUpdate';
import {handleGuildMemberAdd} from '@app/features/guild/events/GuildMemberAdd';
import {handleGuildMemberListUpdate} from '@app/features/guild/events/GuildMemberListUpdate';
import {handleGuildMemberRemove} from '@app/features/guild/events/GuildMemberRemove';
@@ -112,6 +113,7 @@ export function createHandlerRegistry(): GatewayHandlerRegistry {
registry.set('GUILD_MEMBERS_CHUNK', handleGuildMembersChunk as GatewayEventHandler);
registry.set('GUILD_MEMBER_LIST_UPDATE', handleGuildMemberListUpdate as GatewayEventHandler);
registry.set('GUILD_COUNTS_UPDATE', handleGuildCountsUpdate as GatewayEventHandler);
registry.set('GUILD_HEALTH_UPDATE', handleGuildHealthUpdate as GatewayEventHandler);
registry.set('CHANNEL_MEMBER_COUNTS_UPDATE', handleChannelMemberCountsUpdate as GatewayEventHandler);
registry.set('GUILD_ROLE_CREATE', handleGuildRoleCreate as GatewayEventHandler);
registry.set('GUILD_ROLE_UPDATE', handleGuildRoleUpdate as GatewayEventHandler);
@@ -23,4 +23,5 @@ export type GuildReadyData = Readonly<{
joined_at: string;
unavailable?: boolean;
unavailable_hidden?: boolean;
degraded?: boolean;
}>;
@@ -48,6 +48,9 @@ export function handleGuildCreate(data: GuildReadyData, _context: GatewayHandler
return;
}
GuildAvailability.setGuildAvailable(data.id);
if (!isSync || data.degraded !== undefined) {
GuildAvailability.setGuildDegraded(data.id, data.degraded === true);
}
Guilds.handleGuildCreate(data);
GuildCount.handleGuildCreate(data);
if (!isSync) {
@@ -32,6 +32,7 @@ interface GuildDeletePayload {
}
export function handleGuildDelete(data: GuildDeletePayload, _context: GatewayHandlerContext): void {
GuildAvailability.setGuildDegraded(data.id, false);
GuildAvailability.handleGuildAvailability(data.id, data.unavailable, data.unavailable_hidden);
Guilds.handleGuildDelete({guildId: data.id, unavailable: data.unavailable});
GuildList.handleGuildDelete(data.id, data.unavailable);
@@ -0,0 +1,10 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import GuildAvailability from '@app/features/guild/state/GuildAvailability';
import Guilds from '@app/features/guild/state/Guilds';
export function handleGuildHealthUpdate(data: {guild_id: string; degraded: boolean}): void {
if (Guilds.getGuild(data.guild_id)) {
GuildAvailability.setGuildDegraded(data.guild_id, data.degraded);
}
}
@@ -5,12 +5,14 @@ import {makeAutoObservable, observable} from 'mobx';
class GuildAvailability {
unavailableGuilds: Set<string> = observable.set();
degradedGuilds: Set<string> = observable.set();
constructor() {
makeAutoObservable(
this,
{
unavailableGuilds: false,
degradedGuilds: false,
},
{autoBind: true},
);
@@ -23,11 +25,20 @@ class GuildAvailability {
}
setGuildUnavailable(guildId: string): void {
this.degradedGuilds.delete(guildId);
if (!this.unavailableGuilds.has(guildId)) {
this.unavailableGuilds.add(guildId);
}
}
setGuildDegraded(guildId: string, degraded: boolean): void {
if (degraded) {
this.degradedGuilds.add(guildId);
} else {
this.degradedGuilds.delete(guildId);
}
}
handleGuildAvailability(guildId: string, unavailable = false, unavailableHidden = false): void {
if (unavailable && !unavailableHidden) {
this.setGuildUnavailable(guildId);
@@ -38,7 +49,11 @@ class GuildAvailability {
loadUnavailableGuilds(guilds: ReadonlyArray<GuildReadyData>): void {
this.unavailableGuilds.clear();
this.degradedGuilds.clear();
for (const guild of guilds) {
if (guild.degraded && !guild.unavailable) {
this.degradedGuilds.add(guild.id);
}
if (guild.unavailable && !guild.unavailable_hidden) {
this.unavailableGuilds.add(guild.id);
}
@@ -2471,7 +2471,7 @@ msgid "A Linux desktop update is available. Choose the package format for this s
msgstr "يتوفر تحديث لتطبيق سطح المكتب على Linux. اختر تنسيق الحزمة لهذا النظام وسيحفظه {productName} محليًا."
#. Plutonium subscription card line offering a move to a lower price. {listPriceEffectiveDate} is a date already formatted and localized by code; never write a date into the translation.
#: src/features/app/components/dialogs/components/plutonium/SubscriptionCard.tsx:684
#: src/features/app/components/dialogs/components/plutonium/SubscriptionCard.tsx:700
msgid "A lower price is available. Switching takes effect on <0>{listPriceEffectiveDate}</0> and changes nothing else."
msgstr "يتوفر سعر أقل. يسري التبديل اعتبارًا من <0>{listPriceEffectiveDate}</0> ولا يغيّر أي شيء آخر."
@@ -2639,7 +2639,7 @@ msgstr "الوصول إلى معلومات ملفك الشخصي الأساسي
#. Settings search synonym. Used to match this term when the user types it in the settings search bar.
#. User settings tab for accessibility preferences (visual, motion, screen reader, etc.).
#: src/features/user/components/modals/tabs/advanced_settings_tab/AdvancedSettingsCategories.ts:14
#: src/features/user/components/settings_utils/section_registry/SharedDescriptors.ts:94
#: src/features/user/components/settings_utils/section_registry/SharedDescriptors.ts:102
#: src/features/user/components/settings_utils/SettingsConstants.tsx:61
msgid "Accessibility"
msgstr "إمكانية الوصول"
@@ -3487,19 +3487,19 @@ msgstr "عناصر التحكم في المحاذاة متاحة فقط على
msgid "All"
msgstr "الكل"
#: src/features/app/components/dialogs/components/plutonium/SubscriptionCard.tsx:524
#: src/features/app/components/dialogs/components/plutonium/SubscriptionCard.tsx:540
msgid "All <0/> for <1>{effectiveMonthlyPrice}/month</1>."
msgstr "جميع <0/> مقابل <1>{effectiveMonthlyPrice} شهريًا</1>."
#: src/features/app/components/dialogs/components/plutonium/SubscriptionCard.tsx:546
#: src/features/app/components/dialogs/components/plutonium/SubscriptionCard.tsx:562
msgid "All <0/> for <1>{effectiveYearlyPrice}/year</1>."
msgstr "جميع <0/> مقابل <1>{effectiveYearlyPrice} سنويًا</1>."
#: src/features/app/components/dialogs/components/plutonium/SubscriptionCard.tsx:567
#: src/features/app/components/dialogs/components/plutonium/SubscriptionCard.tsx:583
msgid "All <0/> included with your subscription."
msgstr "جميع <0/> مشمولة في اشتراكك."
#: src/features/app/components/dialogs/components/plutonium/SubscriptionCard.tsx:514
#: src/features/app/components/dialogs/components/plutonium/SubscriptionCard.tsx:530
msgid "All <0/>, forever. No billing, no renewals."
msgstr "جميع <0/> للأبد. بلا فواتير ولا تجديدات."
@@ -4706,7 +4706,7 @@ msgstr "المرفقات"
#. Generic tab / section / category label for audio content or settings.
#. Settings search synonym. Used to match this term when the user types it in the settings search bar.
#: src/features/i18n/utils/CommonMessageDescriptors.ts:255
#: src/features/user/components/settings_utils/section_registry/SharedDescriptors.ts:171
#: src/features/user/components/settings_utils/section_registry/SharedDescriptors.ts:179
msgid "Audio"
msgstr "الصوت"
@@ -6210,7 +6210,7 @@ msgid "California"
msgstr "كاليفورنيا"
#. Settings search synonym. Used to match this term when the user types it in the settings search bar.
#: src/features/user/components/settings_utils/section_registry/SharedDescriptors.ts:151
#: src/features/user/components/settings_utils/section_registry/SharedDescriptors.ts:159
msgid "Call"
msgstr "مكالمة"
@@ -6469,7 +6469,7 @@ msgid "Cancel import"
msgstr "إلغاء الاستيراد"
#. Billing button that cancels a scheduled move to a lower subscription price.
#: src/features/app/components/dialogs/components/plutonium/SubscriptionCard.tsx:821
#: src/features/app/components/dialogs/components/plutonium/SubscriptionCard.tsx:837
msgid "Cancel price change"
msgstr "إلغاء تغيير السعر"
@@ -6484,14 +6484,14 @@ msgid "Cancel request"
msgstr "إلغاء الطلب"
#. Billing button that cancels a scheduled billing-cycle change.
#: src/features/app/components/dialogs/components/plutonium/SubscriptionCard.tsx:829
#: src/features/app/components/dialogs/components/plutonium/SubscriptionCard.tsx:845
msgid "Cancel scheduled switch"
msgstr "إلغاء التبديل المجدول"
#. Billing button that starts subscription cancellation.
#. Settings search synonym. Used to match this term when the user types it in the settings search bar.
#: src/features/app/components/dialogs/components/plutonium/SubscriptionCard.tsx:844
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:142
#: src/features/app/components/dialogs/components/plutonium/SubscriptionCard.tsx:860
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:141
#: src/features/user/components/settings_utils/search_index/PlutoniumIndex.ts:31
msgid "Cancel subscription"
msgstr "إلغاء الاشتراك"
@@ -6501,7 +6501,7 @@ msgid "Cancel subscription?"
msgstr "هل تريد إلغاء الاشتراك؟"
#. Plutonium subscription card hint shown to a subscriber who could move to a lower price but has another billing change already scheduled.
#: src/features/app/components/dialogs/components/plutonium/SubscriptionCard.tsx:647
#: src/features/app/components/dialogs/components/plutonium/SubscriptionCard.tsx:663
msgid "Cancel the scheduled change to move to the current price instead."
msgstr "ألغِ التغيير المجدول للانتقال إلى السعر الحالي بدلاً من ذلك."
@@ -6511,7 +6511,7 @@ msgid "Cancel upload"
msgstr "إلغاء التحميل"
#. Billing button that cancels a scheduled yearly upgrade.
#: src/features/app/components/dialogs/components/plutonium/SubscriptionCard.tsx:825
#: src/features/app/components/dialogs/components/plutonium/SubscriptionCard.tsx:841
msgid "Cancel yearly upgrade"
msgstr "إلغاء الترقية السنوية"
@@ -6530,7 +6530,7 @@ msgstr "سيتم الإلغاء"
msgid "Cancellation"
msgstr "إلغاء الصدى"
#: src/features/app/components/dialogs/components/plutonium/SubscriptionCard.tsx:501
#: src/features/app/components/dialogs/components/plutonium/SubscriptionCard.tsx:517
msgid "Cancels on <0>{cancelDate}</0>. <1/> remain active until then."
msgstr "سيتم الإلغاء في <0>{cancelDate}</0>. تظل <1/> سارية حتى ذلك الحين."
@@ -7088,7 +7088,7 @@ msgid "Channel"
msgstr "القناة"
#. Fallback name for an unnamed channel in the forward modal destination list. Preserve {id}; it is inserted by code.
#: src/features/app/components/dialogs/shared/UseForwardDestinations.ts:78
#: src/features/app/components/dialogs/shared/UseForwardDestinations.ts:79
msgid "Channel {id}"
msgstr "القناة {id}"
@@ -7317,7 +7317,7 @@ msgid "Chat font size"
msgstr "حجم خط الدردشة"
#. Settings search synonym. Refers to the message composer area.
#: src/features/user/components/settings_utils/section_registry/SharedDescriptors.ts:66
#: src/features/user/components/settings_utils/section_registry/SharedDescriptors.ts:74
msgid "chat input"
msgstr "مربع الكتابة"
@@ -8726,7 +8726,7 @@ msgid "Community updated"
msgstr "تم تحديث المجتمع"
#. Settings search synonym. Used to match this term when the user types it in the settings search bar.
#: src/features/user/components/settings_utils/search_index/AppearanceIndex.ts:35
#: src/features/user/components/settings_utils/search_index/AppearanceIndex.ts:38
msgid "Community voice"
msgstr "صوت المجتمع"
@@ -8789,12 +8789,13 @@ msgstr "أكمل عملية التحقق المطلوبة لمتابعة است
msgid "Completely hide your {premiumProductName} badge from other users"
msgstr "إخفاء شارة {premiumProductName} الخاصة بك بالكامل عن المستخدمين الآخرين"
#: src/features/auth/components/pages/SsoCallbackPage.tsx:134
#: src/features/auth/components/pages/SsoCallbackPage.tsx:118
#: src/features/auth/components/pages/SsoCallbackPage.tsx:168
#: src/features/auth/flow/HandoffApprovalFlow.tsx:218
msgid "Completing sign-in…"
msgstr "جارٍ إكمال تسجيل الدخول…"
#: src/features/auth/components/pages/SsoCallbackPage.tsx:138
#: src/features/auth/components/pages/SsoCallbackPage.tsx:172
msgid "Completing your sign-in…"
msgstr "جارٍ إكمال تسجيل دخولك…"
@@ -8912,8 +8913,8 @@ msgstr "تأكيد"
#. Settings search entry label. Names the settings search entry in the settings UI.
#. Short label for an advanced voice-channel safety preference.
#: src/features/user/components/modals/tabs/advanced_settings_tab/AdvancedAppearanceControls.tsx:21
#: src/features/user/components/settings_utils/search_index/AppearanceIndex.ts:15
#: src/features/user/components/modals/tabs/advanced_settings_tab/AdvancedAppearanceControls.tsx:24
#: src/features/user/components/settings_utils/search_index/AppearanceIndex.ts:18
msgid "Confirm before joining voice channels"
msgstr "التأكيد قبل الانضمام إلى القنوات الصوتية"
@@ -8952,7 +8953,7 @@ msgstr "تأكيد كلمة المرور"
#. Settings search synonym. Used to match this term when the user types it in the settings search bar.
#. Settings search synonym. Used to match this term when the user types it in the settings search bar.
#: src/features/user/components/settings_utils/search_index/AppearanceIndex.ts:27
#: src/features/user/components/settings_utils/search_index/AppearanceIndex.ts:30
#: src/features/user/components/settings_utils/section_registry/AppearanceSections.ts:398
msgid "Confirm voice join"
msgstr "تأكيد الانضمام إلى الصوت"
@@ -8963,7 +8964,7 @@ msgid "Confirmation"
msgstr "تأكيد"
#. Settings search entry description. One-line summary of what the settings search entry controls.
#: src/features/user/components/settings_utils/search_index/AppearanceIndex.ts:43
#: src/features/user/components/settings_utils/search_index/AppearanceIndex.ts:46
msgid "Confirmation or double-click for community voice joins"
msgstr "التأكيد أو النقر المزدوج للانضمام إلى القنوات الصوتية في المجتمعات"
@@ -9200,7 +9201,7 @@ msgstr "بوابات سلامة المحتوى"
#. Settings search synonym. Used to match this term when the user types it in the settings search bar.
#: src/features/ui/action_menu/ContextMenu.tsx:23
#: src/features/user/components/settings_utils/search_index/AccessibilityIndex.ts:225
#: src/features/user/components/settings_utils/section_registry/SharedDescriptors.ts:155
#: src/features/user/components/settings_utils/section_registry/SharedDescriptors.ts:163
msgid "Context menu"
msgstr "قائمة السياق"
@@ -10871,7 +10872,7 @@ msgstr "مخصص…"
#. Billing button that opens the external customer portal.
#. Settings search synonym. Used to match this term when the user types it in the settings search bar.
#: src/features/app/components/dialogs/components/plutonium/SubscriptionCard.tsx:750
#: src/features/app/components/dialogs/components/plutonium/SubscriptionCard.tsx:766
#: src/features/user/components/settings_utils/search_index/PlutoniumIndex.ts:39
msgid "Customer portal"
msgstr "بوابة العملاء"
@@ -11341,7 +11342,7 @@ msgstr "ديلاوير"
#: src/features/user/components/modals/tabs/component_gallery_tab/ComponentGalleryTabButtonsTab.tsx:290
#: src/features/user/components/modals/tabs/component_gallery_tab/index.tsx:238
#: src/features/user/components/settings_utils/search_index/AccountSecurityIndex.ts:174
#: src/features/user/components/settings_utils/section_registry/SharedDescriptors.ts:188
#: src/features/user/components/settings_utils/section_registry/SharedDescriptors.ts:196
msgid "Delete"
msgstr "حذف"
@@ -11626,7 +11627,7 @@ msgid "Deleted {duration} of the member's recent message history"
msgstr "تم حذف {duration} من سجل رسائل العضو الحديثة"
#. Settings search synonym. Used to match this term when the user types it in the settings search bar.
#: src/features/user/components/settings_utils/section_registry/SharedDescriptors.ts:115
#: src/features/user/components/settings_utils/section_registry/SharedDescriptors.ts:123
msgid "Deleted text"
msgstr "نص محذوف"
@@ -12028,7 +12029,7 @@ msgid "Different sizes & member counts"
msgstr "أحجام وأعداد أعضاء مختلفة"
#. Toggle label and settings search entry for slightly muting text formatted with Markdown strikethrough.
#: src/features/user/components/settings_utils/section_registry/SharedDescriptors.ts:98
#: src/features/user/components/settings_utils/section_registry/SharedDescriptors.ts:106
msgid "Dim strikethrough text"
msgstr "تعتيم النص المشطوب"
@@ -12043,7 +12044,7 @@ msgstr "إدخال مباشر"
#: src/features/channel/components/bottomsheets/channel_details_bottom_sheet/ChannelDetailsBottomSheetShared.ts:14
#: src/features/channel/components/channel_header_components/developer_tools/MockingMenu.tsx:398
#: src/features/i18n/utils/CommonMessageDescriptors.ts:567
#: src/features/user/components/settings_utils/section_registry/SharedDescriptors.ts:86
#: src/features/user/components/settings_utils/section_registry/SharedDescriptors.ts:94
msgid "Direct message"
msgstr "رسالة مباشرة"
@@ -12480,7 +12481,7 @@ msgid "District of Columbia"
msgstr "مقاطعة كولومبيا"
#. Settings search synonym. Used to match this term when the user types it in the settings search bar.
#: src/features/user/components/settings_utils/section_registry/SharedDescriptors.ts:180
#: src/features/user/components/settings_utils/section_registry/SharedDescriptors.ts:188
msgid "DM"
msgstr "رسالة مباشرة"
@@ -12735,6 +12736,16 @@ msgstr "تنزيل {productName}"
msgid "Download <0>fluxer-verification</0> and place it in your <1>.well-known</1> folder so we can validate the domain."
msgstr "نزّل <0>fluxer-verification</0> وضعه في مجلد <1>.well-known</1> حتى نتمكن من التحقق من النطاق."
#. Settings search synonym. Used to match this term when the user types it in the settings search bar.
#: src/features/user/components/settings_utils/search_index/AppearanceIndex.ts:98
msgid "Download app"
msgstr "تنزيل التطبيق"
#. Settings search entry description. One-line summary of what the setting controls.
#: src/features/user/components/settings_utils/search_index/AppearanceIndex.ts:106
msgid "Download app button in the sidebar"
msgstr "تنزيل التطبيق"
#. Media context menu action that downloads an audio attachment.
#: src/features/ui/action_menu/items/MediaMenuData.tsx:122
msgid "Download audio"
@@ -13433,7 +13444,7 @@ msgstr "تم إظهار التضمينات"
#: src/features/app/config/AuditLogConstants.ts:167
#: src/features/emoji/components/emojis/EmojiListItem.tsx:337
#: src/features/theme_studio/sections/TokenGroups.ts:114
#: src/features/user/components/settings_utils/section_registry/SharedDescriptors.ts:163
#: src/features/user/components/settings_utils/section_registry/SharedDescriptors.ts:171
msgid "Emoji"
msgstr "رمز تعبيري"
@@ -13866,15 +13877,15 @@ msgid "End date"
msgstr "تاريخ الانتهاء"
#. Billing button that ends premium grace period immediately.
#: src/features/app/components/dialogs/components/plutonium/SubscriptionCard.tsx:856
#: src/features/app/components/dialogs/components/plutonium/SubscriptionCard.tsx:872
msgid "End grace now"
msgstr "إنهاء فترة السماح الآن"
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:164
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:163
msgid "End grace period"
msgstr "إنهاء فترة السماح"
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:157
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:156
msgid "End grace period now?"
msgstr "إنهاء فترة السماح الآن؟"
@@ -14681,7 +14692,7 @@ msgid "Failed to clear trusted domains."
msgstr "تعذّر مسح النطاقات الموثوقة."
#. Short label in the authentication SSO callback page. Keep the tone plain and specific.
#: src/features/auth/components/pages/SsoCallbackPage.tsx:28
#: src/features/auth/components/pages/SsoCallbackPage.tsx:30
msgid "Failed to complete SSO sign-in"
msgstr "تعذّر إكمال تسجيل الدخول الموحّد (SSO)"
@@ -15027,7 +15038,7 @@ msgid "Favorite channel"
msgstr "إضافة القناة إلى المفضلة"
#. Settings search synonym. Used to match this term when the user types it in the settings search bar.
#: src/features/user/components/settings_utils/search_index/AppearanceIndex.ts:79
#: src/features/user/components/settings_utils/search_index/AppearanceIndex.ts:82
msgid "Favorite channels"
msgstr "القنوات المفضلة"
@@ -15800,7 +15811,7 @@ msgstr "بوت يقبل الصداقة"
#: src/features/connection/components/modals/EditConnectionModal.tsx:86
#: src/features/search/utils/QuickSwitcherModalUtils.tsx:50
#: src/features/user/components/modals/tabs/my_profile_tab/TimezoneProfileSettings.tsx:63
#: src/features/user/components/settings_utils/section_registry/SharedDescriptors.ts:90
#: src/features/user/components/settings_utils/section_registry/SharedDescriptors.ts:98
msgid "Friends"
msgstr "الأصدقاء"
@@ -16145,7 +16156,7 @@ msgstr "احصل على تطبيق {productName} الجديد"
#. Settings search synonym. Used to match this term when the user types it in the settings search bar.
#. Type label for GIF media.
#: src/features/expressions/utils/FavoriteMemeUtils.ts:10
#: src/features/user/components/settings_utils/section_registry/SharedDescriptors.ts:159
#: src/features/user/components/settings_utils/section_registry/SharedDescriptors.ts:167
msgid "GIF"
msgstr "GIF"
@@ -16336,7 +16347,7 @@ msgstr "هدية: تنتهي صلاحيتها قريبًا"
msgid "Gift: grace period (still have access)"
msgstr "هدية: فترة سماح (لا يزال لديك حق الوصول)"
#: src/features/app/components/dialogs/components/plutonium/SubscriptionCard.tsx:486
#: src/features/app/components/dialogs/components/plutonium/SubscriptionCard.tsx:502
msgid "Gifted <0/> until <1>{giftEndDate}</1>. Does not renew automatically. Redeem more gift codes to extend."
msgstr "<0/> مُهدى حتى <1>{giftEndDate}</1>. لا يتجدد تلقائيًا. استرد المزيد من رموز الهدايا لتمديده."
@@ -16549,7 +16560,7 @@ msgid "Graphics"
msgstr "الرسومات"
#. Settings search synonym. Used to match this term when the user types it in the settings search bar.
#: src/features/user/components/settings_utils/section_registry/SharedDescriptors.ts:119
#: src/features/user/components/settings_utils/section_registry/SharedDescriptors.ts:127
msgid "Gray text"
msgstr "نص رمادي"
@@ -16815,7 +16826,7 @@ msgid "Hate speech"
msgstr "خطاب الكراهية"
#. Premium page text shown on an instance where the premium tier cannot be bought, only redeemed. {PREMIUM_PRODUCT_NAME} is the premium tier name.
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:297
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:296
msgid "Have a gift code? Redeem it to unlock {PREMIUM_PRODUCT_NAME}."
msgstr "هل لديك رمز هدية؟ استبدله لفتح {PREMIUM_PRODUCT_NAME}."
@@ -16896,11 +16907,21 @@ msgstr "مرحباً، {username}! سعيدون بوجودك معنا."
msgid "Help"
msgstr "مساعدة"
#. Settings search synonym. Used to match this term when the user types it in the settings search bar.
#: src/features/user/components/settings_utils/search_index/AppearanceIndex.ts:94
msgid "Help button"
msgstr "زر المساعدة"
#. Short label in the sidebar navigation help button.
#: src/features/app/components/layout/sidebar_nav/HelpButton.tsx:27
msgid "Help center"
msgstr "مركز المساعدة"
#. Settings search entry description. One-line summary of what the setting controls.
#: src/features/user/components/settings_utils/search_index/AppearanceIndex.ts:102
msgid "Help center button in the sidebar"
msgstr "مركز المساعدة"
#: src/features/user/components/modals/tabs/LanguageTab.tsx:348
msgid "Help translate {PRODUCT_NAME} into your language on <0>{I18N_WEBLATE_DOMAIN}</0>."
msgstr "ساعد في ترجمة {PRODUCT_NAME} إلى لغتك على <0>{I18N_WEBLATE_DOMAIN}</0>."
@@ -17035,7 +17056,7 @@ msgid "Hide join messages"
msgstr "إخفاء رسائل الانضمام"
#. Settings search synonym. Used to match this term when the user types it in the settings search bar.
#: src/features/user/components/settings_utils/search_index/AppearanceIndex.ts:47
#: src/features/user/components/settings_utils/search_index/AppearanceIndex.ts:50
msgid "Hide keyboard hints"
msgstr "إخفاء تلميحات لوحة المفاتيح"
@@ -18721,7 +18742,7 @@ msgstr "الانضمام إلى مكالمة الفيديو"
#. Settings search synonym. Used to match this term when the user types it in the settings search bar.
#. Settings search synonym. Used to match this term when the user types it in the settings search bar.
#: src/features/user/components/profile/VoiceActivityCard.tsx:67
#: src/features/user/components/settings_utils/search_index/AppearanceIndex.ts:31
#: src/features/user/components/settings_utils/search_index/AppearanceIndex.ts:34
#: src/features/user/components/settings_utils/section_registry/AppearanceSections.ts:454
msgid "Join voice"
msgstr "الانضمام إلى المحادثة الصوتية"
@@ -18764,6 +18785,7 @@ msgstr "التنقل بين القنوات غير المقروءة"
msgid "Jump between unread channels with mentions"
msgstr "التنقل بين القنوات غير المقروءة التي تحتوي على إشارات"
#: src/features/auth/components/pages/SsoCallbackPage.tsx:121
#: src/features/auth/flow/DesktopDeepLinkPrompt.tsx:75
msgid "Jump straight to the app to continue."
msgstr "انتقل مباشرة إلى التطبيق للمتابعة."
@@ -18904,7 +18926,7 @@ msgstr "الاحتفاظ بالمرفقات عند إفراغ نص الرسال
msgid "Keep free and premium tiers. You can customize the tiers, sell premium through Stripe, or hand out gift codes later from the admin panel."
msgstr "حافظ على المستويات المجانية والمدفوعة. يمكنك تخصيص المستويات، أو البيع عبر Stripe، أو توزيع رموز هدايا لاحقًا من لوحة التحكم."
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:166
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:165
msgid "Keep grace period"
msgstr "الاحتفاظ بفترة السماح"
@@ -18918,7 +18940,7 @@ msgstr "إبقاء Neko ثابتًا"
msgid "Keep running"
msgstr "مواصلة التشغيل"
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:144
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:143
msgid "Keep subscription"
msgstr "الاحتفاظ بالاشتراك"
@@ -18980,7 +19002,7 @@ msgid "Keyboard mode"
msgstr "وضع لوحة المفاتيح"
#. Settings search entry description. One-line summary of what the setting controls.
#: src/features/user/components/settings_utils/search_index/AppearanceIndex.ts:59
#: src/features/user/components/settings_utils/search_index/AppearanceIndex.ts:62
msgid "Keyboard shortcut hints in tooltips"
msgstr "إظهار اختصارات لوحة المفاتيح في التلميحات"
@@ -19275,8 +19297,8 @@ msgstr "زر الفأرة الأيسر"
msgid "Left tooltip"
msgstr "تلميح لليسار"
#: src/features/app/components/dialogs/components/plutonium/SubscriptionCard.tsx:539
#: src/features/app/components/dialogs/components/plutonium/SubscriptionCard.tsx:561
#: src/features/app/components/dialogs/components/plutonium/SubscriptionCard.tsx:555
#: src/features/app/components/dialogs/components/plutonium/SubscriptionCard.tsx:577
msgid "Legacy rate"
msgstr "السعر القديم"
@@ -20094,7 +20116,7 @@ msgstr "وضع علامة على أعلى قناة في صندوق الوارد
#. Settings search synonym. Used to match this term when the user types it in the settings search bar.
#: src/features/user/components/modals/tabs/component_gallery_tab/index.tsx:364
#: src/features/user/components/settings_utils/section_registry/SharedDescriptors.ts:123
#: src/features/user/components/settings_utils/section_registry/SharedDescriptors.ts:131
msgid "Markdown"
msgstr "تنسيق ماركداون"
@@ -20644,7 +20666,7 @@ msgstr "هل تريد الإشارة إلى هذا الدور؟"
#. Settings search synonym. Used to match this term when the user types it in the settings search bar.
#: src/features/guild/components/modals/GuildNotificationSettingsModal.tsx:136
#: src/features/i18n/utils/CommonMessageDescriptors.ts:648
#: src/features/user/components/settings_utils/section_registry/SharedDescriptors.ts:167
#: src/features/user/components/settings_utils/section_registry/SharedDescriptors.ts:175
msgid "Mentions"
msgstr "الإشارات"
@@ -21158,7 +21180,7 @@ msgid "Missing client_id"
msgstr "client_id مفقود"
#. SSO callback page error shown when the SSO callback is missing code or state parameters.
#: src/features/auth/components/pages/SsoCallbackPage.tsx:24
#: src/features/auth/components/pages/SsoCallbackPage.tsx:26
msgid "Missing SSO code. Sign in again."
msgstr "رمز تسجيل الدخول الموحّد مفقود. سجّل الدخول مرة أخرى."
@@ -21317,11 +21339,11 @@ msgstr "شهريًا"
msgid "Monthly {monthlyPrice}"
msgstr "شهريًا {monthlyPrice}"
#: src/features/app/components/dialogs/components/plutonium/SubscriptionCard.tsx:631
#: src/features/app/components/dialogs/components/plutonium/SubscriptionCard.tsx:647
msgid "Monthly billing starts on <0>{pendingChangeDate}</0>."
msgstr "تبدأ الفوترة الشهرية في <0>{pendingChangeDate}</0>."
#: src/features/app/components/dialogs/components/plutonium/SubscriptionCard.tsx:624
#: src/features/app/components/dialogs/components/plutonium/SubscriptionCard.tsx:640
msgid "Monthly billing starts on <0>{pendingChangeDate}</0>. First monthly charge: {pendingInitialPriceLabel}. Future monthly renewals: {pendingRecurringPriceLabel}/month."
msgstr "تبدأ الفوترة الشهرية في <0>{pendingChangeDate}</0>. أول رسوم شهرية: {pendingInitialPriceLabel}. التجديدات الشهرية اللاحقة: {pendingRecurringPriceLabel}/شهر."
@@ -21756,7 +21778,7 @@ msgstr "مكتوم"
#. Status label indicating the surface is currently muted.
#: src/features/app/components/layout/sidebar_nav/UseGuildListItemState.ts:23
#: src/features/user/components/settings_utils/search_index/ChatSettingsIndex.ts:491
#: src/features/user/components/settings_utils/section_registry/SharedDescriptors.ts:74
#: src/features/user/components/settings_utils/section_registry/SharedDescriptors.ts:82
#: src/lib/overlay/OverlayContextMenu.tsx:24
msgid "Muted"
msgstr "مكتوم"
@@ -21779,7 +21801,7 @@ msgstr "مكتوم الصوت من المشرفين"
#. Settings search synonym. Used to match this term when the user types it in the settings search bar.
#. Settings search synonym. Used to match this term when the user types it in the settings search bar.
#: src/features/user/components/settings_utils/search_index/ChatSettingsIndex.ts:495
#: src/features/user/components/settings_utils/section_registry/SharedDescriptors.ts:70
#: src/features/user/components/settings_utils/section_registry/SharedDescriptors.ts:78
msgid "Muted channels"
msgstr "القنوات المكتومة"
@@ -21992,7 +22014,7 @@ msgid "Narrates only the channel you're viewing. Narration follows you between c
msgstr "يقرأ رسائل القناة التي تشاهدها فقط. تنتقل القراءة معك بين القنوات."
#. Settings search synonym. Used to match this term when the user types it in the settings search bar.
#: src/features/user/components/settings_utils/section_registry/SharedDescriptors.ts:147
#: src/features/user/components/settings_utils/section_registry/SharedDescriptors.ts:155
msgid "Narration"
msgstr "السرد"
@@ -22057,12 +22079,12 @@ msgid "Need an account?"
msgstr "هل تحتاج إلى حساب؟"
#. Settings search synonym. Proper name of the optional cat sprite that chases the cursor.
#: src/features/user/components/settings_utils/section_registry/SharedDescriptors.ts:62
#: src/features/user/components/settings_utils/section_registry/SharedDescriptors.ts:70
msgid "Neko"
msgstr "نيكو"
#. Settings search entry description. One-line summary of what the setting controls.
#: src/features/user/components/settings_utils/search_index/AppearanceIndex.ts:63
#: src/features/user/components/settings_utils/search_index/AppearanceIndex.ts:66
msgid "Neko cat that chases your cursor"
msgstr "قط نيكو يطارد مؤشر الفأرة"
@@ -22247,12 +22269,12 @@ msgid "New password"
msgstr "كلمة مرور جديدة"
#. Plutonium subscription card line shown when a move to a lower price is scheduled but the new amount is unknown. {pendingChangeDate} is a date already formatted and localized by code; never write a date into the translation.
#: src/features/app/components/dialogs/components/plutonium/SubscriptionCard.tsx:595
#: src/features/app/components/dialogs/components/plutonium/SubscriptionCard.tsx:611
msgid "New price scheduled for <0>{pendingChangeDate}</0>."
msgstr "من المقرر تطبيق سعر جديد في <0>{pendingChangeDate}</0>."
#. Plutonium subscription card line shown when a move to a lower price is scheduled. {pendingChangeDate} is a date and {pendingTargetPriceLabel} is a currency amount, both already formatted and localized by code; never write a date or an amount into the translation.
#: src/features/app/components/dialogs/components/plutonium/SubscriptionCard.tsx:589
#: src/features/app/components/dialogs/components/plutonium/SubscriptionCard.tsx:605
msgid "New price scheduled for <0>{pendingChangeDate}</0>. Renewals will be {pendingTargetPriceLabel} from then on."
msgstr "من المقرر تطبيق سعر جديد في <0>{pendingChangeDate}</0>. سيكون التجديد بسعر {pendingTargetPriceLabel} من ذلك الحين فصاعدًا."
@@ -23560,6 +23582,8 @@ msgid "Open {macosSystemSettingsName} → {macosPrivacyAndSecuritySettingsName}
msgstr "افتح {macosSystemSettingsName} ← {macosPrivacyAndSecuritySettingsName} ← {macosScreenRecordingPermissionName}، ثم اسمح لـ {productName}. إذا كان {productName2} مفعّلًا بالفعل، فأغلقه تمامًا ثم أعد تشغيل {productName3} حتى يطبّق macOS الإذن."
#. Button label that opens the desktop app. productName is the app name.
#. Button that hands SSO sign-in back to the mobile app. productName is the app name.
#: src/features/auth/components/pages/SsoCallbackPage.tsx:34
#: src/features/auth/flow/DesktopDeepLinkPrompt.tsx:27
msgid "Open {productName}"
msgstr "فتح {productName}"
@@ -24945,7 +24969,7 @@ msgstr "ثبّت هذه الرسالة في القناة ليراها الجمي
#. Settings search synonym. Used to match this term when the user types it in the settings search bar.
#. Voice stats field label (mobile lobby). Shows current latency in ms.
#. Voice stats field label (mobile voice lobby). Shows current latency in ms.
#: src/features/user/components/settings_utils/section_registry/SharedDescriptors.ts:184
#: src/features/user/components/settings_utils/section_registry/SharedDescriptors.ts:192
#: src/features/voice/components/bottomsheets/DirectCallLobbyBottomSheet.tsx:118
#: src/features/voice/components/bottomsheets/VoiceLobbyBottomSheet.tsx:86
msgid "Ping"
@@ -25165,7 +25189,7 @@ msgid "Popped out"
msgstr "في نافذة منفصلة"
#. Settings search synonym. Used to match this term when the user types it in the settings search bar.
#: src/features/user/components/settings_utils/section_registry/SharedDescriptors.ts:196
#: src/features/user/components/settings_utils/section_registry/SharedDescriptors.ts:204
msgid "Popup"
msgstr "نافذة منبثقة"
@@ -26128,7 +26152,7 @@ msgstr "التفاعلات"
#. Billing button that cancels a scheduled subscription cancellation.
#. Button label on the premium-expired nagbar. Opens billing so the user can reactivate their subscription.
#: src/features/app/components/dialogs/components/plutonium/SubscriptionCard.tsx:746
#: src/features/app/components/dialogs/components/plutonium/SubscriptionCard.tsx:762
#: src/features/app/components/layout/app_layout/nagbars/PremiumExpiredNagbar.tsx:25
msgid "Reactivate"
msgstr "إعادة التفعيل"
@@ -26139,7 +26163,7 @@ msgid "Reactivate subscription"
msgstr "إعادة تفعيل الاشتراك"
#. Settings search synonym. Used to match this term when the user types it in the settings search bar.
#: src/features/user/components/settings_utils/section_registry/SharedDescriptors.ts:131
#: src/features/user/components/settings_utils/section_registry/SharedDescriptors.ts:139
msgid "Read aloud"
msgstr "قراءة بصوت عالٍ"
@@ -26156,7 +26180,7 @@ msgstr "قراءة سجل الرسائل"
#. Settings search synonym. Used to match this term when the user types it in the settings search bar.
#. Settings search synonym. Used to match this term when the user types it in the settings search bar.
#: src/features/user/components/settings_utils/search_index/AccessibilityIndex.ts:177
#: src/features/user/components/settings_utils/section_registry/SharedDescriptors.ts:127
#: src/features/user/components/settings_utils/section_registry/SharedDescriptors.ts:135
msgid "Read messages"
msgstr "قراءة الرسائل"
@@ -26351,8 +26375,8 @@ msgstr "استرداد لنفسك"
#. Billing button for entering a premium gift code.
#. Billing button for entering a premium gift code.
#: src/features/app/components/dialogs/components/plutonium/SubscriptionCard.tsx:719
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:307
#: src/features/app/components/dialogs/components/plutonium/SubscriptionCard.tsx:735
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:306
msgid "Redeem gift code"
msgstr "استرداد رمز الهدية"
@@ -26630,7 +26654,7 @@ msgstr "تذكر حجم النافذة وموضعها"
#: src/features/user/components/modals/tabs/chat_settings_tab/SearchEnginesTab.tsx:26
#: src/features/user/components/modals/tabs/LinkedAccountsTab.tsx:55
#: src/features/user/components/settings_utils/search_index/AccountSecurityIndex.ts:178
#: src/features/user/components/settings_utils/section_registry/SharedDescriptors.ts:192
#: src/features/user/components/settings_utils/section_registry/SharedDescriptors.ts:200
#: src/features/webhook/components/WebhookListItem.tsx:277
msgid "Remove"
msgstr "إزالة"
@@ -27123,7 +27147,7 @@ msgstr "عتبة التجديد (بالأيام)"
msgid "Renew window (days)"
msgstr "فترة التجديد (بالأيام)"
#: src/features/app/components/dialogs/components/plutonium/SubscriptionCard.tsx:701
#: src/features/app/components/dialogs/components/plutonium/SubscriptionCard.tsx:717
msgid "Renews on <0>{renewalDate}.</0>"
msgstr "يتجدد في <0>{renewalDate}.</0>"
@@ -27321,7 +27345,7 @@ msgid "Require a passkey as your second factor"
msgstr "طلب مفتاح مرور كعامل ثانٍ"
#. Short label for an advanced voice-channel navigation preference.
#: src/features/user/components/modals/tabs/advanced_settings_tab/AdvancedAppearanceControls.tsx:17
#: src/features/user/components/modals/tabs/advanced_settings_tab/AdvancedAppearanceControls.tsx:20
msgid "Require double-click to join voice channels"
msgstr "اشتراط النقر المزدوج للانضمام إلى القنوات الصوتية"
@@ -27872,8 +27896,8 @@ msgstr "استعادة النافذة"
#. Billing button for restarting a subscription during the grace period.
#. Billing button for starting a new subscription after premium expired.
#: src/features/app/components/dialogs/components/plutonium/SubscriptionCard.tsx:738
#: src/features/app/components/dialogs/components/plutonium/SubscriptionCard.tsx:742
#: src/features/app/components/dialogs/components/plutonium/SubscriptionCard.tsx:754
#: src/features/app/components/dialogs/components/plutonium/SubscriptionCard.tsx:758
msgid "Resubscribe"
msgstr "إعادة الاشتراك"
@@ -29578,7 +29602,7 @@ msgid "Sending malware or dangerous links"
msgstr "إرسال برامج ضارة أو روابط خطيرة"
#. Short label under a destination row in the forward modal, shown when the community has messaging disabled.
#: src/features/app/components/dialogs/shared/UseForwardDestinations.ts:54
#: src/features/app/components/dialogs/shared/UseForwardDestinations.ts:55
msgid "Sending messages is disabled in this community"
msgstr "تم تعطيل إرسال الرسائل في هذا المجتمع"
@@ -30120,7 +30144,7 @@ msgstr "اختصار"
#. Settings search synonym. Used to match this term when the user types it in the settings search bar.
#. Settings search synonym. Used to match this term when the user types it in the settings search bar.
#: src/features/user/components/settings_utils/search_index/AppearanceIndex.ts:55
#: src/features/user/components/settings_utils/search_index/AppearanceIndex.ts:58
#: src/features/user/components/settings_utils/section_registry/AppearanceSections.ts:370
msgid "Shortcut badges"
msgstr "شارات الاختصارات"
@@ -30280,7 +30304,9 @@ msgid "Show delete button"
msgstr "إظهار زر الحذف"
#. Short label for an advanced media button preference.
#. Toggle label and settings search entry for showing the download app button in the sidebar.
#: src/features/user/components/modals/tabs/advanced_settings_tab/AdvancedChatControls.tsx:76
#: src/features/user/components/settings_utils/section_registry/SharedDescriptors.ts:66
msgid "Show download button"
msgstr "إظهار زر التنزيل"
@@ -30322,7 +30348,7 @@ msgid "Show favorite button"
msgstr "إظهار زر المفضلة"
#. Settings search entry description. One-line summary of what the setting controls.
#: src/features/user/components/settings_utils/search_index/AppearanceIndex.ts:87
#: src/features/user/components/settings_utils/search_index/AppearanceIndex.ts:90
msgid "Show favorites throughout the app"
msgstr "إظهار العناصر المفضلة في جميع أنحاء التطبيق"
@@ -30350,6 +30376,11 @@ msgstr "إظهار مؤشر GIF"
msgid "Show GIFs button"
msgstr "إظهار زر صور GIF"
#. Toggle label and settings search entry for showing the help center button in the sidebar.
#: src/features/user/components/settings_utils/section_registry/SharedDescriptors.ts:62
msgid "Show help center button"
msgstr "إظهار زر مركز المساعدة"
#. Switch label in the community-folder settings modal. When on, the folder icon is shown in the sidebar while the folder is collapsed.
#: src/features/guild/components/modals/GuildFolderSettingsModal.tsx:77
msgid "Show icon when collapsed"
@@ -30565,7 +30596,7 @@ msgid "Show stickers in expression autocomplete"
msgstr "إظهار الملصقات في الإكمال التلقائي للتعبيرات"
#. Description for a visual accessibility setting that mutes the color of text formatted with Markdown strikethrough.
#: src/features/user/components/settings_utils/section_registry/SharedDescriptors.ts:102
#: src/features/user/components/settings_utils/section_registry/SharedDescriptors.ts:110
msgid "Show strikethrough Markdown text in a slightly muted color."
msgstr "إظهار نص Markdown المشطوب بلون باهت قليلًا."
@@ -31008,7 +31039,7 @@ msgid "Slowmode"
msgstr "الوضع البطيء"
#. Short label under a destination row in the forward modal while slowmode blocks sending. Preserve {remaining}; it is inserted by code.
#: src/features/app/components/dialogs/shared/UseForwardDestinations.ts:73
#: src/features/app/components/dialogs/shared/UseForwardDestinations.ts:74
msgid "Slowmode · wait {remaining}"
msgstr "الوضع البطيء · انتظر {remaining}"
@@ -31559,7 +31590,7 @@ msgstr "المشاهدون - {count}"
#. Native macOS Edit submenu title for text-to-speech actions.
#. Settings search synonym. Used to match this term when the user types it in the settings search bar.
#: src/features/platform/utils/DesktopLocaleBridge.ts:85
#: src/features/user/components/settings_utils/section_registry/SharedDescriptors.ts:143
#: src/features/user/components/settings_utils/section_registry/SharedDescriptors.ts:151
msgid "Speech"
msgstr "الكلام"
@@ -31670,12 +31701,12 @@ msgstr "أزرار أيقونات مربعة"
msgid "SSO is required to access this workspace."
msgstr "تتطلب مساحة العمل هذه تسجيل الدخول الموحّد (SSO)."
#: src/features/auth/components/pages/SsoCallbackPage.tsx:105
#: src/features/auth/components/pages/SsoCallbackPage.tsx:139
msgid "SSO sign-in failed"
msgstr "فشل تسجيل الدخول الموحّد"
#. SSO callback page error shown when the SSO sign-in timed out before the callback arrived.
#: src/features/auth/components/pages/SsoCallbackPage.tsx:20
#: src/features/auth/components/pages/SsoCallbackPage.tsx:22
msgid "SSO sign-in timed out. Try again."
msgstr "انتهت مهلة تسجيل الدخول الموحّد (SSO). حاول مرة أخرى."
@@ -31714,7 +31745,7 @@ msgid "Star animation"
msgstr "رسوم متحركة للنجمة"
#. Settings search synonym. Used to match this term when the user types it in the settings search bar.
#: src/features/user/components/settings_utils/search_index/AppearanceIndex.ts:83
#: src/features/user/components/settings_utils/search_index/AppearanceIndex.ts:86
msgid "Starred channels"
msgstr "القنوات المميزة بنجمة"
@@ -31791,7 +31822,7 @@ msgstr "البدء من جديد"
msgid "Start recording"
msgstr "بدء التسجيل"
#: src/features/search/components/quick_switcher/QuickSwitcherModal.tsx:592
#: src/features/search/components/quick_switcher/QuickSwitcherModal.tsx:582
msgid "Start searches with <0>{peoplePrefix}</0> <1>{textChannelPrefix}</1> <2>{voiceChannelPrefix}</2> <3>{communityPrefix}</3> to narrow down results."
msgstr "ابدأ عمليات البحث بـ <0>{peoplePrefix}</0> <1>{textChannelPrefix}</1> <2>{voiceChannelPrefix}</2> <3>{communityPrefix}</3> لتضييق نطاق النتائج."
@@ -31831,7 +31862,7 @@ msgid "Startup"
msgstr "بدء التشغيل"
#. Settings search synonym. Used to match this term when the user types it in the settings search bar.
#: src/features/user/components/settings_utils/search_index/AppearanceIndex.ts:75
#: src/features/user/components/settings_utils/search_index/AppearanceIndex.ts:78
msgid "Static Neko"
msgstr "نيكو ثابت"
@@ -32038,7 +32069,7 @@ msgid "Still indexing — check back in a moment"
msgstr "لا تزال الفهرسة جارية. تحقق مجددًا بعد قليل"
#. Settings search synonym. Used to match this term when the user types it in the settings search bar.
#: src/features/user/components/settings_utils/search_index/AppearanceIndex.ts:71
#: src/features/user/components/settings_utils/search_index/AppearanceIndex.ts:74
msgid "Still Neko"
msgstr "نيكو ساكن"
@@ -32067,7 +32098,7 @@ msgid "Stop mic test"
msgstr "إيقاف اختبار الميكروفون"
#. Settings search entry description. One-line summary of what the setting controls.
#: src/features/user/components/settings_utils/search_index/AppearanceIndex.ts:67
#: src/features/user/components/settings_utils/search_index/AppearanceIndex.ts:70
msgid "Stop Neko from chasing your cursor while keeping it draggable and interactive."
msgstr "منع نيكو من مطاردة مؤشر الفأرة مع إبقائه قابلًا للسحب والتفاعل."
@@ -32259,14 +32290,14 @@ msgid "Streaming privacy on"
msgstr "خصوصية البث مفعّلة"
#. Settings search synonym. Used to match this term when the user types it in the settings search bar.
#: src/features/user/components/settings_utils/section_registry/SharedDescriptors.ts:111
#: src/features/user/components/settings_utils/section_registry/SharedDescriptors.ts:119
msgid "Strike through"
msgstr "يتوسطه خط"
#. Selection formatting toolbar button.
#. Settings search synonym. Used to match this term when the user types it in the settings search bar.
#: src/features/lexical/composer/SelectionFormattingToolbar.tsx:72
#: src/features/user/components/settings_utils/section_registry/SharedDescriptors.ts:107
#: src/features/user/components/settings_utils/section_registry/SharedDescriptors.ts:115
msgid "Strikethrough"
msgstr "يتوسطه خط"
@@ -32490,7 +32521,7 @@ msgid "Switch price"
msgstr "تبديل السعر"
#. Billing button that opens confirmation to move the subscription down to the current price. {listPriceNewLabel} is the localized new price.
#: src/features/app/components/dialogs/components/plutonium/SubscriptionCard.tsx:806
#: src/features/app/components/dialogs/components/plutonium/SubscriptionCard.tsx:822
msgid "Switch to {listPriceNewLabel}"
msgstr "التبديل إلى {listPriceNewLabel}"
@@ -32515,7 +32546,7 @@ msgstr "التبديل إلى الإدخال المباشر"
#. Billing button that opens confirmation to switch to monthly billing.
#. Button confirming a change to monthly subscription billing.
#: src/features/app/components/dialogs/components/plutonium/SubscriptionCard.tsx:57
#: src/features/app/components/dialogs/components/plutonium/SubscriptionCard.tsx:789
#: src/features/app/components/dialogs/components/plutonium/SubscriptionCard.tsx:805
msgid "Switch to monthly"
msgstr "التبديل إلى الفوترة الشهرية"
@@ -32584,7 +32615,7 @@ msgstr "التبديل إلى عمودين"
#. Billing button that opens confirmation to switch to yearly billing.
#. Button confirming a change to yearly subscription billing.
#: src/features/app/components/dialogs/components/plutonium/SubscriptionCard.tsx:53
#: src/features/app/components/dialogs/components/plutonium/SubscriptionCard.tsx:785
#: src/features/app/components/dialogs/components/plutonium/SubscriptionCard.tsx:801
msgid "Switch to yearly"
msgstr "التبديل إلى الفوترة السنوية"
@@ -33074,7 +33105,7 @@ msgstr "أرسل لنا رسالة نصية من هاتفك"
#. Feature label / section heading for the text-to-speech feature.
#. Settings search synonym. Used to match this term when the user types it in the settings search bar.
#: src/features/i18n/utils/CommonMessageDescriptors.ts:419
#: src/features/user/components/settings_utils/section_registry/SharedDescriptors.ts:139
#: src/features/user/components/settings_utils/section_registry/SharedDescriptors.ts:147
msgid "Text-to-speech"
msgstr "تحويل النص إلى كلام"
@@ -34133,7 +34164,7 @@ msgid "This member isn't connected to voice anymore."
msgstr "لم يعد هذا العضو متصلًا بالقناة الصوتية."
#. Forward dialog error shown when the forwarded message contains age-restricted media and the target channel is not age-restricted.
#: src/features/app/components/dialogs/shared/UseForwardDestinations.ts:68
#: src/features/app/components/dialogs/shared/UseForwardDestinations.ts:69
msgid "This message can only be forwarded to age-restricted channels"
msgstr "لا يمكن إعادة توجيه هذه الرسالة إلا إلى القنوات المخصصة للبالغين"
@@ -34766,7 +34797,7 @@ msgid "Toolbar"
msgstr "شريط الأدوات"
#. Settings search synonym. Used to match this term when the user types it in the settings search bar.
#: src/features/user/components/settings_utils/search_index/AppearanceIndex.ts:51
#: src/features/user/components/settings_utils/search_index/AppearanceIndex.ts:54
msgid "Tooltip shortcuts"
msgstr "تلميحات الاختصارات"
@@ -35080,7 +35111,7 @@ msgid "Try sending your message again."
msgstr "حاول إرسال رسالتك مرة أخرى."
#. Settings search synonym. Used to match this term when the user types it in the settings search bar.
#: src/features/user/components/settings_utils/section_registry/SharedDescriptors.ts:135
#: src/features/user/components/settings_utils/section_registry/SharedDescriptors.ts:143
msgid "TTS"
msgstr "TTS"
@@ -35277,7 +35308,7 @@ msgstr "أربع وعشرون ساعة"
#. Settings search synonym. Used to match this term when the user types it in the settings search bar.
#. Settings search synonym. Used to match this term when the user types it in the settings search bar.
#: src/features/user/components/settings_utils/search_index/AppearanceIndex.ts:39
#: src/features/user/components/settings_utils/search_index/AppearanceIndex.ts:42
#: src/features/user/components/settings_utils/section_registry/AppearanceSections.ts:406
msgid "Two clicks"
msgstr "نقرتان"
@@ -35834,7 +35865,7 @@ msgstr "افتح الرموز التعبيرية المخصصة في الرسا
msgid "Unlock HD screen share with {premiumProductName}"
msgstr "افتح مشاركة الشاشة عالية الدقة باستخدام {premiumProductName}"
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:217
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:216
msgid "Unlock higher limits and exclusive features while supporting an independent communication platform."
msgstr "احصل على حدود أعلى وميزات حصرية مع دعم منصة تواصل مستقلة."
@@ -36044,7 +36075,7 @@ msgstr "غير مقروءة"
#. Tab label in the inbox popout filtering to channels with unread messages.
#: src/features/messaging/components/popouts/InboxPopout.tsx:30
#: src/features/user/components/settings_utils/search_index/NotificationsIndex.ts:10
#: src/features/user/components/settings_utils/section_registry/SharedDescriptors.ts:78
#: src/features/user/components/settings_utils/section_registry/SharedDescriptors.ts:86
msgid "Unread"
msgstr "غير مقروءة"
@@ -37866,7 +37897,7 @@ msgstr "مؤشرات مرئية توضح حالة المستخدم، وتُعر
#: src/features/app/components/layout/MobileBottomNav.tsx:123
#: src/features/channel/utils/ChannelUtils.tsx:31
#: src/features/user/components/modals/tabs/advanced_settings_tab/AdvancedSettingsCategories.ts:17
#: src/features/user/components/settings_utils/section_registry/SharedDescriptors.ts:82
#: src/features/user/components/settings_utils/section_registry/SharedDescriptors.ts:90
msgid "Voice"
msgstr "صوت"
@@ -37961,7 +37992,7 @@ msgstr "قناة صوتية"
#. Settings search synonym. Used to match this term when the user types it in the settings search bar.
#. Settings search synonym. Used to match this term when the user types it in the settings search bar.
#: src/features/user/components/settings_utils/search_index/AppearanceIndex.ts:23
#: src/features/user/components/settings_utils/search_index/AppearanceIndex.ts:26
#: src/features/user/components/settings_utils/section_registry/AppearanceSections.ts:402
msgid "Voice channel confirmation"
msgstr "تأكيد القناة الصوتية"
@@ -37972,7 +38003,7 @@ msgid "Voice channel full"
msgstr "القناة الصوتية ممتلئة"
#. Settings search entry label. Names the settings search entry in the settings UI.
#: src/features/user/components/settings_utils/search_index/AppearanceIndex.ts:19
#: src/features/user/components/settings_utils/search_index/AppearanceIndex.ts:22
msgid "Voice channel join behavior"
msgstr "سلوك الانضمام إلى القناة الصوتية"
@@ -38099,7 +38130,7 @@ msgstr "ملغاة"
#: src/features/i18n/utils/CommonMessageDescriptors.ts:378
#: src/features/user/components/modals/tabs/UserVoiceTab.tsx:181
#: src/features/user/components/settings_utils/search_index/NotificationsIndex.ts:86
#: src/features/user/components/settings_utils/section_registry/SharedDescriptors.ts:175
#: src/features/user/components/settings_utils/section_registry/SharedDescriptors.ts:183
msgid "Volume"
msgstr "مستوى الصوت"
@@ -38984,15 +39015,15 @@ msgstr "سنويًا {yearlyPrice}"
msgid "Yearly gift"
msgstr "هدية سنوية"
#: src/features/app/components/dialogs/components/plutonium/SubscriptionCard.tsx:618
#: src/features/app/components/dialogs/components/plutonium/SubscriptionCard.tsx:634
msgid "Yearly upgrade scheduled for <0>{pendingChangeDate}</0>."
msgstr "الترقية السنوية مجدولة بتاريخ <0>{pendingChangeDate}</0>."
#: src/features/app/components/dialogs/components/plutonium/SubscriptionCard.tsx:603
#: src/features/app/components/dialogs/components/plutonium/SubscriptionCard.tsx:619
msgid "Yearly upgrade scheduled for <0>{pendingChangeDate}</0>. First yearly charge: {pendingInitialPriceLabel} after a {pendingCreditPriceLabel} credit. Future yearly renewals: {pendingRecurringPriceLabel}/year."
msgstr "الترقية السنوية مجدولة بتاريخ <0>{pendingChangeDate}</0>. أول رسوم سنوية: {pendingInitialPriceLabel} بعد خصم رصيد بقيمة {pendingCreditPriceLabel}. التجديدات السنوية المستقبلية: {pendingRecurringPriceLabel} سنويًا."
#: src/features/app/components/dialogs/components/plutonium/SubscriptionCard.tsx:610
#: src/features/app/components/dialogs/components/plutonium/SubscriptionCard.tsx:626
msgid "Yearly upgrade scheduled for <0>{pendingChangeDate}</0>. First yearly charge: {pendingInitialPriceLabel}. Future yearly renewals: {pendingRecurringPriceLabel}/year."
msgstr "الترقية السنوية مجدولة بتاريخ <0>{pendingChangeDate}</0>. أول رسوم سنوية: {pendingInitialPriceLabel}. التجديدات السنوية المستقبلية: {pendingRecurringPriceLabel} سنويًا."
@@ -39462,10 +39493,10 @@ msgid "You just pressed Tab. Keyboard mode is now on so you can navigate {produc
msgstr "لقد ضغطت على Tab للتو. وضع لوحة المفاتيح قيد التشغيل الآن حتى تتمكن من التنقل في {productName} دون استخدام الماوس."
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:137
msgid "You keep your perks until your next renewal date, then have a 3-day grace period to resubscribe and keep your subscriber history."
msgstr "ستحتفظ بمزاياك حتى تاريخ التجديد التالي، ثم تحصل على فترة سماح مدتها 3 أيام لإعادة الاشتراك والاحتفاظ بسجل اشتراكك."
msgid "You keep your perks until your next renewal date. Reactivate before then to keep your subscriber history."
msgstr "ستحتفظ بمزاياك حتى تاريخ التجديد التالي. أعد تفعيل اشتراكك قبل ذلك للاحتفاظ بسجل اشتراكك."
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:159
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:158
msgid "You lose all {PREMIUM_PRODUCT_NAME} perks immediately and your subscriber history resets. This cannot be undone."
msgstr "ستفقد جميع مزايا {PREMIUM_PRODUCT_NAME} على الفور وستتم إعادة تعيين سجل اشتراكك. لا يمكن التراجع عن هذا الإجراء."
@@ -39508,12 +39539,12 @@ msgid "You need access to your current email to change it from here. Use phone v
msgstr "تحتاج إلى الوصول إلى بريدك الإلكتروني الحالي لتغييره من هنا. استخدم التحقق عبر الهاتف إذا كان متاحًا أو اتصل بالدعم."
#. Forward dialog error shown when the forwarded message has attachments and the user lacks Attach Files in the target channel.
#: src/features/app/components/dialogs/shared/UseForwardDestinations.ts:63
#: src/features/app/components/dialogs/shared/UseForwardDestinations.ts:64
msgid "You need the \"{attachFilesPermissionLabel}\" permission to attach files in this channel"
msgstr "أنت بحاجة إلى إذن \"{attachFilesPermissionLabel}\" لإرفاق الملفات في هذه القناة"
#. Forward dialog error shown when the forwarded message contains embeds and the user lacks Embed Links in the target channel.
#: src/features/app/components/dialogs/shared/UseForwardDestinations.ts:58
#: src/features/app/components/dialogs/shared/UseForwardDestinations.ts:59
msgid "You need the \"{embedLinksPermissionLabel}\" permission to embed links in this channel"
msgstr "أنت بحاجة إلى إذن \"{embedLinksPermissionLabel}\" لتضمين الروابط في هذه القناة"
@@ -40200,6 +40231,11 @@ msgstr "منطقتك"
msgid "Your registration needs an extra anti-spam check before you can continue."
msgstr "يتطلب تسجيلك فحصًا إضافيًا لمكافحة الرسائل المزعجة قبل أن تتمكن من المتابعة."
#. Plutonium subscription card text shown while a failed renewal payment is being retried. {graceDate} is a date already formatted and localized by code; never write a date into the translation.
#: src/features/app/components/dialogs/components/plutonium/SubscriptionCard.tsx:470
msgid "Your renewal payment failed but <0/> stay active until <1>{graceDate}</1>. Update your payment method before then to keep your subscription."
msgstr "فشل دفع تجديد اشتراكك، لكن <0/> ستظل نشطة حتى <1>{graceDate}</1>. حدّث طريقة الدفع قبل ذلك التاريخ للاحتفاظ باشتراكك."
#. Video settings note shown when the saved screen share quality is above what this account can send. Resolutions are short labels such as 1440p or Source and rates are whole numbers. FPS is a technical token.
#: src/features/user/components/modals/tabs/UserVideoTab.tsx:92
msgid "Your saved {savedResolution} at {savedFrameRate} FPS needs {premiumProductName}, so shares use {resolution} at {frameRate} FPS."
@@ -40221,7 +40257,7 @@ msgstr "البث المباشر الخاص بك لا يزال نشطًا"
msgid "Your subscription can't move to the new price right now."
msgstr "لا يمكن نقل اشتراكك إلى السعر الجديد في الوقت الحالي."
#: src/features/app/components/dialogs/components/plutonium/SubscriptionCard.tsx:470
#: src/features/app/components/dialogs/components/plutonium/SubscriptionCard.tsx:486
msgid "Your subscription ended but <0/> stay active until <1>{graceDate}</1>. Resubscribe before then to keep your subscriber history."
msgstr "انتهى اشتراكك، لكن <0/> ستظل نشطة حتى <1>{graceDate}</1>. أعد الاشتراك قبل ذلك التاريخ للاحتفاظ بسجل اشتراكك."
@@ -40250,7 +40286,7 @@ msgstr "تم تحويل اشتراكك إلى الفوترة الشهرية."
msgid "Your subscription has been switched to yearly billing."
msgstr "تم تحويل اشتراكك إلى الفوترة السنوية."
#: src/features/app/components/dialogs/components/plutonium/SubscriptionCard.tsx:512
#: src/features/app/components/dialogs/components/plutonium/SubscriptionCard.tsx:528
msgid "Your subscription is active but perks are temporarily paused."
msgstr "اشتراكك نشط، ولكن المزايا متوقفة مؤقتًا."
@@ -40270,7 +40306,7 @@ msgid "Your subscription moves to the new price on {effectiveDate}."
msgstr "سينتقل اشتراكك إلى السعر الجديد في {effectiveDate}."
#. Plutonium subscription card line shown to a subscriber on a legacy price whose subscription is already set to cancel. {cancelDate} is a date and {listPriceNewLabel} is a currency amount, both already formatted and localized by code; never write a date or an amount into the translation.
#: src/features/app/components/dialogs/components/plutonium/SubscriptionCard.tsx:666
#: src/features/app/components/dialogs/components/plutonium/SubscriptionCard.tsx:682
msgid "Your subscription still ends on <0>{cancelDate}</0>. The current price is now {listPriceNewLabel}. If you reactivate, you can switch to it from here."
msgstr "سينتهي اشتراكك في <0>{cancelDate}</0> كما هو مقرر. السعر الحالي الآن {listPriceNewLabel}. إذا أعدت التفعيل، يمكنك التبديل إليه من هنا."
@@ -448,6 +448,9 @@
{
"msgid": "Download"
},
{
"msgid": "Download app"
},
{
"msgid": "Download {productName}"
},
@@ -1884,6 +1887,12 @@
{
"msgid": "Domain moved nagbar"
},
{
"msgid": "Download app"
},
{
"msgid": "Download app button in the sidebar"
},
{
"msgid": "Drag members between voice channels they can access, and disconnect them from voice."
},
@@ -2016,6 +2025,9 @@
{
"msgid": "Have a gift code? Redeem it to unlock {PREMIUM_PRODUCT_NAME}."
},
{
"msgid": "Help button"
},
{
"msgid": "Help translate {PRODUCT_NAME} into your language on <0>{I18N_WEBLATE_DOMAIN}</0>."
},

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