mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-17 04:55:42 +09:00
fix(admin): save premium limits and model changes (#3386)
This commit is contained in:
1 parent
7d2c51a57f
commit
6acd7f3d22
18 files changed
+1031
-55
No files matched your search
@@ -143,7 +143,7 @@ pub(super) fn billing_result<T>(result: Result<T, ApiError>) -> FlashData {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn validation_message(error: &ApiError) -> Option<String> {
|
pub(super) fn validation_message(error: &ApiError) -> Option<String> {
|
||||||
let ApiError::Http {
|
let ApiError::Http {
|
||||||
status: 400,
|
status: 400,
|
||||||
message,
|
message,
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ use crate::{
|
|||||||
InstanceIntegrationsUpdateRequest, InstanceMediaUpdateRequest,
|
InstanceIntegrationsUpdateRequest, InstanceMediaUpdateRequest,
|
||||||
InstancePolicyUpdateRequest, InstanceRegistrationConfigUpdateRequest,
|
InstancePolicyUpdateRequest, InstanceRegistrationConfigUpdateRequest,
|
||||||
InstanceServicesUpdateRequest, InstanceYoutubeIntegrationUpdateRequest,
|
InstanceServicesUpdateRequest, InstanceYoutubeIntegrationUpdateRequest,
|
||||||
LimitConfigUpdateRequest, LimitRule, LimitRuleFilters, PremiumMode,
|
LimitConfigUpdateRequest, LimitKeyMetadata, LimitRule, LimitRuleFilters, PremiumMode,
|
||||||
PushRelayConfigUpdateRequest, RegistrationMode, SsoConfigUpdateRequest,
|
PushRelayConfigUpdateRequest, RegistrationMode, SsoConfigUpdateRequest,
|
||||||
VOICE_P2P_MAX_PARTICIPANTS_RANGE, VoiceE2eeScope, VoiceP2pConfigUpdateRequest,
|
VOICE_P2P_MAX_PARTICIPANTS_RANGE, VoiceE2eeScope, VoiceP2pConfigUpdateRequest,
|
||||||
},
|
},
|
||||||
@@ -405,12 +405,17 @@ fn render_fragment_with_toast(markup: Markup, flash: &FlashData) -> Response {
|
|||||||
response
|
response
|
||||||
}
|
}
|
||||||
|
|
||||||
fn instance_config_result<T, E: std::fmt::Display>(result: Result<T, E>) -> FlashData {
|
fn instance_config_result<T>(result: Result<T, crate::api::client::ApiError>) -> FlashData {
|
||||||
match result {
|
match result {
|
||||||
Ok(_) => FlashData::success("Instance config updated"),
|
Ok(_) => FlashData::success("Instance config updated"),
|
||||||
Err(error) => {
|
Err(error) => {
|
||||||
tracing::warn!(%error, "admin API request failed: update instance config");
|
tracing::warn!(%error, "admin API request failed: update instance config");
|
||||||
FlashData::error("Failed to update instance config")
|
match super::billing_actions::validation_message(&error) {
|
||||||
|
Some(message) => {
|
||||||
|
FlashData::error(format!("Failed to update instance config: {message}"))
|
||||||
|
}
|
||||||
|
None => FlashData::error("Failed to update instance config"),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1042,6 +1047,7 @@ fn update_limit_rule_values(
|
|||||||
rule: &mut LimitRule,
|
rule: &mut LimitRule,
|
||||||
form: &MultiValueForm,
|
form: &MultiValueForm,
|
||||||
limit_keys: &[String],
|
limit_keys: &[String],
|
||||||
|
metadata: &std::collections::BTreeMap<String, LimitKeyMetadata>,
|
||||||
fallback_limits: Option<&std::collections::BTreeMap<String, u64>>,
|
fallback_limits: Option<&std::collections::BTreeMap<String, u64>>,
|
||||||
) {
|
) {
|
||||||
let mut limits = std::collections::BTreeMap::new();
|
let mut limits = std::collections::BTreeMap::new();
|
||||||
@@ -1055,12 +1061,19 @@ fn update_limit_rule_values(
|
|||||||
{
|
{
|
||||||
limits.extend(defaults.clone());
|
limits.extend(defaults.clone());
|
||||||
}
|
}
|
||||||
|
for key in limit_keys {
|
||||||
|
if metadata.get(key).is_some_and(|metadata| metadata.is_toggle)
|
||||||
|
&& rule.limits.contains_key(key)
|
||||||
|
{
|
||||||
|
limits.entry(key.clone()).or_insert(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
rule.limits = limits;
|
rule.limits = limits;
|
||||||
rule.filters = build_limit_filters(form);
|
rule.filters = build_limit_filters(form);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn limit_config_result<T, E: std::fmt::Display>(
|
fn limit_config_result<T>(
|
||||||
result: Result<T, E>,
|
result: Result<T, crate::api::client::ApiError>,
|
||||||
success_message: &'static str,
|
success_message: &'static str,
|
||||||
error_message: &'static str,
|
error_message: &'static str,
|
||||||
) -> FlashData {
|
) -> FlashData {
|
||||||
@@ -1068,7 +1081,10 @@ fn limit_config_result<T, E: std::fmt::Display>(
|
|||||||
Ok(_) => FlashData::success(success_message),
|
Ok(_) => FlashData::success(success_message),
|
||||||
Err(error) => {
|
Err(error) => {
|
||||||
tracing::warn!(%error, "admin API request failed: update limit config");
|
tracing::warn!(%error, "admin API request failed: update limit config");
|
||||||
FlashData::error(error_message)
|
match super::billing_actions::validation_message(&error) {
|
||||||
|
Some(message) => FlashData::error(format!("{error_message}: {message}")),
|
||||||
|
None => FlashData::error(error_message),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1137,7 +1153,13 @@ pub async fn limit_config_post(
|
|||||||
.defaults
|
.defaults
|
||||||
.get(&rule_id)
|
.get(&rule_id)
|
||||||
.or_else(|| current.defaults.get("default"));
|
.or_else(|| current.defaults.get("default"));
|
||||||
update_limit_rule_values(rule, &form, ¤t.limit_keys, fallback);
|
update_limit_rule_values(
|
||||||
|
rule,
|
||||||
|
&form,
|
||||||
|
¤t.limit_keys,
|
||||||
|
¤t.metadata,
|
||||||
|
fallback,
|
||||||
|
);
|
||||||
let request = LimitConfigUpdateRequest { limit_config };
|
let request = LimitConfigUpdateRequest { limit_config };
|
||||||
let result = client.update_limit_config(&request).await;
|
let result = client.update_limit_config(&request).await;
|
||||||
let flash = limit_config_result(
|
let flash = limit_config_result(
|
||||||
@@ -1147,6 +1169,43 @@ pub async fn limit_config_post(
|
|||||||
);
|
);
|
||||||
return redirect_back_with_flash(base, "/limit-config", flash, secure_cookies);
|
return redirect_back_with_flash(base, "/limit-config", flash, secure_cookies);
|
||||||
}
|
}
|
||||||
|
"reset" => {
|
||||||
|
let rule = aq
|
||||||
|
.rule
|
||||||
|
.as_deref()
|
||||||
|
.and_then(clean_string)
|
||||||
|
.and_then(|rule_id| {
|
||||||
|
limit_config
|
||||||
|
.rules
|
||||||
|
.iter_mut()
|
||||||
|
.find(|rule| rule.id == rule_id)
|
||||||
|
});
|
||||||
|
let Some(rule) = rule else {
|
||||||
|
return redirect_back_with_flash(
|
||||||
|
base,
|
||||||
|
"/limit-config",
|
||||||
|
FlashData::error("Rule not found"),
|
||||||
|
secure_cookies,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
let Some(defaults) = current.defaults.get(&rule.id) else {
|
||||||
|
return redirect_back_with_flash(
|
||||||
|
base,
|
||||||
|
"/limit-config",
|
||||||
|
FlashData::error("This rule has no defaults to reset to"),
|
||||||
|
secure_cookies,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
rule.limits = defaults.clone();
|
||||||
|
let request = LimitConfigUpdateRequest { limit_config };
|
||||||
|
let result = client.update_limit_config(&request).await;
|
||||||
|
let flash = limit_config_result(
|
||||||
|
result,
|
||||||
|
"Limit rule reset to defaults",
|
||||||
|
"Failed to reset limit rule",
|
||||||
|
);
|
||||||
|
return redirect_back_with_flash(base, "/limit-config", flash, secure_cookies);
|
||||||
|
}
|
||||||
"delete" => {
|
"delete" => {
|
||||||
let rule_id = match aq.rule.as_deref().and_then(clean_string) {
|
let rule_id = match aq.rule.as_deref().and_then(clean_string) {
|
||||||
Some(rule_id) => rule_id,
|
Some(rule_id) => rule_id,
|
||||||
@@ -1360,6 +1419,109 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn instance_config_result_surfaces_the_api_validation_message() {
|
||||||
|
let error = crate::api::client::ApiError::Http {
|
||||||
|
status: 400,
|
||||||
|
message: serde_json::json!({
|
||||||
|
"code": "INVALID_FORM_BODY",
|
||||||
|
"message": "Input Validation Error",
|
||||||
|
"errors": [{
|
||||||
|
"path": "policy.premium_mode",
|
||||||
|
"code": "X",
|
||||||
|
"message": "Disable billing before switching the premium mode to everyone"
|
||||||
|
}]
|
||||||
|
})
|
||||||
|
.to_string(),
|
||||||
|
};
|
||||||
|
assert_eq!(
|
||||||
|
instance_config_result::<()>(Err(error)).message,
|
||||||
|
"Failed to update instance config: policy.premium_mode: Disable billing before switching the premium mode to everyone"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn limit_config_result_surfaces_the_api_validation_message() {
|
||||||
|
let error = crate::api::client::ApiError::Http {
|
||||||
|
status: 400,
|
||||||
|
message: serde_json::json!({
|
||||||
|
"code": "INVALID_FORM_BODY",
|
||||||
|
"message": "Input Validation Error",
|
||||||
|
"errors": [{
|
||||||
|
"path": "limit_config.rules.0.limits.max_bio_length",
|
||||||
|
"code": "VALUE_MUST_BE_INTEGER_IN_RANGE",
|
||||||
|
"message": "Value must be an integer between 0 and 320."
|
||||||
|
}]
|
||||||
|
})
|
||||||
|
.to_string(),
|
||||||
|
};
|
||||||
|
assert_eq!(
|
||||||
|
limit_config_result::<()>(Err(error), "Saved", "Failed to update limit configuration")
|
||||||
|
.message,
|
||||||
|
"Failed to update limit configuration: limit_config.rules.0.limits.max_bio_length: Value must be an integer between 0 and 320."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn update_limit_rule_values_saves_unchecked_toggles_as_off() {
|
||||||
|
let toggle = |key: &str, is_toggle: bool| {
|
||||||
|
(
|
||||||
|
key.to_owned(),
|
||||||
|
LimitKeyMetadata {
|
||||||
|
key: key.to_owned(),
|
||||||
|
label: key.to_owned(),
|
||||||
|
description: String::new(),
|
||||||
|
category: "features".to_owned(),
|
||||||
|
scope: "user".to_owned(),
|
||||||
|
is_toggle,
|
||||||
|
unit: None,
|
||||||
|
min: None,
|
||||||
|
max: None,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
};
|
||||||
|
let metadata = std::collections::BTreeMap::from([
|
||||||
|
toggle("feature_animated_avatar", true),
|
||||||
|
toggle("feature_animated_banner", true),
|
||||||
|
toggle("max_guilds", false),
|
||||||
|
]);
|
||||||
|
let limit_keys = metadata.keys().cloned().collect::<Vec<_>>();
|
||||||
|
let mut rule = LimitRule {
|
||||||
|
id: "premium".to_owned(),
|
||||||
|
filters: None,
|
||||||
|
limits: std::collections::BTreeMap::from([
|
||||||
|
("feature_animated_avatar".to_owned(), 1),
|
||||||
|
("feature_animated_banner".to_owned(), 1),
|
||||||
|
("max_guilds".to_owned(), 200),
|
||||||
|
]),
|
||||||
|
modified_fields: None,
|
||||||
|
};
|
||||||
|
let form =
|
||||||
|
MultiValueForm::parse(b"traits=premium&feature_animated_banner=1&max_guilds=250");
|
||||||
|
update_limit_rule_values(&mut rule, &form, &limit_keys, &metadata, None);
|
||||||
|
assert_eq!(
|
||||||
|
rule.limits,
|
||||||
|
std::collections::BTreeMap::from([
|
||||||
|
("feature_animated_avatar".to_owned(), 0),
|
||||||
|
("feature_animated_banner".to_owned(), 1),
|
||||||
|
("max_guilds".to_owned(), 250),
|
||||||
|
])
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut custom = LimitRule {
|
||||||
|
id: "vip".to_owned(),
|
||||||
|
filters: None,
|
||||||
|
limits: std::collections::BTreeMap::from([("max_guilds".to_owned(), 300)]),
|
||||||
|
modified_fields: None,
|
||||||
|
};
|
||||||
|
let form = MultiValueForm::parse(b"traits=vip&max_guilds=300");
|
||||||
|
update_limit_rule_values(&mut custom, &form, &limit_keys, &metadata, None);
|
||||||
|
assert_eq!(
|
||||||
|
custom.limits,
|
||||||
|
std::collections::BTreeMap::from([("max_guilds".to_owned(), 300)])
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn parse_experiment_user_ids_splits_newlines_and_commas() {
|
fn parse_experiment_user_ids_splits_newlines_and_commas() {
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@@ -1823,7 +1985,13 @@ mod tests {
|
|||||||
limits: std::collections::BTreeMap::new(),
|
limits: std::collections::BTreeMap::new(),
|
||||||
modified_fields: None,
|
modified_fields: None,
|
||||||
};
|
};
|
||||||
update_limit_rule_values(&mut rule, &form, &["message_send".to_owned()], None);
|
update_limit_rule_values(
|
||||||
|
&mut rule,
|
||||||
|
&form,
|
||||||
|
&["message_send".to_owned()],
|
||||||
|
&std::collections::BTreeMap::new(),
|
||||||
|
None,
|
||||||
|
);
|
||||||
assert_eq!(rule.limits.get("message_send"), Some(&1));
|
assert_eq!(rule.limits.get("message_send"), Some(&1));
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
rule.filters.expect("filters").traits,
|
rule.filters.expect("filters").traits,
|
||||||
|
|||||||
@@ -151,6 +151,10 @@ pub fn danger_button(label: &str) -> Markup {
|
|||||||
button_markup(label, "submit", DANGER_BUTTON_CLASS)
|
button_markup(label, "submit", DANGER_BUTTON_CLASS)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn secondary_button(label: &str) -> Markup {
|
||||||
|
button_markup(label, "submit", SECONDARY_BUTTON_CLASS)
|
||||||
|
}
|
||||||
|
|
||||||
pub fn form_field_group(
|
pub fn form_field_group(
|
||||||
label: &str,
|
label: &str,
|
||||||
name: &str,
|
name: &str,
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ use crate::{
|
|||||||
ExperimentDeliveryConfigResponse, GatewayRolloutConfigResponse, InstanceConfigResponse,
|
ExperimentDeliveryConfigResponse, GatewayRolloutConfigResponse, InstanceConfigResponse,
|
||||||
InstanceIntegrationsResponse, InstanceMediaResponse, InstancePolicyResponse,
|
InstanceIntegrationsResponse, InstanceMediaResponse, InstancePolicyResponse,
|
||||||
InstanceRegistrationResponse, LimitConfigResponse, PendingRegistrationResponse,
|
InstanceRegistrationResponse, LimitConfigResponse, PendingRegistrationResponse,
|
||||||
PushRelayConfigResponse, RegistrationUrlResponse, SsoConfigResponse, TagStyle,
|
PremiumMode, PushRelayConfigResponse, RegistrationUrlResponse, SsoConfigResponse, TagStyle,
|
||||||
VOICE_P2P_DEFAULT_SALT, VOICE_P2P_MAX_PARTICIPANTS_RANGE, VoiceP2pConfigResponse,
|
VOICE_P2P_DEFAULT_SALT, VOICE_P2P_MAX_PARTICIPANTS_RANGE, VoiceP2pConfigResponse,
|
||||||
},
|
},
|
||||||
config::AdminConfig,
|
config::AdminConfig,
|
||||||
@@ -158,6 +158,7 @@ pub fn instance_config_page(
|
|||||||
csrf_token,
|
csrf_token,
|
||||||
&instance_config.policy,
|
&instance_config.policy,
|
||||||
&instance_config.app_public.branding.premium_product_name,
|
&instance_config.app_public.branding.premium_product_name,
|
||||||
|
instance_config.billing.enabled == Some(true),
|
||||||
))
|
))
|
||||||
},
|
},
|
||||||
))
|
))
|
||||||
@@ -264,6 +265,7 @@ fn policy_config_section(
|
|||||||
csrf_token: &str,
|
csrf_token: &str,
|
||||||
policy: &InstancePolicyResponse,
|
policy: &InstancePolicyResponse,
|
||||||
premium_name: &str,
|
premium_name: &str,
|
||||||
|
billing_enabled: bool,
|
||||||
) -> Markup {
|
) -> Markup {
|
||||||
section_card_with_description(
|
section_card_with_description(
|
||||||
"Community & Policy",
|
"Community & Policy",
|
||||||
@@ -274,7 +276,7 @@ fn policy_config_section(
|
|||||||
div class="space-y-8" {
|
div class="space-y-8" {
|
||||||
(single_community_form(base, csrf_token, policy))
|
(single_community_form(base, csrf_token, policy))
|
||||||
(direct_messages_form(base, csrf_token, policy))
|
(direct_messages_form(base, csrf_token, policy))
|
||||||
(premium_mode_form(base, csrf_token, policy, premium_name))
|
(premium_mode_form(base, csrf_token, policy, premium_name, billing_enabled))
|
||||||
(community_creation_form(base, csrf_token, policy))
|
(community_creation_form(base, csrf_token, policy))
|
||||||
(services_form(base, csrf_token, policy))
|
(services_form(base, csrf_token, policy))
|
||||||
}
|
}
|
||||||
@@ -373,6 +375,7 @@ fn premium_mode_form(
|
|||||||
csrf_token: &str,
|
csrf_token: &str,
|
||||||
policy: &InstancePolicyResponse,
|
policy: &InstancePolicyResponse,
|
||||||
premium_name: &str,
|
premium_name: &str,
|
||||||
|
billing_enabled: bool,
|
||||||
) -> Markup {
|
) -> Markup {
|
||||||
let mirror_label = format!("Mirror (Free and {premium_name} tiers)");
|
let mirror_label = format!("Mirror (Free and {premium_name} tiers)");
|
||||||
let everyone_label = format!("Everyone (every member gets {premium_name} limits)");
|
let everyone_label = format!("Everyone (every member gets {premium_name} limits)");
|
||||||
@@ -386,6 +389,16 @@ fn premium_mode_form(
|
|||||||
("mirror", mirror_label.as_str()),
|
("mirror", mirror_label.as_str()),
|
||||||
("everyone", everyone_label.as_str()),
|
("everyone", everyone_label.as_str()),
|
||||||
], policy.premium_mode.as_str()))
|
], policy.premium_mode.as_str()))
|
||||||
|
p class="text-xs text-neutral-500" {
|
||||||
|
"Limits you have not edited follow the model. Limits you changed under Limit \
|
||||||
|
Config keep your values in both models."
|
||||||
|
}
|
||||||
|
@if billing_enabled && matches!(policy.premium_mode, PremiumMode::Mirror) {
|
||||||
|
p class="text-sm text-amber-700" {
|
||||||
|
"Billing is enabled. Disable it under Premium & billing before switching \
|
||||||
|
the premium model to Everyone."
|
||||||
|
}
|
||||||
|
}
|
||||||
(form_actions(html! {
|
(form_actions(html! {
|
||||||
(submit_button("Save premium model"))
|
(submit_button("Save premium model"))
|
||||||
}))
|
}))
|
||||||
|
|||||||
@@ -8,7 +8,10 @@ use crate::{
|
|||||||
middleware::auth::AuthContext,
|
middleware::auth::AuthContext,
|
||||||
templates::{
|
templates::{
|
||||||
components::{
|
components::{
|
||||||
form::{FORM_INPUT_CLASS, csrf_input, danger_button, form_actions, submit_button},
|
form::{
|
||||||
|
FORM_INPUT_CLASS, csrf_input, danger_button, form_actions, secondary_button,
|
||||||
|
submit_button,
|
||||||
|
},
|
||||||
page_container::{card_with_header, page_header},
|
page_container::{card_with_header, page_header},
|
||||||
tooltip,
|
tooltip,
|
||||||
},
|
},
|
||||||
@@ -81,7 +84,7 @@ fn render_limit_page(
|
|||||||
div class="space-y-6" {
|
div class="space-y-6" {
|
||||||
(rule_tabs(config, &rules, active_rule_id, can_update))
|
(rule_tabs(config, &rules, active_rule_id, can_update))
|
||||||
@if let Some(rule) = active_rule {
|
@if let Some(rule) = active_rule {
|
||||||
(rule_header(config, csrf_token, rule, can_update))
|
(rule_header(&config.base_path, csrf_token, rule, can_update, response.defaults.contains_key(&rule.id)))
|
||||||
(rule_editor(config, csrf_token, response, rule, can_update))
|
(rule_editor(config, csrf_token, response, rule, can_update))
|
||||||
} @else {
|
} @else {
|
||||||
div class="rounded-lg border border-neutral-200 bg-white p-4 shadow-sm" {
|
div class="rounded-lg border border-neutral-200 bg-white p-4 shadow-sm" {
|
||||||
@@ -151,11 +154,18 @@ fn rule_tabs(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn rule_header(
|
fn rule_header(
|
||||||
config: &AdminConfig,
|
base: &str,
|
||||||
csrf_token: &str,
|
csrf_token: &str,
|
||||||
rule: &LimitRule,
|
rule: &LimitRule,
|
||||||
can_update: bool,
|
can_update: bool,
|
||||||
|
has_defaults: bool,
|
||||||
) -> Markup {
|
) -> Markup {
|
||||||
|
let can_reset = can_update
|
||||||
|
&& has_defaults
|
||||||
|
&& rule
|
||||||
|
.modified_fields
|
||||||
|
.as_ref()
|
||||||
|
.is_some_and(|fields| !fields.is_empty());
|
||||||
card_with_header(
|
card_with_header(
|
||||||
"",
|
"",
|
||||||
html! {
|
html! {
|
||||||
@@ -176,14 +186,23 @@ fn rule_header(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
div class="flex flex-wrap gap-2" {
|
||||||
|
@if can_reset {
|
||||||
|
form method="post"
|
||||||
|
action={(base) "/limit-config?action=reset&rule=" (rule.id)} {
|
||||||
|
(csrf_input(csrf_token))
|
||||||
|
(secondary_button("Reset to Defaults"))
|
||||||
|
}
|
||||||
|
}
|
||||||
@if can_update && rule.id != "default" {
|
@if can_update && rule.id != "default" {
|
||||||
form method="post"
|
form method="post"
|
||||||
action={(config.base_path) "/limit-config?action=delete&rule=" (rule.id)} {
|
action={(base) "/limit-config?action=delete&rule=" (rule.id)} {
|
||||||
(csrf_input(csrf_token))
|
(csrf_input(csrf_token))
|
||||||
(danger_button("Delete Rule"))
|
(danger_button("Delete Rule"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -550,6 +550,10 @@ fn traits_form(
|
|||||||
class="w-full rounded-lg border border-neutral-300 bg-white px-3 py-1.5 text-sm text-neutral-900 placeholder:text-neutral-400 focus:border-brand-primary focus:outline-none focus:ring-2 focus:ring-brand-primary/20" {
|
class="w-full rounded-lg border border-neutral-300 bg-white px-3 py-1.5 text-sm text-neutral-900 placeholder:text-neutral-400 focus:border-brand-primary focus:outline-none focus:ring-2 focus:ring-brand-primary/20" {
|
||||||
(custom_traits.join("\n"))
|
(custom_traits.join("\n"))
|
||||||
}
|
}
|
||||||
|
p class="text-neutral-500 text-xs" {
|
||||||
|
"The premium trait follows each member's subscription or gift and cannot be \
|
||||||
|
assigned here."
|
||||||
|
}
|
||||||
}
|
}
|
||||||
(form_actions(html! {
|
(form_actions(html! {
|
||||||
(submit_button("Save Traits"))
|
(submit_button("Save Traits"))
|
||||||
|
|||||||
@@ -225,6 +225,60 @@ async fn instance_config_legal_form_round_trips_the_guidelines_url() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn limit_rule_reset_form_sends_the_rule_defaults() {
|
||||||
|
let updates = Arc::new(Mutex::new(Vec::<Value>::new()));
|
||||||
|
let sink = updates.clone();
|
||||||
|
let edited = || {
|
||||||
|
let mut config = limit_config();
|
||||||
|
config["limit_config"]["rules"][0]["limits"]["maxGuilds"] = json!(150);
|
||||||
|
config["limit_config"]["rules"][0]["modifiedFields"] = json!(["maxGuilds"]);
|
||||||
|
config
|
||||||
|
};
|
||||||
|
let api = Router::new()
|
||||||
|
.route(
|
||||||
|
"/admin/limit-config",
|
||||||
|
routing::get(move || async move { json_response(edited()) }).put(
|
||||||
|
move |Json(body): Json<Value>| {
|
||||||
|
let sink = sink.clone();
|
||||||
|
async move {
|
||||||
|
sink.lock().unwrap().push(body);
|
||||||
|
json_response(limit_config())
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.fallback(mock_api);
|
||||||
|
let app = setup_with_api(api).await;
|
||||||
|
let (headers, page) = get_with_headers(&app, "/limit-config", &[]).await;
|
||||||
|
let csrf_token = csrf_cookie(&headers)
|
||||||
|
.unwrap_or_else(|| panic!("limit config page did not set csrf_token cookie\n{page}"));
|
||||||
|
assert_form_has_csrf(
|
||||||
|
&page,
|
||||||
|
"/limit-config?action=reset&rule=default",
|
||||||
|
&csrf_token,
|
||||||
|
);
|
||||||
|
let cookie = format!("{}; csrf_token={}", app.session_cookie, csrf_token);
|
||||||
|
let (status, _, body) = post_form_with_headers(
|
||||||
|
&app,
|
||||||
|
"/limit-config?action=reset&rule=default",
|
||||||
|
&[("Cookie", &cookie)],
|
||||||
|
&format!("_csrf={csrf_token}"),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert!(
|
||||||
|
status.is_redirection() || status.is_success(),
|
||||||
|
"{status} {body}"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
*updates.lock().unwrap(),
|
||||||
|
vec![json!({"limit_config": {
|
||||||
|
"traitDefinitions": [],
|
||||||
|
"rules": [{"id": "default", "limits": {"maxGuilds": 100}}]
|
||||||
|
}})]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn report_resolve_form_sends_the_chosen_resolution_and_refuses_none() {
|
async fn report_resolve_form_sends_the_chosen_resolution_and_refuses_none() {
|
||||||
let received = Arc::new(Mutex::new(Vec::<Value>::new()));
|
let received = Arc::new(Mutex::new(Vec::<Value>::new()));
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
// 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 {getInstanceConfigRepository, getLimitConfigService} 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 {AdminACLs} from '@fluxer/constants/src/AdminACLs';
|
||||||
|
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
|
||||||
|
import {MAX_BIO_LENGTH} from '@fluxer/constants/src/LimitConstants';
|
||||||
|
import type {LimitConfigGetResponse} from '@fluxer/schema/src/domains/admin/AdminSchemas';
|
||||||
|
import {afterAll, beforeAll, beforeEach, describe, expect, it} from 'vitest';
|
||||||
|
import type {z} from 'zod';
|
||||||
|
|
||||||
|
type LimitConfigResponse = z.infer<typeof LimitConfigGetResponse>;
|
||||||
|
|
||||||
|
describe('limit config allowed ranges', () => {
|
||||||
|
let harness: ApiTestHarness;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
harness = await createApiTestHarness();
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await harness.reset();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await harness.shutdown();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function createAdmin(): Promise<TestAccount> {
|
||||||
|
return await setUserACLs(harness, await createTestAccount(harness), [
|
||||||
|
AdminACLs.AUTHENTICATE,
|
||||||
|
AdminACLs.INSTANCE_LIMIT_CONFIG_VIEW,
|
||||||
|
AdminACLs.INSTANCE_LIMIT_CONFIG_UPDATE,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function vipRule(maxBioLength: number) {
|
||||||
|
return {id: 'vip', filters: {traits: ['vip']}, limits: {max_bio_length: maxBioLength}};
|
||||||
|
}
|
||||||
|
|
||||||
|
it('refuses a bio length above what a profile can hold', async () => {
|
||||||
|
const admin = await createAdmin();
|
||||||
|
await createBuilder(harness, admin.token)
|
||||||
|
.put('/admin/limit-config')
|
||||||
|
.body({limit_config: {traitDefinitions: ['vip'], rules: [vipRule(MAX_BIO_LENGTH + 1)]}})
|
||||||
|
.expect(HTTP_STATUS.BAD_REQUEST, APIErrorCodes.INVALID_FORM_BODY)
|
||||||
|
.execute();
|
||||||
|
const saved = await createBuilder<LimitConfigResponse>(harness, admin.token)
|
||||||
|
.put('/admin/limit-config')
|
||||||
|
.body({limit_config: {traitDefinitions: ['vip'], rules: [vipRule(MAX_BIO_LENGTH)]}})
|
||||||
|
.execute();
|
||||||
|
expect(saved.limit_config.rules.find((rule) => rule.id === 'vip')?.limits).toEqual({
|
||||||
|
max_bio_length: MAX_BIO_LENGTH,
|
||||||
|
});
|
||||||
|
expect(saved.metadata.max_bio_length).toMatchObject({min: 0, max: MAX_BIO_LENGTH});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reads a stored bio length above the profile maximum as the maximum', async () => {
|
||||||
|
const admin = await createAdmin();
|
||||||
|
await getInstanceConfigRepository().setLimitConfig({traitDefinitions: ['vip'], rules: [vipRule(1000)]});
|
||||||
|
await getLimitConfigService().refreshCache();
|
||||||
|
const response = await createBuilder<LimitConfigResponse>(harness, admin.token)
|
||||||
|
.get('/admin/limit-config')
|
||||||
|
.execute();
|
||||||
|
expect(response.limit_config.rules.find((rule) => rule.id === 'vip')?.limits).toEqual({
|
||||||
|
max_bio_length: MAX_BIO_LENGTH,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,289 @@
|
|||||||
|
// 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 {getCachedInstancePremiumMode, setCachedInstancePremiumMode} from '@app/api/limits/InstancePremiumModeCache';
|
||||||
|
import {getInstanceConfigRepository, getLimitConfigService} from '@app/api/middleware/ServiceSingletons';
|
||||||
|
import {type ApiTestHarness, createApiTestHarness} from '@app/api/test/ApiTestHarness';
|
||||||
|
import {createBuilder, createBuilderWithoutAuth} from '@app/api/test/TestRequestBuilder';
|
||||||
|
import {fetchUserMe, grantPremium} from '@app/api/user/tests/UserTestUtils';
|
||||||
|
import {AdminACLs} from '@fluxer/constants/src/AdminACLs';
|
||||||
|
import {LIMIT_KEYS} from '@fluxer/constants/src/LimitConfigMetadata';
|
||||||
|
import {UserPremiumTypes} from '@fluxer/constants/src/UserConstants';
|
||||||
|
import {DEFAULT_RESTRICTED_LIMITS, DEFAULT_STOCK_LIMITS} from '@fluxer/limits/src/LimitDefaults';
|
||||||
|
import {expandWireFormat} from '@fluxer/limits/src/LimitDiffer';
|
||||||
|
import {resolveLimits} from '@fluxer/limits/src/LimitResolver';
|
||||||
|
import type {LimitConfigSnapshot} from '@fluxer/limits/src/LimitTypes';
|
||||||
|
import type {LimitConfigGetResponse} from '@fluxer/schema/src/domains/admin/AdminSchemas';
|
||||||
|
import type {WellKnownFluxerResponse} from '@fluxer/schema/src/domains/instance/InstanceSchemas';
|
||||||
|
import {afterAll, beforeAll, beforeEach, describe, expect, it} from 'vitest';
|
||||||
|
import type {z} from 'zod';
|
||||||
|
|
||||||
|
type LimitConfigResponse = z.infer<typeof LimitConfigGetResponse>;
|
||||||
|
|
||||||
|
const MODE_KEYS = LIMIT_KEYS.filter((key) => DEFAULT_STOCK_LIMITS[key] !== DEFAULT_RESTRICTED_LIMITS[key]);
|
||||||
|
|
||||||
|
describe('limit config across premium model switches on a self-hosted instance', () => {
|
||||||
|
let harness: ApiTestHarness;
|
||||||
|
let originalSelfHosted: boolean;
|
||||||
|
let originalPremiumMode: ReturnType<typeof getCachedInstancePremiumMode>;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
originalSelfHosted = getConfig().instance.selfHosted;
|
||||||
|
originalPremiumMode = getCachedInstancePremiumMode();
|
||||||
|
getConfig().instance.selfHosted = true;
|
||||||
|
harness = await createApiTestHarness();
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await harness.reset();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await harness.shutdown();
|
||||||
|
getConfig().instance.selfHosted = originalSelfHosted;
|
||||||
|
setCachedInstancePremiumMode(originalPremiumMode);
|
||||||
|
});
|
||||||
|
|
||||||
|
async function createAdmin(): Promise<TestAccount> {
|
||||||
|
return await setUserACLs(harness, await createTestAccount(harness), [
|
||||||
|
AdminACLs.AUTHENTICATE,
|
||||||
|
AdminACLs.INSTANCE_CONFIG_UPDATE,
|
||||||
|
AdminACLs.INSTANCE_LIMIT_CONFIG_VIEW,
|
||||||
|
AdminACLs.INSTANCE_LIMIT_CONFIG_UPDATE,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function setPremiumMode(admin: TestAccount, premiumMode: 'mirror' | 'everyone'): Promise<void> {
|
||||||
|
await createBuilder(harness, admin.token)
|
||||||
|
.patch('/admin/instance/config')
|
||||||
|
.body({policy: {premium_mode: premiumMode}})
|
||||||
|
.execute();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getLimitConfig(admin: TestAccount): Promise<LimitConfigResponse> {
|
||||||
|
return await createBuilder<LimitConfigResponse>(harness, admin.token).get('/admin/limit-config').execute();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveRule(admin: TestAccount, ruleId: string, overrides: Record<string, number>): Promise<void> {
|
||||||
|
const {limit_config: config} = await getLimitConfig(admin);
|
||||||
|
await createBuilder(harness, admin.token)
|
||||||
|
.put('/admin/limit-config')
|
||||||
|
.body({
|
||||||
|
limit_config: {
|
||||||
|
traitDefinitions: config.traitDefinitions,
|
||||||
|
rules: config.rules.map(({modifiedFields: _modifiedFields, ...rule}) =>
|
||||||
|
rule.id === ruleId ? {...rule, limits: {...rule.limits, ...overrides}} : rule,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.execute();
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveDefaultRule(admin: TestAccount, overrides: Record<string, number>): Promise<void> {
|
||||||
|
return saveRule(admin, 'default', overrides);
|
||||||
|
}
|
||||||
|
|
||||||
|
function findRule(response: LimitConfigResponse, ruleId: string) {
|
||||||
|
const rule = response.limit_config.rules.find((candidate) => candidate.id === ruleId);
|
||||||
|
expect(rule).toBeDefined();
|
||||||
|
return rule!;
|
||||||
|
}
|
||||||
|
|
||||||
|
function defaultRule(response: LimitConfigResponse) {
|
||||||
|
return findRule(response, 'default');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function storeLegacyLimitConfig(config: LimitConfigSnapshot): Promise<void> {
|
||||||
|
await getInstanceConfigRepository().setLimitConfig(config);
|
||||||
|
await getLimitConfigService().refreshCache();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function changeTag(account: TestAccount, discriminator: string): Promise<number> {
|
||||||
|
const {response} = await createBuilder(harness, account.token)
|
||||||
|
.patch('/users/@me')
|
||||||
|
.body({discriminator, password: account.password})
|
||||||
|
.executeRaw();
|
||||||
|
return response.status;
|
||||||
|
}
|
||||||
|
|
||||||
|
it('gives everyone premium limits after a save in mirror mode and keeps edited values', async () => {
|
||||||
|
const admin = await createAdmin();
|
||||||
|
await setPremiumMode(admin, 'mirror');
|
||||||
|
await saveDefaultRule(admin, {max_bio_length: 300});
|
||||||
|
await setPremiumMode(admin, 'everyone');
|
||||||
|
const rule = defaultRule(await getLimitConfig(admin));
|
||||||
|
expect(rule.limits).toEqual({...DEFAULT_STOCK_LIMITS, max_bio_length: 300});
|
||||||
|
expect(rule.modifiedFields).toEqual(['max_bio_length']);
|
||||||
|
const discovery = await createBuilderWithoutAuth<WellKnownFluxerResponse>(harness)
|
||||||
|
.get('/.well-known/fluxer')
|
||||||
|
.execute();
|
||||||
|
expect(discovery.limits.rules.find((candidate) => candidate.id === 'default')?.overrides).toMatchObject({
|
||||||
|
feature_custom_discriminator: 1,
|
||||||
|
max_guilds: DEFAULT_STOCK_LIMITS.max_guilds,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('restores free limits after a save in everyone mode', async () => {
|
||||||
|
const admin = await createAdmin();
|
||||||
|
await setPremiumMode(admin, 'everyone');
|
||||||
|
await saveDefaultRule(admin, {});
|
||||||
|
await setPremiumMode(admin, 'mirror');
|
||||||
|
const response = await getLimitConfig(admin);
|
||||||
|
expect(defaultRule(response).limits).toEqual(DEFAULT_RESTRICTED_LIMITS);
|
||||||
|
expect(response.limit_config.rules.find((rule) => rule.id === 'premium')?.limits).toEqual(DEFAULT_STOCK_LIMITS);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stores only the edited values', async () => {
|
||||||
|
const admin = await createAdmin();
|
||||||
|
await setPremiumMode(admin, 'mirror');
|
||||||
|
await saveDefaultRule(admin, {max_bio_length: 300});
|
||||||
|
const {config: stored} = await getInstanceConfigRepository().readLimitConfigInputs();
|
||||||
|
expect(stored?.overridesOnly).toBe(true);
|
||||||
|
expect(stored?.rules).toEqual([
|
||||||
|
{id: 'premium', filters: {traits: ['premium']}, limits: {}},
|
||||||
|
{id: 'default', limits: {max_bio_length: 300}},
|
||||||
|
{id: 'very_large_guild', filters: {guildFeatures: ['VERY_LARGE_GUILD']}, limits: {}},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('repairs free limits stored in full before an earlier switch to everyone', async () => {
|
||||||
|
const admin = await createAdmin();
|
||||||
|
await setPremiumMode(admin, 'everyone');
|
||||||
|
await storeLegacyLimitConfig({
|
||||||
|
traitDefinitions: ['premium'],
|
||||||
|
rules: [
|
||||||
|
{id: 'premium', filters: {traits: ['premium']}, limits: {...DEFAULT_STOCK_LIMITS}},
|
||||||
|
{
|
||||||
|
id: 'default',
|
||||||
|
limits: {...DEFAULT_RESTRICTED_LIMITS, max_bio_length: 300},
|
||||||
|
modifiedFields: ['max_bio_length'],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const rule = defaultRule(await getLimitConfig(admin));
|
||||||
|
expect(rule.limits).toEqual({...DEFAULT_STOCK_LIMITS, max_bio_length: 300});
|
||||||
|
expect(rule.modifiedFields).toEqual(['max_bio_length']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('repairs premium limits stored in full before an earlier switch to mirror', async () => {
|
||||||
|
const admin = await createAdmin();
|
||||||
|
await setPremiumMode(admin, 'mirror');
|
||||||
|
await storeLegacyLimitConfig({
|
||||||
|
traitDefinitions: [],
|
||||||
|
rules: [{id: 'default', limits: {...DEFAULT_STOCK_LIMITS}}],
|
||||||
|
});
|
||||||
|
const response = await getLimitConfig(admin);
|
||||||
|
expect(defaultRule(response).limits).toEqual(DEFAULT_RESTRICTED_LIMITS);
|
||||||
|
expect(defaultRule(response).modifiedFields).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('repairs free limits saved again after an earlier switch to everyone', async () => {
|
||||||
|
const admin = await createAdmin();
|
||||||
|
await setPremiumMode(admin, 'everyone');
|
||||||
|
await storeLegacyLimitConfig({
|
||||||
|
traitDefinitions: [],
|
||||||
|
rules: [{id: 'default', limits: {...DEFAULT_RESTRICTED_LIMITS}, modifiedFields: MODE_KEYS}],
|
||||||
|
});
|
||||||
|
const rule = defaultRule(await getLimitConfig(admin));
|
||||||
|
expect(rule.limits).toEqual(DEFAULT_STOCK_LIMITS);
|
||||||
|
expect(rule.modifiedFields).toBeUndefined();
|
||||||
|
const member = await createTestAccount(harness);
|
||||||
|
expect(await changeTag(member, '4242')).toBe(200);
|
||||||
|
await saveDefaultRule(admin, {max_bio_length: 300});
|
||||||
|
const {config: stored} = await getInstanceConfigRepository().readLimitConfigInputs();
|
||||||
|
expect(stored?.rules.find((candidate) => candidate.id === 'default')).toEqual({
|
||||||
|
id: 'default',
|
||||||
|
limits: {max_bio_length: 300},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('repairs premium limits saved again after an earlier switch to mirror', async () => {
|
||||||
|
const admin = await createAdmin();
|
||||||
|
await setPremiumMode(admin, 'mirror');
|
||||||
|
await storeLegacyLimitConfig({
|
||||||
|
traitDefinitions: [],
|
||||||
|
rules: [{id: 'default', limits: {...DEFAULT_STOCK_LIMITS}, modifiedFields: MODE_KEYS}],
|
||||||
|
});
|
||||||
|
const response = await getLimitConfig(admin);
|
||||||
|
expect(defaultRule(response).limits).toEqual(DEFAULT_RESTRICTED_LIMITS);
|
||||||
|
expect(findRule(response, 'premium').limits).toEqual(DEFAULT_STOCK_LIMITS);
|
||||||
|
expect(response.limit_config.traitDefinitions).toEqual(['premium']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reads limits stored in full under the model that is being left', async () => {
|
||||||
|
const admin = await createAdmin();
|
||||||
|
await setPremiumMode(admin, 'mirror');
|
||||||
|
await storeLegacyLimitConfig({
|
||||||
|
traitDefinitions: ['premium'],
|
||||||
|
rules: [
|
||||||
|
{id: 'premium', filters: {traits: ['premium']}, limits: {...DEFAULT_STOCK_LIMITS}},
|
||||||
|
{id: 'default', limits: {...DEFAULT_RESTRICTED_LIMITS, max_guilds: 150}},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
await setPremiumMode(admin, 'everyone');
|
||||||
|
expect(defaultRule(await getLimitConfig(admin)).limits).toEqual({...DEFAULT_STOCK_LIMITS, max_guilds: 150});
|
||||||
|
const {config: stored} = await getInstanceConfigRepository().readLimitConfigInputs();
|
||||||
|
expect(stored?.rules).toEqual([
|
||||||
|
{id: 'premium', filters: {traits: ['premium']}, limits: {}},
|
||||||
|
{id: 'default', limits: {max_guilds: 150}},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps edits that match the other model across a save there', async () => {
|
||||||
|
const admin = await createAdmin();
|
||||||
|
await setPremiumMode(admin, 'mirror');
|
||||||
|
const edits = {feature_custom_discriminator: 1, max_guilds: DEFAULT_STOCK_LIMITS.max_guilds};
|
||||||
|
await saveDefaultRule(admin, edits);
|
||||||
|
await setPremiumMode(admin, 'everyone');
|
||||||
|
await saveDefaultRule(admin, {max_bio_length: 300});
|
||||||
|
await setPremiumMode(admin, 'mirror');
|
||||||
|
const rule = defaultRule(await getLimitConfig(admin));
|
||||||
|
expect(rule.limits).toEqual({...DEFAULT_RESTRICTED_LIMITS, ...edits, max_bio_length: 300});
|
||||||
|
expect(rule.modifiedFields).toEqual(['feature_custom_discriminator', 'max_bio_length', 'max_guilds']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps premium rule edits across a save while everyone hides the rule', async () => {
|
||||||
|
const admin = await createAdmin();
|
||||||
|
await setPremiumMode(admin, 'mirror');
|
||||||
|
const edits = {max_guilds: 999, feature_animated_avatar: 0};
|
||||||
|
await saveRule(admin, 'premium', edits);
|
||||||
|
await setPremiumMode(admin, 'everyone');
|
||||||
|
const hidden = await getLimitConfig(admin);
|
||||||
|
expect(hidden.limit_config.rules.map((rule) => rule.id)).toEqual(['default', 'very_large_guild']);
|
||||||
|
expect(hidden.limit_config.traitDefinitions).toEqual([]);
|
||||||
|
await saveDefaultRule(admin, {max_bio_length: 300});
|
||||||
|
await setPremiumMode(admin, 'mirror');
|
||||||
|
const response = await getLimitConfig(admin);
|
||||||
|
expect(findRule(response, 'premium').limits).toEqual({...DEFAULT_STOCK_LIMITS, ...edits});
|
||||||
|
expect(response.limit_config.traitDefinitions).toEqual(['premium']);
|
||||||
|
expect(defaultRule(response).limits).toEqual({...DEFAULT_RESTRICTED_LIMITS, max_bio_length: 300});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lets only premium members change their tag in mirror mode', async () => {
|
||||||
|
const admin = await createAdmin();
|
||||||
|
await setPremiumMode(admin, 'everyone');
|
||||||
|
await saveDefaultRule(admin, {});
|
||||||
|
await setPremiumMode(admin, 'mirror');
|
||||||
|
const free = await createTestAccount(harness);
|
||||||
|
const subscriber = await createTestAccount(harness);
|
||||||
|
const gifted = await createTestAccount(harness);
|
||||||
|
await grantPremium(harness, subscriber.userId, UserPremiumTypes.SUBSCRIPTION);
|
||||||
|
await grantPremium(harness, gifted.userId, UserPremiumTypes.LIFETIME);
|
||||||
|
expect(await changeTag(free, '4242')).toBe(400);
|
||||||
|
expect(await changeTag(subscriber, '4242')).toBe(200);
|
||||||
|
expect(await changeTag(gifted, '4243')).toBe(200);
|
||||||
|
const {json: me} = await fetchUserMe(harness, subscriber.token);
|
||||||
|
expect(me.traits).toContain('premium');
|
||||||
|
const discovery = await createBuilderWithoutAuth<WellKnownFluxerResponse>(harness)
|
||||||
|
.get('/.well-known/fluxer')
|
||||||
|
.execute();
|
||||||
|
const {limits} = resolveLimits(expandWireFormat(discovery.limits), {
|
||||||
|
traits: new Set(me.traits),
|
||||||
|
guildFeatures: new Set(),
|
||||||
|
});
|
||||||
|
expect(limits.feature_custom_discriminator).toBe(1);
|
||||||
|
expect(limits.max_guilds).toBe(DEFAULT_STOCK_LIMITS.max_guilds);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import {GuildFeatures} from '@fluxer/constants/src/GuildConstants';
|
import {GuildFeatures} from '@fluxer/constants/src/GuildConstants';
|
||||||
import type {LimitKey} from '@fluxer/constants/src/LimitConfigMetadata';
|
import type {LimitKey} from '@fluxer/constants/src/LimitConfigMetadata';
|
||||||
import {LIMIT_KEYS} from '@fluxer/constants/src/LimitConfigMetadata';
|
import {LIMIT_KEY_METADATA, LIMIT_KEYS} from '@fluxer/constants/src/LimitConfigMetadata';
|
||||||
import {MAX_GUILD_MEMBERS_VERY_LARGE_GUILD} from '@fluxer/constants/src/LimitConstants';
|
import {MAX_GUILD_MEMBERS_VERY_LARGE_GUILD} from '@fluxer/constants/src/LimitConstants';
|
||||||
import {DEFAULT_RESTRICTED_LIMITS, DEFAULT_STOCK_LIMITS} from '@fluxer/limits/src/LimitDefaults';
|
import {DEFAULT_RESTRICTED_LIMITS, DEFAULT_STOCK_LIMITS} from '@fluxer/limits/src/LimitDefaults';
|
||||||
import type {LimitConfigSnapshot, LimitRule} from '@fluxer/limits/src/LimitTypes';
|
import type {LimitConfigSnapshot, LimitRule} from '@fluxer/limits/src/LimitTypes';
|
||||||
@@ -20,16 +20,22 @@ export function getLegacyLimitConfigKvKey(selfHosted: boolean): string {
|
|||||||
|
|
||||||
export const LIMIT_CONFIG_REFRESH_CHANNEL = 'limit-config-refresh';
|
export const LIMIT_CONFIG_REFRESH_CHANNEL = 'limit-config-refresh';
|
||||||
|
|
||||||
|
export interface LimitBuildOptions {
|
||||||
|
selfHosted?: boolean;
|
||||||
|
premiumMode?: 'mirror' | 'everyone';
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StoredLimitConfig extends LimitConfigSnapshot {
|
||||||
|
overridesOnly?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
function cloneLimitConfigSnapshot(config: LimitConfigSnapshot): LimitConfigSnapshot {
|
function cloneLimitConfigSnapshot(config: LimitConfigSnapshot): LimitConfigSnapshot {
|
||||||
return structuredClone(config);
|
return structuredClone(config);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function sanitizeLimitConfigForInstance(
|
export function sanitizeLimitConfigForInstance(
|
||||||
config: LimitConfigSnapshot,
|
config: LimitConfigSnapshot,
|
||||||
options?: {
|
options?: LimitBuildOptions,
|
||||||
selfHosted?: boolean;
|
|
||||||
premiumMode?: 'mirror' | 'everyone';
|
|
||||||
},
|
|
||||||
): LimitConfigSnapshot {
|
): LimitConfigSnapshot {
|
||||||
const selfHosted = options?.selfHosted ?? false;
|
const selfHosted = options?.selfHosted ?? false;
|
||||||
const premiumMode = options?.premiumMode ?? 'everyone';
|
const premiumMode = options?.premiumMode ?? 'everyone';
|
||||||
@@ -51,10 +57,7 @@ export function sanitizeLimitConfigForInstance(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createDefaultLimitConfig(options?: {
|
export function createDefaultLimitConfig(options?: LimitBuildOptions): LimitConfigSnapshot {
|
||||||
selfHosted?: boolean;
|
|
||||||
premiumMode?: 'mirror' | 'everyone';
|
|
||||||
}): LimitConfigSnapshot {
|
|
||||||
const selfHosted = options?.selfHosted ?? false;
|
const selfHosted = options?.selfHosted ?? false;
|
||||||
const premiumMode = options?.premiumMode ?? 'everyone';
|
const premiumMode = options?.premiumMode ?? 'everyone';
|
||||||
const useTiers = !selfHosted || premiumMode === 'mirror';
|
const useTiers = !selfHosted || premiumMode === 'mirror';
|
||||||
@@ -94,10 +97,7 @@ export function createDefaultLimitConfig(options?: {
|
|||||||
|
|
||||||
export function mergeWithCurrentDefaults(
|
export function mergeWithCurrentDefaults(
|
||||||
stored: LimitConfigSnapshot,
|
stored: LimitConfigSnapshot,
|
||||||
options?: {
|
options?: LimitBuildOptions,
|
||||||
selfHosted?: boolean;
|
|
||||||
premiumMode?: 'mirror' | 'everyone';
|
|
||||||
},
|
|
||||||
): LimitConfigSnapshot {
|
): LimitConfigSnapshot {
|
||||||
const selfHosted = options?.selfHosted ?? false;
|
const selfHosted = options?.selfHosted ?? false;
|
||||||
const premiumMode = options?.premiumMode ?? 'everyone';
|
const premiumMode = options?.premiumMode ?? 'everyone';
|
||||||
@@ -107,7 +107,7 @@ export function mergeWithCurrentDefaults(
|
|||||||
const mergedRules: Array<LimitRule> = [];
|
const mergedRules: Array<LimitRule> = [];
|
||||||
const existingRulesMap = new Map<string, LimitRule>();
|
const existingRulesMap = new Map<string, LimitRule>();
|
||||||
for (const rule of stored.rules) {
|
for (const rule of stored.rules) {
|
||||||
existingRulesMap.set(rule.id, rule);
|
existingRulesMap.set(rule.id, {...rule, limits: clampToAllowedRange(rule.limits)});
|
||||||
}
|
}
|
||||||
for (const defaultRule of newDefaults.rules) {
|
for (const defaultRule of newDefaults.rules) {
|
||||||
const existingRule = existingRulesMap.get(defaultRule.id);
|
const existingRule = existingRulesMap.get(defaultRule.id);
|
||||||
@@ -138,11 +138,136 @@ export function mergeWithCurrentDefaults(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
traitDefinitions: stored.traitDefinitions,
|
traitDefinitions: [...new Set([...newDefaults.traitDefinitions, ...stored.traitDefinitions])],
|
||||||
rules: mergedRules,
|
rules: mergedRules,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function readStoredLimitOverrides(
|
||||||
|
stored: StoredLimitConfig,
|
||||||
|
options: Required<LimitBuildOptions>,
|
||||||
|
): StoredLimitConfig {
|
||||||
|
if (!options.selfHosted || stored.overridesOnly) {
|
||||||
|
return stored;
|
||||||
|
}
|
||||||
|
const currentDefaults = builtInRuleLimits(options);
|
||||||
|
const otherDefaults = builtInRuleLimits({
|
||||||
|
selfHosted: true,
|
||||||
|
premiumMode: options.premiumMode === 'mirror' ? 'everyone' : 'mirror',
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
overridesOnly: true,
|
||||||
|
traitDefinitions: stored.traitDefinitions,
|
||||||
|
rules: stored.rules.map((rule) => {
|
||||||
|
const current = currentDefaults.get(rule.id);
|
||||||
|
const other = otherDefaults.get(rule.id);
|
||||||
|
const savedUnder = current && !(other && wasSavedUnderOtherDefaults(rule, current, other)) ? current : other;
|
||||||
|
return {
|
||||||
|
id: rule.id,
|
||||||
|
filters: rule.filters,
|
||||||
|
limits: savedUnder ? pickModifiedLimits(rule.limits, savedUnder) : rule.limits,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildStoredLimitConfig(
|
||||||
|
submitted: LimitConfigSnapshot,
|
||||||
|
previous: StoredLimitConfig | null,
|
||||||
|
options: Required<LimitBuildOptions>,
|
||||||
|
): StoredLimitConfig {
|
||||||
|
const visible = sanitizeLimitConfigForInstance(submitted, options);
|
||||||
|
if (!options.selfHosted) {
|
||||||
|
return mergeWithCurrentDefaults(visible, options);
|
||||||
|
}
|
||||||
|
const kept = previous === null ? null : readStoredLimitOverrides(previous, options);
|
||||||
|
const keptRules = new Map((kept?.rules ?? []).map((rule) => [rule.id, rule]));
|
||||||
|
const defaults = builtInRuleLimits(options);
|
||||||
|
const rules: Array<LimitRule> = visible.rules.map((rule) => {
|
||||||
|
const ruleDefaults = defaults.get(rule.id);
|
||||||
|
const keptLimits = keptRules.get(rule.id)?.limits ?? {};
|
||||||
|
return {
|
||||||
|
id: rule.id,
|
||||||
|
filters: rule.filters,
|
||||||
|
limits: ruleDefaults
|
||||||
|
? pickLimits(rule.limits, (key, value) => value !== ruleDefaults[key] || value === keptLimits[key])
|
||||||
|
: rule.limits,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
const hidden = kept === null ? null : hiddenFromInstance(kept, options);
|
||||||
|
const visibleRuleIds = new Set(rules.map((rule) => rule.id));
|
||||||
|
return {
|
||||||
|
overridesOnly: true,
|
||||||
|
traitDefinitions: [
|
||||||
|
...(hidden?.traitDefinitions ?? []).filter((trait) => !visible.traitDefinitions.includes(trait)),
|
||||||
|
...visible.traitDefinitions,
|
||||||
|
],
|
||||||
|
rules: [...(hidden?.rules ?? []).filter((rule) => !visibleRuleIds.has(rule.id)), ...rules],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function hiddenFromInstance(config: LimitConfigSnapshot, options: Required<LimitBuildOptions>): LimitConfigSnapshot {
|
||||||
|
const visible = sanitizeLimitConfigForInstance(config, options);
|
||||||
|
return {
|
||||||
|
traitDefinitions: config.traitDefinitions.filter((trait) => !visible.traitDefinitions.includes(trait)),
|
||||||
|
rules: config.rules.filter((rule) => !visible.rules.includes(rule)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function builtInRuleLimits(options?: LimitBuildOptions): Map<string, Partial<Record<LimitKey, number>>> {
|
||||||
|
return new Map(createDefaultLimitConfig(options).rules.map((rule) => [rule.id, rule.limits]));
|
||||||
|
}
|
||||||
|
|
||||||
|
function wasSavedUnderOtherDefaults(
|
||||||
|
rule: LimitRule,
|
||||||
|
currentDefaults: Partial<Record<LimitKey, number>>,
|
||||||
|
otherDefaults: Partial<Record<LimitKey, number>>,
|
||||||
|
): boolean {
|
||||||
|
const modeKeys = LIMIT_KEYS.filter((key) => currentDefaults[key] !== otherDefaults[key]);
|
||||||
|
const edited = new Set<string>(rule.modifiedFields ?? []);
|
||||||
|
const untouchedModeKeys = modeKeys.filter((key) => !edited.has(key));
|
||||||
|
if (untouchedModeKeys.length > 0) {
|
||||||
|
return untouchedModeKeys.every((key) => rule.limits[key] === otherDefaults[key]);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
modeKeys.some((key) => rule.limits[key] === otherDefaults[key]) &&
|
||||||
|
modeKeys.every((key) => rule.limits[key] !== undefined && rule.limits[key] !== currentDefaults[key])
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function clampToAllowedRange(limits: Partial<Record<LimitKey, number>>): Partial<Record<LimitKey, number>> {
|
||||||
|
const clamped = {...limits};
|
||||||
|
for (const key of LIMIT_KEYS) {
|
||||||
|
const value = clamped[key];
|
||||||
|
const {min = 0, max} = LIMIT_KEY_METADATA[key];
|
||||||
|
if (value !== undefined && max !== undefined) {
|
||||||
|
clamped[key] = Math.min(Math.max(value, min), max);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return clamped;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickModifiedLimits(
|
||||||
|
limits: Partial<Record<LimitKey, number>>,
|
||||||
|
defaultLimits: Partial<Record<LimitKey, number>>,
|
||||||
|
): Partial<Record<LimitKey, number>> {
|
||||||
|
return pickLimits(limits, (key, value) => value !== defaultLimits[key]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickLimits(
|
||||||
|
limits: Partial<Record<LimitKey, number>>,
|
||||||
|
keep: (key: LimitKey, value: number) => boolean,
|
||||||
|
): Partial<Record<LimitKey, number>> {
|
||||||
|
const picked: Partial<Record<LimitKey, number>> = {};
|
||||||
|
for (const key of LIMIT_KEYS) {
|
||||||
|
const value = limits[key];
|
||||||
|
if (value !== undefined && keep(key, value)) {
|
||||||
|
picked[key] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return picked;
|
||||||
|
}
|
||||||
|
|
||||||
function findModifiedLimits(
|
function findModifiedLimits(
|
||||||
currentLimits: Partial<Record<LimitKey, number>>,
|
currentLimits: Partial<Record<LimitKey, number>>,
|
||||||
defaultLimits: Partial<Record<LimitKey, number>>,
|
defaultLimits: Partial<Record<LimitKey, number>>,
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import crypto from 'node:crypto';
|
import crypto from 'node:crypto';
|
||||||
import {Config} from '@app/api/Config';
|
import {Config} from '@app/api/Config';
|
||||||
import type {APIConfig, BlueskyOAuthConfig, BlueskyOAuthKeyConfig} from '@app/api/config/APIConfig';
|
import type {APIConfig, BlueskyOAuthConfig, BlueskyOAuthKeyConfig} from '@app/api/config/APIConfig';
|
||||||
|
import type {StoredLimitConfig} from '@app/api/constants/LimitConfig';
|
||||||
import {executeConditional, fetchMany, fetchOne, upsertOne} from '@app/api/database/CassandraQueryExecution';
|
import {executeConditional, fetchMany, fetchOne, upsertOne} from '@app/api/database/CassandraQueryExecution';
|
||||||
import {Db, type PreparedQuery} from '@app/api/database/CassandraTypes';
|
import {Db, type PreparedQuery} from '@app/api/database/CassandraTypes';
|
||||||
import type {InstanceConfigurationRow} from '@app/api/database/types/InstanceConfigTypes';
|
import type {InstanceConfigurationRow} from '@app/api/database/types/InstanceConfigTypes';
|
||||||
@@ -33,7 +34,7 @@ import {setCachedConfiguredLegalUrls} from '@app/api/instance/LegalUrls';
|
|||||||
import {getDefaultProductName, setCachedProductName} from '@app/api/instance/ProductName';
|
import {getDefaultProductName, setCachedProductName} from '@app/api/instance/ProductName';
|
||||||
import {normalizeSsoAllowedEmailDomains} from '@app/api/instance/SsoConfigValidation';
|
import {normalizeSsoAllowedEmailDomains} from '@app/api/instance/SsoConfigValidation';
|
||||||
import {Logger} from '@app/api/Logger';
|
import {Logger} from '@app/api/Logger';
|
||||||
import {isLimitConfigSnapshot} from '@app/api/limits/LimitConfigValidation';
|
import {isStoredLimitConfig} from '@app/api/limits/LimitConfigValidation';
|
||||||
import {
|
import {
|
||||||
getEffectiveBillingConfig,
|
getEffectiveBillingConfig,
|
||||||
isBillingActive,
|
isBillingActive,
|
||||||
@@ -52,7 +53,6 @@ import {
|
|||||||
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
|
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
|
||||||
import {ConflictError} from '@fluxer/errors/src/domains/core/ConflictError';
|
import {ConflictError} from '@fluxer/errors/src/domains/core/ConflictError';
|
||||||
import {ServiceUnavailableError} from '@fluxer/errors/src/domains/core/ServiceUnavailableError';
|
import {ServiceUnavailableError} from '@fluxer/errors/src/domains/core/ServiceUnavailableError';
|
||||||
import type {LimitConfigSnapshot} from '@fluxer/limits/src/LimitTypes';
|
|
||||||
import {
|
import {
|
||||||
InstanceConfigResponse,
|
InstanceConfigResponse,
|
||||||
InstanceConfigUpdateRequest,
|
InstanceConfigUpdateRequest,
|
||||||
@@ -198,7 +198,7 @@ export interface InstanceBillingAdminConfig {
|
|||||||
export type InstancePremiumMode = 'mirror' | 'everyone';
|
export type InstancePremiumMode = 'mirror' | 'everyone';
|
||||||
|
|
||||||
interface LimitConfigInputs {
|
interface LimitConfigInputs {
|
||||||
config: LimitConfigSnapshot | null;
|
config: StoredLimitConfig | null;
|
||||||
premiumMode: InstancePremiumMode;
|
premiumMode: InstancePremiumMode;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -382,7 +382,7 @@ const FETCH_LIMIT_CONFIG_INPUTS_QUERY = InstanceConfiguration.selectCql({
|
|||||||
where: InstanceConfiguration.where.in('key', 'keys'),
|
where: InstanceConfiguration.where.in('key', 'keys'),
|
||||||
});
|
});
|
||||||
|
|
||||||
function parseStoredLimitConfig(raw: string | null): LimitConfigSnapshot | null {
|
function parseStoredLimitConfig(raw: string | null): StoredLimitConfig | null {
|
||||||
if (raw === null) return null;
|
if (raw === null) return null;
|
||||||
let parsed: unknown;
|
let parsed: unknown;
|
||||||
try {
|
try {
|
||||||
@@ -391,7 +391,7 @@ function parseStoredLimitConfig(raw: string | null): LimitConfigSnapshot | null
|
|||||||
Logger.error({error}, 'Stored limit configuration is not valid JSON, falling back to default limits');
|
Logger.error({error}, 'Stored limit configuration is not valid JSON, falling back to default limits');
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
if (!isLimitConfigSnapshot(parsed)) {
|
if (!isStoredLimitConfig(parsed)) {
|
||||||
Logger.error('Stored limit configuration has an invalid shape, falling back to default limits');
|
Logger.error('Stored limit configuration has an invalid shape, falling back to default limits');
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -1680,7 +1680,7 @@ export class InstanceConfigRepository {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async setLimitConfig(config: LimitConfigSnapshot): Promise<void> {
|
async setLimitConfig(config: StoredLimitConfig): Promise<void> {
|
||||||
await this.setConfig(LIMIT_CONFIG_KEY, JSON.stringify(config));
|
await this.setConfig(LIMIT_CONFIG_KEY, JSON.stringify(config));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,10 +2,13 @@
|
|||||||
|
|
||||||
import {Config} from '@app/api/Config';
|
import {Config} from '@app/api/Config';
|
||||||
import {
|
import {
|
||||||
|
buildStoredLimitConfig,
|
||||||
createDefaultLimitConfig,
|
createDefaultLimitConfig,
|
||||||
getLegacyLimitConfigKvKey,
|
getLegacyLimitConfigKvKey,
|
||||||
LIMIT_CONFIG_REFRESH_CHANNEL,
|
LIMIT_CONFIG_REFRESH_CHANNEL,
|
||||||
|
type LimitBuildOptions,
|
||||||
mergeWithCurrentDefaults,
|
mergeWithCurrentDefaults,
|
||||||
|
readStoredLimitOverrides,
|
||||||
sanitizeLimitConfigForInstance,
|
sanitizeLimitConfigForInstance,
|
||||||
} from '@app/api/constants/LimitConfig';
|
} from '@app/api/constants/LimitConfig';
|
||||||
import {
|
import {
|
||||||
@@ -28,11 +31,6 @@ const MAX_PENDING_OPERATIONS = 32;
|
|||||||
|
|
||||||
let globalLimitConfigService: LimitConfigService | null = null;
|
let globalLimitConfigService: LimitConfigService | null = null;
|
||||||
|
|
||||||
interface LimitBuildOptions {
|
|
||||||
selfHosted: boolean;
|
|
||||||
premiumMode: InstancePremiumMode;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class LimitConfigService {
|
export class LimitConfigService {
|
||||||
private premiumMode: InstancePremiumMode = 'everyone';
|
private premiumMode: InstancePremiumMode = 'everyone';
|
||||||
private config: LimitConfigSnapshot = createDefaultLimitConfig({
|
private config: LimitConfigSnapshot = createDefaultLimitConfig({
|
||||||
@@ -146,40 +144,60 @@ export class LimitConfigService {
|
|||||||
this.assertRefreshActive(generation);
|
this.assertRefreshActive(generation);
|
||||||
const {config: stored, premiumMode} = await this.repository.readLimitConfigInputs();
|
const {config: stored, premiumMode} = await this.repository.readLimitConfigInputs();
|
||||||
this.assertRefreshActive(generation);
|
this.assertRefreshActive(generation);
|
||||||
const buildOptions: LimitBuildOptions = {
|
const buildOptions: Required<LimitBuildOptions> = {
|
||||||
selfHosted: Config.instance.selfHosted,
|
selfHosted: Config.instance.selfHosted,
|
||||||
premiumMode,
|
premiumMode,
|
||||||
};
|
};
|
||||||
this.config =
|
this.config =
|
||||||
stored === null
|
stored === null
|
||||||
? createDefaultLimitConfig(buildOptions)
|
? createDefaultLimitConfig(buildOptions)
|
||||||
: mergeWithCurrentDefaults(sanitizeLimitConfigForInstance(stored, buildOptions), buildOptions);
|
: mergeWithCurrentDefaults(
|
||||||
|
sanitizeLimitConfigForInstance(readStoredLimitOverrides(stored, buildOptions), buildOptions),
|
||||||
|
buildOptions,
|
||||||
|
);
|
||||||
this.premiumMode = premiumMode;
|
this.premiumMode = premiumMode;
|
||||||
setCachedInstancePremiumMode(premiumMode);
|
setCachedInstancePremiumMode(premiumMode);
|
||||||
}
|
}
|
||||||
|
|
||||||
updateConfig(config: LimitConfigSnapshot): Promise<void> {
|
updateConfig(config: LimitConfigSnapshot): Promise<void> {
|
||||||
return this.runOperation(async (generation) => {
|
return this.runOperation(async (generation) => {
|
||||||
const policy = await this.repository.readStoredInstancePolicyConfig();
|
const {config: previous, premiumMode} = await this.repository.readLimitConfigInputs();
|
||||||
this.assertRefreshActive(generation);
|
this.assertRefreshActive(generation);
|
||||||
const buildOptions: LimitBuildOptions = {
|
const stored = buildStoredLimitConfig(config, previous, {
|
||||||
selfHosted: Config.instance.selfHosted,
|
selfHosted: Config.instance.selfHosted,
|
||||||
premiumMode: policy.premium_mode,
|
premiumMode,
|
||||||
};
|
});
|
||||||
const normalized = mergeWithCurrentDefaults(sanitizeLimitConfigForInstance(config, buildOptions), buildOptions);
|
await this.repository.setLimitConfig(stored);
|
||||||
await this.repository.setLimitConfig(normalized);
|
|
||||||
await this.reloadConfig(generation);
|
await this.reloadConfig(generation);
|
||||||
Logger.info({ruleCount: normalized.rules.length}, 'Limit config updated');
|
Logger.info({ruleCount: stored.rules.length}, 'Limit config updated');
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
updatePolicyConfig(patch: Partial<InstancePolicyConfig>): Promise<void> {
|
updatePolicyConfig(patch: Partial<InstancePolicyConfig>): Promise<void> {
|
||||||
return this.runOperation(async (generation) => {
|
return this.runOperation(async (generation) => {
|
||||||
|
if (patch.premium_mode !== undefined) {
|
||||||
|
await this.detachStoredLimitsFromPremiumMode(generation, patch.premium_mode);
|
||||||
|
}
|
||||||
await this.repository.setInstancePolicyConfig(patch);
|
await this.repository.setInstancePolicyConfig(patch);
|
||||||
await this.reloadConfig(generation);
|
await this.reloadConfig(generation);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async detachStoredLimitsFromPremiumMode(
|
||||||
|
generation: number,
|
||||||
|
nextPremiumMode: InstancePremiumMode,
|
||||||
|
): Promise<void> {
|
||||||
|
const {config: stored, premiumMode} = await this.repository.readLimitConfigInputs();
|
||||||
|
this.assertRefreshActive(generation);
|
||||||
|
if (stored === null || premiumMode === nextPremiumMode) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const overrides = readStoredLimitOverrides(stored, {selfHosted: Config.instance.selfHosted, premiumMode});
|
||||||
|
if (overrides !== stored) {
|
||||||
|
await this.repository.setLimitConfig(overrides);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private async reloadConfig(generation: number): Promise<void> {
|
private async reloadConfig(generation: number): Promise<void> {
|
||||||
this.assertRefreshActive(generation);
|
this.assertRefreshActive(generation);
|
||||||
await this.cacheService.delete(getLegacyLimitConfigKvKey(Config.instance.selfHosted));
|
await this.cacheService.delete(getLegacyLimitConfigKvKey(Config.instance.selfHosted));
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
|
import type {StoredLimitConfig} from '@app/api/constants/LimitConfig';
|
||||||
import {isJsonRecord} from '@app/api/utils/JsonBoundaryUtils';
|
import {isJsonRecord} from '@app/api/utils/JsonBoundaryUtils';
|
||||||
import type {LimitConfigSnapshot, LimitRule} from '@fluxer/limits/src/LimitTypes';
|
import type {LimitRule} from '@fluxer/limits/src/LimitTypes';
|
||||||
|
|
||||||
function isStringArray(value: unknown): value is Array<string> {
|
function isStringArray(value: unknown): value is Array<string> {
|
||||||
return Array.isArray(value) && value.every((entry) => typeof entry === 'string');
|
return Array.isArray(value) && value.every((entry) => typeof entry === 'string');
|
||||||
@@ -17,10 +18,11 @@ function isLimitRuleSnapshot(value: unknown): value is LimitRule {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isLimitConfigSnapshot(value: unknown): value is LimitConfigSnapshot {
|
export function isStoredLimitConfig(value: unknown): value is StoredLimitConfig {
|
||||||
return (
|
return (
|
||||||
isJsonRecord(value) &&
|
isJsonRecord(value) &&
|
||||||
(value.version === undefined || typeof value.version === 'number') &&
|
(value.version === undefined || typeof value.version === 'number') &&
|
||||||
|
(value.overridesOnly === undefined || typeof value.overridesOnly === 'boolean') &&
|
||||||
isStringArray(value.traitDefinitions) &&
|
isStringArray(value.traitDefinitions) &&
|
||||||
Array.isArray(value.rules) &&
|
Array.isArray(value.rules) &&
|
||||||
value.rules.every(isLimitRuleSnapshot)
|
value.rules.every(isLimitRuleSnapshot)
|
||||||
|
|||||||
@@ -0,0 +1,188 @@
|
|||||||
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
|
|
||||||
|
import {
|
||||||
|
buildStoredLimitConfig,
|
||||||
|
createDefaultLimitConfig,
|
||||||
|
mergeWithCurrentDefaults,
|
||||||
|
readStoredLimitOverrides,
|
||||||
|
sanitizeLimitConfigForInstance,
|
||||||
|
} from '@app/api/constants/LimitConfig';
|
||||||
|
import {LIMIT_KEYS} from '@fluxer/constants/src/LimitConfigMetadata';
|
||||||
|
import {DEFAULT_RESTRICTED_LIMITS, DEFAULT_STOCK_LIMITS} from '@fluxer/limits/src/LimitDefaults';
|
||||||
|
import type {LimitConfigSnapshot, LimitRule} from '@fluxer/limits/src/LimitTypes';
|
||||||
|
import {describe, expect, test} from 'vitest';
|
||||||
|
|
||||||
|
describe('Limit config storage', () => {
|
||||||
|
const mirror = {selfHosted: true, premiumMode: 'mirror'} as const;
|
||||||
|
const everyone = {selfHosted: true, premiumMode: 'everyone'} as const;
|
||||||
|
const hosted = {selfHosted: false, premiumMode: 'everyone'} as const;
|
||||||
|
const modeKeys = LIMIT_KEYS.filter((key) => DEFAULT_STOCK_LIMITS[key] !== DEFAULT_RESTRICTED_LIMITS[key]);
|
||||||
|
|
||||||
|
function defaultRuleConfig(rule: Omit<LimitRule, 'id'>): LimitConfigSnapshot {
|
||||||
|
return {traitDefinitions: [], rules: [{id: 'default', ...rule}]};
|
||||||
|
}
|
||||||
|
|
||||||
|
function fullConfig(options: {selfHosted: boolean; premiumMode: 'mirror' | 'everyone'}): LimitConfigSnapshot {
|
||||||
|
const {traitDefinitions, rules} = createDefaultLimitConfig(options);
|
||||||
|
return {traitDefinitions, rules};
|
||||||
|
}
|
||||||
|
|
||||||
|
function withLimits(config: LimitConfigSnapshot, ruleId: string, limits: LimitRule['limits']): LimitConfigSnapshot {
|
||||||
|
return {
|
||||||
|
traitDefinitions: config.traitDefinitions,
|
||||||
|
rules: config.rules.map((rule) => (rule.id === ruleId ? {...rule, limits: {...rule.limits, ...limits}} : rule)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function load(stored: LimitConfigSnapshot, options: typeof mirror | typeof everyone): LimitConfigSnapshot {
|
||||||
|
const overrides = readStoredLimitOverrides(structuredClone(stored), options);
|
||||||
|
return mergeWithCurrentDefaults(sanitizeLimitConfigForInstance(overrides, options), options);
|
||||||
|
}
|
||||||
|
|
||||||
|
function limitsOf(config: LimitConfigSnapshot, ruleId: string): LimitRule['limits'] | undefined {
|
||||||
|
return config.rules.find((rule) => rule.id === ruleId)?.limits;
|
||||||
|
}
|
||||||
|
|
||||||
|
test('built-in rules keep only values that differ from the defaults', () => {
|
||||||
|
const stored = buildStoredLimitConfig(
|
||||||
|
{
|
||||||
|
traitDefinitions: ['premium', 'vip'],
|
||||||
|
rules: [
|
||||||
|
{id: 'premium', filters: {traits: ['premium']}, limits: {...DEFAULT_STOCK_LIMITS, max_guilds: 250}},
|
||||||
|
{id: 'default', limits: {...DEFAULT_RESTRICTED_LIMITS}, modifiedFields: ['max_guilds']},
|
||||||
|
{id: 'vip', filters: {traits: ['vip']}, limits: {max_guilds: 100}},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
mirror,
|
||||||
|
);
|
||||||
|
expect(stored.rules).toEqual([
|
||||||
|
{id: 'premium', filters: {traits: ['premium']}, limits: {max_guilds: 250}},
|
||||||
|
{id: 'default', limits: {}},
|
||||||
|
{id: 'vip', filters: {traits: ['vip']}, limits: {max_guilds: 100}},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('hosted instances keep storing whole rules', () => {
|
||||||
|
const submitted = withLimits(fullConfig(hosted), 'default', {max_guilds: 150});
|
||||||
|
const stored = buildStoredLimitConfig(submitted, null, hosted);
|
||||||
|
expect(stored).toEqual(mergeWithCurrentDefaults(submitted, hosted));
|
||||||
|
expect(limitsOf(stored, 'default')).toEqual({...DEFAULT_RESTRICTED_LIMITS, max_guilds: 150});
|
||||||
|
expect(readStoredLimitOverrides(stored, hosted)).toBe(stored);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a whole default rule saved under the other premium model keeps only its edits', () => {
|
||||||
|
const stored = defaultRuleConfig({
|
||||||
|
limits: {...DEFAULT_RESTRICTED_LIMITS, max_guilds: 150, max_bio_length: 300},
|
||||||
|
modifiedFields: ['max_guilds', 'max_bio_length'],
|
||||||
|
});
|
||||||
|
expect(readStoredLimitOverrides(stored, everyone).rules).toEqual([
|
||||||
|
{id: 'default', limits: {max_guilds: 150, max_bio_length: 300}},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a whole default rule saved under the current premium model keeps only its edits', () => {
|
||||||
|
const stored = defaultRuleConfig({
|
||||||
|
limits: {...DEFAULT_RESTRICTED_LIMITS, feature_custom_discriminator: 1},
|
||||||
|
modifiedFields: ['feature_custom_discriminator'],
|
||||||
|
});
|
||||||
|
expect(readStoredLimitOverrides(stored, mirror).rules).toEqual([
|
||||||
|
{id: 'default', limits: {feature_custom_discriminator: 1}},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('free limits saved again after a switch to everyone are dropped', () => {
|
||||||
|
const savedInMirror = mergeWithCurrentDefaults(fullConfig(mirror), mirror);
|
||||||
|
const shownInEveryone = mergeWithCurrentDefaults(savedInMirror, everyone);
|
||||||
|
expect(limitsOf(shownInEveryone, 'default')).toEqual(DEFAULT_RESTRICTED_LIMITS);
|
||||||
|
const savedAgain = mergeWithCurrentDefaults(shownInEveryone, everyone);
|
||||||
|
expect(savedAgain.rules.find((rule) => rule.id === 'default')?.modifiedFields).toEqual(modeKeys);
|
||||||
|
expect(limitsOf(load(savedAgain, everyone), 'default')).toEqual(DEFAULT_STOCK_LIMITS);
|
||||||
|
expect(limitsOf(load(savedAgain, mirror), 'default')).toEqual(DEFAULT_RESTRICTED_LIMITS);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('free limits saved again with an edit after a switch to everyone keep only the edit', () => {
|
||||||
|
const savedInMirror = mergeWithCurrentDefaults(
|
||||||
|
withLimits(fullConfig(mirror), 'default', {max_guilds: 150, max_bio_length: 300}),
|
||||||
|
mirror,
|
||||||
|
);
|
||||||
|
const savedAgain = mergeWithCurrentDefaults(mergeWithCurrentDefaults(savedInMirror, everyone), everyone);
|
||||||
|
expect(savedAgain.rules.find((rule) => rule.id === 'default')?.modifiedFields).toEqual(
|
||||||
|
LIMIT_KEYS.filter((key) => modeKeys.includes(key) || key === 'max_bio_length'),
|
||||||
|
);
|
||||||
|
expect(readStoredLimitOverrides(savedAgain, everyone).rules.find((rule) => rule.id === 'default')).toEqual({
|
||||||
|
id: 'default',
|
||||||
|
limits: {max_bio_length: 300, max_guilds: 150},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a default rule fixed by hand after a switch is left as saved', () => {
|
||||||
|
const stored = defaultRuleConfig({
|
||||||
|
limits: {...DEFAULT_RESTRICTED_LIMITS, feature_custom_discriminator: 1},
|
||||||
|
modifiedFields: modeKeys.filter((key) => key !== 'feature_custom_discriminator'),
|
||||||
|
});
|
||||||
|
expect(limitsOf(load(stored, everyone), 'default')).toEqual({
|
||||||
|
...DEFAULT_RESTRICTED_LIMITS,
|
||||||
|
feature_custom_discriminator: 1,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('premium limits saved again after a switch to mirror are dropped', () => {
|
||||||
|
const savedInEveryone = mergeWithCurrentDefaults(fullConfig(everyone), everyone);
|
||||||
|
const shownInMirror = mergeWithCurrentDefaults(savedInEveryone, mirror);
|
||||||
|
expect(limitsOf(shownInMirror, 'default')).toEqual(DEFAULT_STOCK_LIMITS);
|
||||||
|
const savedAgain = mergeWithCurrentDefaults(shownInMirror, mirror);
|
||||||
|
const loaded = load(savedAgain, mirror);
|
||||||
|
expect(limitsOf(loaded, 'default')).toEqual(DEFAULT_RESTRICTED_LIMITS);
|
||||||
|
expect(limitsOf(loaded, 'premium')).toEqual(DEFAULT_STOCK_LIMITS);
|
||||||
|
expect(loaded.traitDefinitions).toEqual(['premium']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('overrides saved in the current format are never reinterpreted', () => {
|
||||||
|
const raised = buildStoredLimitConfig(
|
||||||
|
withLimits(fullConfig(mirror), 'default', DEFAULT_STOCK_LIMITS),
|
||||||
|
null,
|
||||||
|
mirror,
|
||||||
|
);
|
||||||
|
expect(Object.keys(limitsOf(raised, 'default') ?? {})).toEqual(modeKeys);
|
||||||
|
expect(readStoredLimitOverrides(raised, mirror)).toBe(raised);
|
||||||
|
expect(readStoredLimitOverrides(raised, everyone)).toBe(raised);
|
||||||
|
expect(limitsOf(load(raised, mirror), 'default')).toEqual(DEFAULT_STOCK_LIMITS);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('an edit that matches the other premium model survives a save there', () => {
|
||||||
|
const edits = {feature_custom_discriminator: 1, max_guilds: DEFAULT_STOCK_LIMITS.max_guilds};
|
||||||
|
const savedInMirror = buildStoredLimitConfig(withLimits(fullConfig(mirror), 'default', edits), null, mirror);
|
||||||
|
const savedInEveryone = buildStoredLimitConfig(
|
||||||
|
withLimits(load(savedInMirror, everyone), 'default', {max_bio_length: 300}),
|
||||||
|
savedInMirror,
|
||||||
|
everyone,
|
||||||
|
);
|
||||||
|
expect(limitsOf(savedInEveryone, 'default')).toEqual({...edits, max_bio_length: 300});
|
||||||
|
expect(limitsOf(load(savedInEveryone, mirror), 'default')).toEqual({
|
||||||
|
...DEFAULT_RESTRICTED_LIMITS,
|
||||||
|
...edits,
|
||||||
|
max_bio_length: 300,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a value moved back to the default stops being stored', () => {
|
||||||
|
const savedInMirror = buildStoredLimitConfig(
|
||||||
|
withLimits(fullConfig(mirror), 'default', {max_guilds: 150}),
|
||||||
|
null,
|
||||||
|
mirror,
|
||||||
|
);
|
||||||
|
const reverted = buildStoredLimitConfig(fullConfig(mirror), savedInMirror, mirror);
|
||||||
|
expect(limitsOf(reverted, 'default')).toEqual({});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('premium rule edits survive a save while everyone hides the rule', () => {
|
||||||
|
const edits = {max_guilds: 999, feature_custom_discriminator: 0};
|
||||||
|
const savedInMirror = buildStoredLimitConfig(withLimits(fullConfig(mirror), 'premium', edits), null, mirror);
|
||||||
|
expect(limitsOf(load(savedInMirror, everyone), 'premium')).toBeUndefined();
|
||||||
|
const savedInEveryone = buildStoredLimitConfig(fullConfig(everyone), savedInMirror, everyone);
|
||||||
|
expect(savedInEveryone.traitDefinitions).toEqual(['premium']);
|
||||||
|
expect(limitsOf(savedInEveryone, 'premium')).toEqual(edits);
|
||||||
|
expect(limitsOf(load(savedInEveryone, mirror), 'premium')).toEqual({...DEFAULT_STOCK_LIMITS, ...edits});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -360,7 +360,7 @@ Each key names one limit. A key whose name begins with `feature_` is a feature g
|
|||||||
| feature_voice_entrance_sounds | Play a sound when joining voice, in user scope |
|
| feature_voice_entrance_sounds | Play a sound when joining voice, in user scope |
|
||||||
| max_attachment_file_size | Maximum size of each attachment file in bytes, in user and guild scope |
|
| max_attachment_file_size | Maximum size of each attachment file in bytes, in user and guild scope |
|
||||||
| max_attachments_per_message | Maximum number of file attachments per message, in user and guild scope |
|
| max_attachments_per_message | Maximum number of file attachments per message, in user and guild scope |
|
||||||
| max_bio_length | Maximum characters in a user biography, in user scope |
|
| max_bio_length | Maximum characters in a user biography, in user scope. At most 320 |
|
||||||
| max_bookmarks | Maximum bookmarked messages, in user scope |
|
| max_bookmarks | Maximum bookmarked messages, in user scope |
|
||||||
| max_channels_per_category | Maximum channels per category, in guild scope |
|
| max_channels_per_category | Maximum channels per category, in guild scope |
|
||||||
| max_custom_backgrounds | Maximum custom profile backgrounds, in user scope |
|
| max_custom_backgrounds | Maximum custom profile backgrounds, in user scope |
|
||||||
|
|||||||
@@ -1069,7 +1069,7 @@ Explicit `false` values and supported null clears remain valid. Branding and int
|
|||||||
|
|
||||||
## Limits
|
## Limits
|
||||||
|
|
||||||
No environment variable changes an instance limit. Use the admin dashboard's Limit Config page or the [Admin API](/admin-api/). The thread and forum limits are fixed and have no limit key. [Thread lifecycle](/http-api/threads/#thread-lifecycle), [Thread members](/http-api/thread-members/) and [Forum channel fields](/http-api/forums/#forum-channel-fields) state them. Effective limits combine saved settings, deployment defaults and premium policy. After a change is saved, every running `api` and `worker` process reloads the limits with no restart. Clients receive the [limit configuration object](/http-api/instance/#limit-configuration-object).
|
No environment variable changes an instance limit. Use the admin dashboard's Limit Config page or the [Admin API](/admin-api/). The thread and forum limits are fixed and have no limit key. [Thread lifecycle](/http-api/threads/#thread-lifecycle), [Thread members](/http-api/thread-members/) and [Forum channel fields](/http-api/forums/#forum-channel-fields) state them. Effective limits combine saved settings, deployment defaults and premium policy. Only the values you change are saved. Every other limit follows the deployment defaults and the premium model, so switching the model moves the untouched limits and keeps your edits. Reset to Defaults on a rule in the dashboard drops the edits saved for that rule. After a change is saved, every running `api` and `worker` process reloads the limits with no restart. Clients receive the [limit configuration object](/http-api/instance/#limit-configuration-object).
|
||||||
|
|
||||||
Request concurrency is separate from instance limits, and each process sets its own. All are optional.
|
Request concurrency is separate from instance limits, and each process sets its own. All are optional.
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
|
|
||||||
|
import {MAX_BIO_LENGTH} from '@fluxer/constants/src/LimitConstants';
|
||||||
|
|
||||||
export const LIMIT_KEYS = [
|
export const LIMIT_KEYS = [
|
||||||
'avatar_max_size',
|
'avatar_max_size',
|
||||||
'emoji_max_size',
|
'emoji_max_size',
|
||||||
@@ -399,6 +401,8 @@ export const LIMIT_KEY_METADATA: Record<LimitKey, LimitKeyMetadata> = {
|
|||||||
scope: 'user',
|
scope: 'user',
|
||||||
isToggle: false,
|
isToggle: false,
|
||||||
unit: 'count',
|
unit: 'count',
|
||||||
|
min: 0,
|
||||||
|
max: MAX_BIO_LENGTH,
|
||||||
},
|
},
|
||||||
max_bookmarks: {
|
max_bookmarks: {
|
||||||
key: 'max_bookmarks',
|
key: 'max_bookmarks',
|
||||||
|
|||||||
@@ -6,7 +6,8 @@ import {
|
|||||||
SystemChannelFlags,
|
SystemChannelFlags,
|
||||||
SystemChannelFlagsDescriptions,
|
SystemChannelFlagsDescriptions,
|
||||||
} from '@fluxer/constants/src/GuildConstants';
|
} from '@fluxer/constants/src/GuildConstants';
|
||||||
import {LIMIT_KEYS} from '@fluxer/constants/src/LimitConfigMetadata';
|
import {LIMIT_KEY_METADATA, LIMIT_KEYS} from '@fluxer/constants/src/LimitConfigMetadata';
|
||||||
|
import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes';
|
||||||
import {ADMIN_ACL_COUNT, AdminAclType} from '@fluxer/schema/src/domains/admin/AdminAclType';
|
import {ADMIN_ACL_COUNT, AdminAclType} from '@fluxer/schema/src/domains/admin/AdminAclType';
|
||||||
import {AdminArchiveResponseSchema} from '@fluxer/schema/src/domains/admin/AdminArchiveSchemas';
|
import {AdminArchiveResponseSchema} from '@fluxer/schema/src/domains/admin/AdminArchiveSchemas';
|
||||||
import {GuildAdminResponse} from '@fluxer/schema/src/domains/admin/AdminGuildSchemas';
|
import {GuildAdminResponse} from '@fluxer/schema/src/domains/admin/AdminGuildSchemas';
|
||||||
@@ -916,6 +917,20 @@ const LimitRuleSchema = z.object({
|
|||||||
},
|
},
|
||||||
{error: 'Invalid limit key detected'},
|
{error: 'Invalid limit key detected'},
|
||||||
)
|
)
|
||||||
|
.superRefine((limits, ctx) => {
|
||||||
|
for (const key of LIMIT_KEYS) {
|
||||||
|
const value = limits[key];
|
||||||
|
const {min = 0, max} = LIMIT_KEY_METADATA[key];
|
||||||
|
if (value !== undefined && max !== undefined && (value < min || value > max)) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: 'custom',
|
||||||
|
message: ValidationErrorCodes.VALUE_MUST_BE_INTEGER_IN_RANGE,
|
||||||
|
params: {minValue: min, maxValue: max},
|
||||||
|
path: [key],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
.describe('Per-limit key values'),
|
.describe('Per-limit key values'),
|
||||||
});
|
});
|
||||||
const LimitConfigSchema = z.object({
|
const LimitConfigSchema = z.object({
|
||||||
|
|||||||
Reference in new issue
Block a user