mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-09 12:12:25 +09:00
Compare commits
14
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dc32a7c70e | ||
|
|
ab0b483fbe | ||
|
|
6e2f90b03c | ||
|
|
5e0806f479 | ||
|
|
dfdfffe5de | ||
|
|
f5e32aed31 | ||
|
|
710c1aeaa8 | ||
|
|
af49cd6cc4 | ||
|
|
ca719e7b5e | ||
|
|
ab4069ed0e | ||
|
|
12bfaa83ba | ||
|
|
1076728241 | ||
|
|
360b984adc | ||
|
|
dcdf7e1d93 |
Generated
+2
-2
@@ -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",
|
||||
|
||||
@@ -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=
|
||||
|
||||
|
||||
@@ -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:-}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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>,
|
||||
|
||||
@@ -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>,
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
pub mod acl;
|
||||
pub mod admin_flags;
|
||||
pub mod admin_hints;
|
||||
pub mod api;
|
||||
pub mod config;
|
||||
pub mod fonts;
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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')
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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}}}],
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>}
|
||||
|
||||
+17
-1
@@ -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}
|
||||
|
||||
+20
-5
@@ -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;
|
||||
|
||||
+3
-5
@@ -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
Reference in New Issue
Block a user