mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-07 19:22:14 +09:00
1640 lines
62 KiB
Rust
1640 lines
62 KiB
Rust
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
|
|
use crate::{
|
|
api::{
|
|
client::AdminApiClient,
|
|
types::{
|
|
AppBrandingConfigUpdateRequest, AppLegalConfigUpdateRequest,
|
|
AppPublicConfigUpdateRequest, AppRegistrationConfigUpdateRequest,
|
|
AppSetupConfigUpdateRequest, CAPTCHA_COST_RANGE, CAPTCHA_MAX_COUNTER_RANGE,
|
|
CaptchaConfigUpdateRequest, CreateRegistrationUrlRequest,
|
|
DomainMigrationConfigUpdateRequest, EXPERIMENT_MAX_TARGETED_USERS,
|
|
ExperimentDeliveryConfigUpdateRequest, GatewayRolloutConfigUpdateRequest,
|
|
GatewayRolloutMode, InstanceAttachmentDecayUpdateRequest,
|
|
InstanceBlueskyIntegrationUpdateRequest, InstanceBlueskyKeyIntegrationUpdateRequest,
|
|
InstanceConfigUpdateRequest, InstanceEmailIntegrationUpdateRequest,
|
|
InstanceEmailSmtpIntegrationUpdateRequest, InstanceEmailSmtpTestRequest,
|
|
InstanceGifIntegrationUpdateRequest, InstanceIntegrationsUpdateRequest,
|
|
InstanceMediaUpdateRequest, InstancePolicyUpdateRequest,
|
|
InstanceRegistrationConfigUpdateRequest, InstanceServicesUpdateRequest,
|
|
InstanceYoutubeIntegrationUpdateRequest, LimitConfigUpdateRequest, LimitRule,
|
|
LimitRuleFilters, PlutoniumPageConfigUpdateRequest, PremiumMode,
|
|
PushRelayConfigUpdateRequest, RegistrationMode, SsoConfigUpdateRequest, VoiceE2eeScope,
|
|
},
|
|
},
|
|
config::AdminConfig,
|
|
middleware::{
|
|
csrf::CsrfToken,
|
|
flash::{self, FlashData},
|
|
htmx,
|
|
},
|
|
state::AppState,
|
|
templates,
|
|
utils::forms::{MultiValueForm, clean_string},
|
|
};
|
|
use axum::{
|
|
extract::{Query, Request, State},
|
|
http::HeaderMap,
|
|
response::{Html, IntoResponse, Response},
|
|
};
|
|
use maud::Markup;
|
|
use serde::Deserialize;
|
|
|
|
#[derive(Deserialize)]
|
|
pub struct ActionQuery {
|
|
pub action: Option<String>,
|
|
pub rule: Option<String>,
|
|
}
|
|
|
|
pub fn redirect_back_with_flash(base: &str, path: &str, fd: FlashData, secure: bool) -> Response {
|
|
flash::redirect_with_flash(&format!("{base}{path}"), fd, secure)
|
|
}
|
|
|
|
pub async fn gateway_post(
|
|
State(state): State<AppState>,
|
|
auth: axum::Extension<crate::middleware::auth::AuthContext>,
|
|
Query(aq): Query<ActionQuery>,
|
|
request: Request,
|
|
) -> Response {
|
|
let config = state.config();
|
|
let base = &config.base_path;
|
|
let form = match MultiValueForm::from_request(request).await {
|
|
Some(form) => form,
|
|
None => {
|
|
return redirect_back_with_flash(
|
|
base,
|
|
"/gateway",
|
|
FlashData::error("Invalid form data"),
|
|
config.secure_cookies(),
|
|
);
|
|
}
|
|
};
|
|
let client = AdminApiClient::new(state.http_client(), config, &auth.0.session);
|
|
let flash = if aq.action.as_deref() == Some("reload_all") {
|
|
let ids = form.list_values_any(&["guild_ids[]", "guild_ids"]);
|
|
match client.reload_all_guilds(&ids).await {
|
|
Ok(_) => FlashData::success("Gateway action completed"),
|
|
Err(error) => {
|
|
tracing::warn!(%error, "admin API request failed: reload all guilds");
|
|
FlashData::error("Failed to reload gateway guilds")
|
|
}
|
|
}
|
|
} else {
|
|
FlashData::error("Unknown gateway action")
|
|
};
|
|
redirect_back_with_flash(base, "/gateway", flash, config.secure_cookies())
|
|
}
|
|
|
|
pub async fn search_index_post(
|
|
State(state): State<AppState>,
|
|
auth: axum::Extension<crate::middleware::auth::AuthContext>,
|
|
request: Request,
|
|
) -> Response {
|
|
let config = state.config();
|
|
let base = &config.base_path;
|
|
let form = match MultiValueForm::from_request(request).await {
|
|
Some(form) => form,
|
|
None => {
|
|
return redirect_back_with_flash(
|
|
base,
|
|
"/search-index",
|
|
FlashData::error("Invalid form data"),
|
|
config.secure_cookies(),
|
|
);
|
|
}
|
|
};
|
|
let client = AdminApiClient::new(state.http_client(), config, &auth.0.session);
|
|
let index_type = form.clean("index_type");
|
|
let guild_id = form.clean("guild_id");
|
|
if let Some(idx_type) = index_type {
|
|
return match client
|
|
.refresh_search_index(&idx_type, guild_id.as_deref())
|
|
.await
|
|
{
|
|
Ok(result) => {
|
|
let job_id = &result.job_id;
|
|
flash::redirect_with_flash(
|
|
&format!("{base}/search-index?job_id={job_id}"),
|
|
FlashData::success("Search index refresh started"),
|
|
config.secure_cookies(),
|
|
)
|
|
}
|
|
Err(error) => {
|
|
tracing::warn!(%error, "admin API request failed: refresh search index");
|
|
redirect_back_with_flash(
|
|
base,
|
|
"/search-index",
|
|
FlashData::error("Failed to start search index refresh"),
|
|
config.secure_cookies(),
|
|
)
|
|
}
|
|
};
|
|
}
|
|
redirect_back_with_flash(
|
|
base,
|
|
"/search-index",
|
|
FlashData::error("Index type is required"),
|
|
config.secure_cookies(),
|
|
)
|
|
}
|
|
|
|
pub async fn instance_config_post(
|
|
State(state): State<AppState>,
|
|
headers: HeaderMap,
|
|
auth: axum::Extension<crate::middleware::auth::AuthContext>,
|
|
csrf: axum::Extension<CsrfToken>,
|
|
Query(aq): Query<ActionQuery>,
|
|
request: Request,
|
|
) -> Response {
|
|
let config = state.config();
|
|
let base = &config.base_path;
|
|
let form = match MultiValueForm::from_request(request).await {
|
|
Some(form) => form,
|
|
None => {
|
|
let flash = FlashData::error("Invalid form data");
|
|
if htmx::is_htmx_request(&headers) {
|
|
return htmx::toast_response(&flash);
|
|
}
|
|
return redirect_back_with_flash(
|
|
base,
|
|
"/instance-config",
|
|
flash,
|
|
config.secure_cookies(),
|
|
);
|
|
}
|
|
};
|
|
let client = AdminApiClient::new(state.http_client(), config, &auth.0.session);
|
|
let action = aq.action.as_deref().unwrap_or("");
|
|
let flash = match action {
|
|
"update_sso" => {
|
|
let update = build_sso_update(&form);
|
|
instance_config_result(client.update_instance_config(&update).await)
|
|
}
|
|
"update_gateway_rollout" => {
|
|
let update = build_gateway_rollout_update(&form);
|
|
instance_config_result(client.update_instance_config(&update).await)
|
|
}
|
|
"update_registration" => {
|
|
let update = build_registration_update(&form);
|
|
instance_config_result(client.update_instance_config(&update).await)
|
|
}
|
|
"update_app_public" => {
|
|
let update = build_app_public_update(&form);
|
|
instance_config_result(client.update_instance_config(&update).await)
|
|
}
|
|
"update_app_legal" => {
|
|
let update = build_app_legal_update(&form);
|
|
instance_config_result(client.update_instance_config(&update).await)
|
|
}
|
|
"update_app_registration" => {
|
|
let update = build_app_registration_update(&form);
|
|
instance_config_result(client.update_instance_config(&update).await)
|
|
}
|
|
"update_policy" => {
|
|
let update = build_policy_update(&form);
|
|
let result = client.update_instance_config(&update).await;
|
|
remember_premium_branding(&state, &result);
|
|
instance_config_result(result)
|
|
}
|
|
"update_integrations" => {
|
|
let update = build_integrations_update(&form);
|
|
instance_config_result(client.update_instance_config(&update).await)
|
|
}
|
|
"update_media" => {
|
|
let update = build_media_update(&form);
|
|
instance_config_result(client.update_instance_config(&update).await)
|
|
}
|
|
"update_billing" => match super::billing_actions::build_billing_update(&form) {
|
|
Ok(update) => {
|
|
let result = client.update_instance_config(&update).await;
|
|
remember_premium_branding(&state, &result);
|
|
super::billing_actions::billing_result(result)
|
|
}
|
|
Err(message) => FlashData::error(message),
|
|
},
|
|
"update_push_relay" => {
|
|
let update = build_push_relay_update(&form);
|
|
instance_config_result(client.update_instance_config(&update).await)
|
|
}
|
|
"update_domain_migration" => match build_domain_migration_update(&form) {
|
|
Ok(update) => instance_config_result(client.update_instance_config(&update).await),
|
|
Err(message) => FlashData::error(message),
|
|
},
|
|
"update_plutonium_page" => match build_plutonium_page_update(&form) {
|
|
Ok(update) => instance_config_result(client.update_instance_config(&update).await),
|
|
Err(message) => FlashData::error(message),
|
|
},
|
|
"update_captcha" => match build_captcha_update(&form) {
|
|
Ok(update) => instance_config_result(client.update_instance_config(&update).await),
|
|
Err(message) => FlashData::error(message),
|
|
},
|
|
"update_experiment_delivery" => match build_experiment_delivery_update(&form) {
|
|
Ok(update) => instance_config_result(client.update_instance_config(&update).await),
|
|
Err(message) => FlashData::error(message),
|
|
},
|
|
"test_smtp" => match build_smtp_test_request(&form) {
|
|
Ok(request) => match client.test_instance_smtp_config(&request).await {
|
|
Ok(response) if response.ok => FlashData::success("SMTP connection verified"),
|
|
Ok(response) => FlashData::error(
|
|
response
|
|
.error
|
|
.unwrap_or_else(|| "SMTP validation failed".to_owned()),
|
|
),
|
|
Err(error) => {
|
|
tracing::warn!(%error, "admin API request failed: test SMTP config");
|
|
FlashData::error("Failed to validate SMTP configuration")
|
|
}
|
|
},
|
|
Err(message) => FlashData::error(message),
|
|
},
|
|
"disable_single_community" => {
|
|
let update = build_single_community_update(false);
|
|
instance_config_result(client.update_instance_config(&update).await)
|
|
}
|
|
"enable_single_community" => {
|
|
let update = build_single_community_update(true);
|
|
instance_config_result(client.update_instance_config(&update).await)
|
|
}
|
|
"create_registration_url" => match build_create_registration_url_request(&form) {
|
|
Ok(request) => match client.create_registration_url(&request).await {
|
|
Ok(response) => {
|
|
let flash = FlashData::success("Registration URL created");
|
|
if htmx::targets(&headers, "registration-url-list") {
|
|
return match client.get_instance_config().await {
|
|
Ok(instance_config) => render_registration_url_list_response(
|
|
config,
|
|
&csrf.0.0,
|
|
&instance_config,
|
|
&flash,
|
|
),
|
|
Err(error) => {
|
|
tracing::warn!(%error, "admin API request failed: reload registration URLs");
|
|
htmx::toast_response(&FlashData::error(
|
|
"Registration URL created, but failed to reload the list",
|
|
))
|
|
}
|
|
};
|
|
}
|
|
FlashData::success(format!("Registration URL created: {}", response.url))
|
|
}
|
|
Err(error) => {
|
|
tracing::warn!(%error, "admin API request failed: create registration URL");
|
|
FlashData::error("Failed to create registration URL")
|
|
}
|
|
},
|
|
Err(message) => FlashData::error(message),
|
|
},
|
|
"revoke_registration_url" => match form.clean("registration_url_id") {
|
|
Some(id) => match client.revoke_registration_url(&id).await {
|
|
Ok(instance_config) => {
|
|
let flash = FlashData::success("Registration URL revoked");
|
|
if htmx::targets(&headers, "registration-url-list") {
|
|
return render_registration_url_list_response(
|
|
config,
|
|
&csrf.0.0,
|
|
&instance_config,
|
|
&flash,
|
|
);
|
|
}
|
|
flash
|
|
}
|
|
Err(error) => {
|
|
tracing::warn!(%error, "admin API request failed: revoke registration URL");
|
|
FlashData::error("Failed to revoke registration URL")
|
|
}
|
|
},
|
|
None => FlashData::error("Registration URL ID is required"),
|
|
},
|
|
"approve_pending_registration" => match form.clean("user_id") {
|
|
Some(user_id) => match client.approve_pending_registration(&user_id).await {
|
|
Ok(instance_config) => {
|
|
let flash = FlashData::success("Registration approved");
|
|
if htmx::targets(&headers, "pending-registration-list") {
|
|
return render_pending_registration_list_response(
|
|
config,
|
|
&csrf.0.0,
|
|
&instance_config,
|
|
&flash,
|
|
);
|
|
}
|
|
flash
|
|
}
|
|
Err(error) => {
|
|
tracing::warn!(%error, "admin API request failed: approve pending registration");
|
|
FlashData::error("Failed to approve registration")
|
|
}
|
|
},
|
|
None => FlashData::error("User ID is required"),
|
|
},
|
|
"reject_pending_registration" => match form.clean("user_id") {
|
|
Some(user_id) => match client.reject_pending_registration(&user_id).await {
|
|
Ok(instance_config) => {
|
|
let flash = FlashData::success("Registration rejected");
|
|
if htmx::targets(&headers, "pending-registration-list") {
|
|
return render_pending_registration_list_response(
|
|
config,
|
|
&csrf.0.0,
|
|
&instance_config,
|
|
&flash,
|
|
);
|
|
}
|
|
flash
|
|
}
|
|
Err(error) => {
|
|
tracing::warn!(%error, "admin API request failed: reject pending registration");
|
|
FlashData::error("Failed to reject registration")
|
|
}
|
|
},
|
|
None => FlashData::error("User ID is required"),
|
|
},
|
|
_ => FlashData::error("Unknown instance config action"),
|
|
};
|
|
if htmx::is_htmx_request(&headers) {
|
|
return htmx::toast_response(&flash);
|
|
}
|
|
redirect_back_with_flash(base, "/instance-config", flash, config.secure_cookies())
|
|
}
|
|
|
|
fn remember_premium_branding(
|
|
state: &AppState,
|
|
result: &Result<crate::api::types::InstanceConfigResponse, crate::api::client::ApiError>,
|
|
) {
|
|
if let Ok(instance_config) = result {
|
|
state.remember_premium_branding(crate::api::types::PremiumBranding::from_instance_config(
|
|
instance_config,
|
|
));
|
|
}
|
|
}
|
|
|
|
fn render_registration_url_list_response(
|
|
config: &AdminConfig,
|
|
csrf_token: &str,
|
|
instance_config: &crate::api::types::InstanceConfigResponse,
|
|
flash: &FlashData,
|
|
) -> Response {
|
|
render_fragment_with_toast(
|
|
templates::pages::instance_config::registration_url_list(
|
|
config,
|
|
csrf_token,
|
|
&instance_config.registration.urls,
|
|
),
|
|
flash,
|
|
)
|
|
}
|
|
|
|
fn render_pending_registration_list_response(
|
|
config: &AdminConfig,
|
|
csrf_token: &str,
|
|
instance_config: &crate::api::types::InstanceConfigResponse,
|
|
flash: &FlashData,
|
|
) -> Response {
|
|
render_fragment_with_toast(
|
|
templates::pages::instance_config::pending_registration_list(
|
|
config,
|
|
csrf_token,
|
|
&instance_config.registration.pending_registrations,
|
|
),
|
|
flash,
|
|
)
|
|
}
|
|
|
|
fn render_fragment_with_toast(markup: Markup, flash: &FlashData) -> Response {
|
|
let mut response = Html(markup.into_string()).into_response();
|
|
htmx::add_toast_header(&mut response, flash);
|
|
response
|
|
}
|
|
|
|
fn instance_config_result<T, E: std::fmt::Display>(result: Result<T, E>) -> FlashData {
|
|
match result {
|
|
Ok(_) => FlashData::success("Instance config updated"),
|
|
Err(error) => {
|
|
tracing::warn!(%error, "admin API request failed: update instance config");
|
|
FlashData::error("Failed to update instance config")
|
|
}
|
|
}
|
|
}
|
|
|
|
fn build_sso_update(form: &MultiValueForm) -> InstanceConfigUpdateRequest {
|
|
let flag = |key: &str| form.bool_value(key);
|
|
let get = |key: &str| Some(form.clean(key));
|
|
let new_secret = form.clean("sso_client_secret");
|
|
let clear_secret = flag("sso_clear_client_secret");
|
|
let allowed = form.list_values_any(&["sso_allowed_domains[]", "sso_allowed_domains"]);
|
|
let client_secret = if new_secret.is_some() {
|
|
Some(new_secret)
|
|
} else if clear_secret {
|
|
Some(None)
|
|
} else {
|
|
None
|
|
};
|
|
InstanceConfigUpdateRequest {
|
|
sso: Some(SsoConfigUpdateRequest {
|
|
enabled: Some(flag("sso_enabled")),
|
|
enforced: Some(flag("sso_enforced")),
|
|
auto_provision: Some(flag("sso_auto_provision")),
|
|
display_name: get("sso_display_name"),
|
|
issuer: get("sso_issuer"),
|
|
authorization_url: get("sso_authorization_url"),
|
|
token_url: get("sso_token_url"),
|
|
userinfo_url: get("sso_userinfo_url"),
|
|
jwks_url: get("sso_jwks_url"),
|
|
client_id: get("sso_client_id"),
|
|
client_secret,
|
|
scope: get("sso_scope"),
|
|
allowed_domains: Some(allowed),
|
|
redirect_uri: None,
|
|
}),
|
|
..Default::default()
|
|
}
|
|
}
|
|
|
|
fn build_gateway_rollout_update(form: &MultiValueForm) -> InstanceConfigUpdateRequest {
|
|
let get_f64 = |key: &str| {
|
|
form.first(key)
|
|
.and_then(|value| value.trim().parse::<f64>().ok())
|
|
};
|
|
let session_rollout_mode = match form.first("gateway_rollout_session_rollout_mode") {
|
|
Some("random") => Some(GatewayRolloutMode::Random),
|
|
Some("modulo") => Some(GatewayRolloutMode::Modulo),
|
|
_ => None,
|
|
};
|
|
let voice_e2ee_scope = match form.first("gateway_rollout_voice_e2ee_scope") {
|
|
Some("platform_wide") => Some(VoiceE2eeScope::PlatformWide),
|
|
Some("guild_feature_only") => Some(VoiceE2eeScope::GuildFeatureOnly),
|
|
_ => None,
|
|
};
|
|
InstanceConfigUpdateRequest {
|
|
gateway_rollout: Some(GatewayRolloutConfigUpdateRequest {
|
|
session_rollout_percentage: get_f64("gateway_rollout_session_rollout_percentage"),
|
|
session_rollout_mode,
|
|
guild_rollout_percentage: get_f64("gateway_rollout_guild_rollout_percentage"),
|
|
rpc_request_timeout_ms: form.parse_u64("gateway_rollout_rpc_request_timeout_ms"),
|
|
max_concurrent_session_starts: form
|
|
.parse_u64("gateway_rollout_max_concurrent_session_starts"),
|
|
max_concurrent_guild_starts: form
|
|
.parse_u64("gateway_rollout_max_concurrent_guild_starts"),
|
|
voice_e2ee_scope,
|
|
}),
|
|
..Default::default()
|
|
}
|
|
}
|
|
|
|
const EXPERIMENT_ROLLOUT_BASIS_POINTS_MAX: u32 = 10_000;
|
|
const EXPERIMENT_MAX_ROLLOUT_SALT_CHARS: usize = 64;
|
|
const EXPERIMENT_MAX_SNOWFLAKE_LENGTH: usize = 20;
|
|
const EXPERIMENT_MIN_POLL_INTERVAL_SECONDS: u64 = 60;
|
|
const EXPERIMENT_MAX_POLL_INTERVAL_SECONDS: u64 = 86_400;
|
|
const EXPERIMENT_MAX_POLL_JITTER_PERCENT: u32 = 50;
|
|
|
|
fn parse_form_number<T>(
|
|
form: &MultiValueForm,
|
|
key: &str,
|
|
label: &str,
|
|
min: T,
|
|
max: T,
|
|
) -> Result<Option<T>, String>
|
|
where
|
|
T: std::str::FromStr + Ord + std::fmt::Display,
|
|
{
|
|
let Some(raw) = form.first(key) else {
|
|
return Ok(None);
|
|
};
|
|
let invalid = || format!("{label} must be a whole number between {min} and {max}");
|
|
let value = raw.trim().parse::<T>().map_err(|_| invalid())?;
|
|
if value < min || value > max {
|
|
return Err(invalid());
|
|
}
|
|
Ok(Some(value))
|
|
}
|
|
|
|
fn parse_experiment_rollout_salt(
|
|
form: &MultiValueForm,
|
|
key: &str,
|
|
) -> Result<Option<String>, String> {
|
|
let Some(raw) = form.first(key) else {
|
|
return Ok(None);
|
|
};
|
|
let salt = raw.trim();
|
|
if salt.is_empty() || salt.encode_utf16().count() > EXPERIMENT_MAX_ROLLOUT_SALT_CHARS {
|
|
return Err(format!(
|
|
"Rollout salt must be between 1 and {EXPERIMENT_MAX_ROLLOUT_SALT_CHARS} characters"
|
|
));
|
|
}
|
|
if !salt
|
|
.bytes()
|
|
.all(|byte| byte.is_ascii_graphic() || byte == b' ')
|
|
{
|
|
return Err("Rollout salt must use printable ASCII".to_owned());
|
|
}
|
|
Ok(Some(salt.to_owned()))
|
|
}
|
|
|
|
fn is_experiment_snowflake(value: &str) -> bool {
|
|
!value.is_empty()
|
|
&& value.len() <= EXPERIMENT_MAX_SNOWFLAKE_LENGTH
|
|
&& value.bytes().all(|byte| byte.is_ascii_digit())
|
|
}
|
|
|
|
fn parse_experiment_user_ids(value: &str, label: &str) -> Result<Vec<String>, String> {
|
|
let mut ids: Vec<String> = Vec::new();
|
|
for (index, candidate) in value.split([',', '\n', '\r']).enumerate() {
|
|
let candidate = candidate.trim();
|
|
if candidate.is_empty() {
|
|
continue;
|
|
}
|
|
if !is_experiment_snowflake(candidate) {
|
|
return Err(format!(
|
|
"{label} entry {} must contain 1 to 20 decimal digits",
|
|
index + 1
|
|
));
|
|
}
|
|
if ids.iter().any(|existing| existing == candidate) {
|
|
continue;
|
|
}
|
|
if ids.len() == EXPERIMENT_MAX_TARGETED_USERS {
|
|
return Err(format!(
|
|
"{label} must contain at most {EXPERIMENT_MAX_TARGETED_USERS} unique IDs"
|
|
));
|
|
}
|
|
ids.push(candidate.to_owned());
|
|
}
|
|
Ok(ids)
|
|
}
|
|
|
|
fn build_push_relay_update(form: &MultiValueForm) -> InstanceConfigUpdateRequest {
|
|
InstanceConfigUpdateRequest {
|
|
push_relay: Some(PushRelayConfigUpdateRequest {
|
|
relay_consent_accepted: Some(form.bool_value("push_relay_consent_accepted")),
|
|
}),
|
|
..Default::default()
|
|
}
|
|
}
|
|
|
|
fn build_domain_migration_update(
|
|
form: &MultiValueForm,
|
|
) -> Result<InstanceConfigUpdateRequest, String> {
|
|
Ok(InstanceConfigUpdateRequest {
|
|
domain_migration: Some(DomainMigrationConfigUpdateRequest {
|
|
enabled: Some(form.bool_value("domain_migration_enabled")),
|
|
rollout_basis_points: parse_form_number(
|
|
form,
|
|
"domain_migration_rollout_basis_points",
|
|
"Rollout basis points",
|
|
0,
|
|
EXPERIMENT_ROLLOUT_BASIS_POINTS_MAX,
|
|
)?,
|
|
rollout_salt: parse_experiment_rollout_salt(form, "domain_migration_rollout_salt")?,
|
|
included_user_ids: Some(parse_experiment_user_ids(
|
|
form.first("domain_migration_included_user_ids")
|
|
.unwrap_or_default(),
|
|
"Included user IDs",
|
|
)?),
|
|
included_guild_ids: Some(parse_experiment_user_ids(
|
|
form.first("domain_migration_included_guild_ids")
|
|
.unwrap_or_default(),
|
|
"Included guild IDs",
|
|
)?),
|
|
include_premium_users: Some(form.bool_value("domain_migration_include_premium_users")),
|
|
excluded_user_ids: Some(parse_experiment_user_ids(
|
|
form.first("domain_migration_excluded_user_ids")
|
|
.unwrap_or_default(),
|
|
"Excluded user IDs",
|
|
)?),
|
|
anonymous_rollout_basis_points: parse_form_number(
|
|
form,
|
|
"domain_migration_anonymous_rollout_basis_points",
|
|
"Anonymous rollout basis points",
|
|
0,
|
|
EXPERIMENT_ROLLOUT_BASIS_POINTS_MAX,
|
|
)?,
|
|
standalone_forwarding: Some(form.bool_value("domain_migration_standalone_forwarding")),
|
|
}),
|
|
..Default::default()
|
|
})
|
|
}
|
|
|
|
fn build_plutonium_page_update(
|
|
form: &MultiValueForm,
|
|
) -> Result<InstanceConfigUpdateRequest, String> {
|
|
Ok(InstanceConfigUpdateRequest {
|
|
plutonium_page: Some(PlutoniumPageConfigUpdateRequest {
|
|
enabled: Some(form.bool_value("plutonium_page_enabled")),
|
|
rollout_basis_points: parse_form_number(
|
|
form,
|
|
"plutonium_page_rollout_basis_points",
|
|
"Rollout basis points",
|
|
0,
|
|
EXPERIMENT_ROLLOUT_BASIS_POINTS_MAX,
|
|
)?,
|
|
rollout_salt: parse_experiment_rollout_salt(form, "plutonium_page_rollout_salt")?,
|
|
included_user_ids: Some(parse_experiment_user_ids(
|
|
form.first("plutonium_page_included_user_ids")
|
|
.unwrap_or_default(),
|
|
"Included user IDs",
|
|
)?),
|
|
included_guild_ids: Some(parse_experiment_user_ids(
|
|
form.first("plutonium_page_included_guild_ids")
|
|
.unwrap_or_default(),
|
|
"Included guild IDs",
|
|
)?),
|
|
include_premium_users: Some(form.bool_value("plutonium_page_include_premium_users")),
|
|
excluded_user_ids: Some(parse_experiment_user_ids(
|
|
form.first("plutonium_page_excluded_user_ids")
|
|
.unwrap_or_default(),
|
|
"Excluded user IDs",
|
|
)?),
|
|
}),
|
|
..Default::default()
|
|
})
|
|
}
|
|
|
|
fn build_captcha_update(form: &MultiValueForm) -> Result<InstanceConfigUpdateRequest, String> {
|
|
Ok(InstanceConfigUpdateRequest {
|
|
captcha: Some(CaptchaConfigUpdateRequest {
|
|
enabled: Some(form.bool_value("captcha_enabled")),
|
|
cost: parse_form_number(
|
|
form,
|
|
"captcha_cost",
|
|
"Cost",
|
|
*CAPTCHA_COST_RANGE.start(),
|
|
*CAPTCHA_COST_RANGE.end(),
|
|
)?,
|
|
max_counter: parse_form_number(
|
|
form,
|
|
"captcha_max_counter",
|
|
"Maximum counter",
|
|
*CAPTCHA_MAX_COUNTER_RANGE.start(),
|
|
*CAPTCHA_MAX_COUNTER_RANGE.end(),
|
|
)?,
|
|
}),
|
|
..Default::default()
|
|
})
|
|
}
|
|
|
|
fn build_experiment_delivery_update(
|
|
form: &MultiValueForm,
|
|
) -> Result<InstanceConfigUpdateRequest, String> {
|
|
Ok(InstanceConfigUpdateRequest {
|
|
experiment_delivery: Some(ExperimentDeliveryConfigUpdateRequest {
|
|
poll_interval_seconds: parse_form_number(
|
|
form,
|
|
"experiment_delivery_poll_interval_seconds",
|
|
"Poll interval",
|
|
EXPERIMENT_MIN_POLL_INTERVAL_SECONDS,
|
|
EXPERIMENT_MAX_POLL_INTERVAL_SECONDS,
|
|
)?,
|
|
poll_jitter_percent: parse_form_number(
|
|
form,
|
|
"experiment_delivery_poll_jitter_percent",
|
|
"Poll jitter",
|
|
0,
|
|
EXPERIMENT_MAX_POLL_JITTER_PERCENT,
|
|
)?,
|
|
}),
|
|
..Default::default()
|
|
})
|
|
}
|
|
|
|
fn build_registration_update(form: &MultiValueForm) -> InstanceConfigUpdateRequest {
|
|
let mode = match form.first("registration_mode") {
|
|
Some("approval") => Some(RegistrationMode::Approval),
|
|
Some("closed") => Some(RegistrationMode::Closed),
|
|
Some("open") => Some(RegistrationMode::Open),
|
|
_ => None,
|
|
};
|
|
InstanceConfigUpdateRequest {
|
|
registration: Some(InstanceRegistrationConfigUpdateRequest {
|
|
mode,
|
|
admin_registration_urls_enabled: Some(
|
|
form.bool_value("admin_registration_urls_enabled"),
|
|
),
|
|
}),
|
|
..Default::default()
|
|
}
|
|
}
|
|
|
|
fn build_app_public_update(form: &MultiValueForm) -> InstanceConfigUpdateRequest {
|
|
let optional = |key: &str| Some(form.clean(key));
|
|
InstanceConfigUpdateRequest {
|
|
app_public: Some(AppPublicConfigUpdateRequest {
|
|
branding: Some(AppBrandingConfigUpdateRequest {
|
|
product_name: form.clean("app_product_name"),
|
|
icon_url: optional("app_icon_url"),
|
|
symbol_url: optional("app_symbol_url"),
|
|
logo_url: optional("app_logo_url"),
|
|
wordmark_url: optional("app_wordmark_url"),
|
|
favicon_url: optional("app_favicon_url"),
|
|
theme_color: optional("app_theme_color"),
|
|
status_page_url: optional("app_status_page_url"),
|
|
status_page_incident_history_url: optional("app_status_page_incident_history_url"),
|
|
..Default::default()
|
|
}),
|
|
setup: Some(AppSetupConfigUpdateRequest {
|
|
configured: Some(form.bool_value("app_setup_configured")),
|
|
}),
|
|
legal: None,
|
|
registration: None,
|
|
}),
|
|
..Default::default()
|
|
}
|
|
}
|
|
|
|
fn build_app_legal_update(form: &MultiValueForm) -> InstanceConfigUpdateRequest {
|
|
let optional = |key: &str| Some(form.clean(key));
|
|
InstanceConfigUpdateRequest {
|
|
app_public: Some(AppPublicConfigUpdateRequest {
|
|
branding: None,
|
|
setup: None,
|
|
legal: Some(AppLegalConfigUpdateRequest {
|
|
terms_url: optional("app_terms_url"),
|
|
privacy_url: optional("app_privacy_url"),
|
|
}),
|
|
registration: None,
|
|
}),
|
|
..Default::default()
|
|
}
|
|
}
|
|
|
|
fn build_app_registration_update(form: &MultiValueForm) -> InstanceConfigUpdateRequest {
|
|
InstanceConfigUpdateRequest {
|
|
app_public: Some(AppPublicConfigUpdateRequest {
|
|
branding: None,
|
|
setup: None,
|
|
legal: None,
|
|
registration: Some(AppRegistrationConfigUpdateRequest {
|
|
collect_date_of_birth: Some(form.bool_value("app_collect_date_of_birth")),
|
|
}),
|
|
}),
|
|
..Default::default()
|
|
}
|
|
}
|
|
|
|
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),
|
|
_ => None,
|
|
};
|
|
let services = build_services_update(form);
|
|
InstanceConfigUpdateRequest {
|
|
policy: Some(InstancePolicyUpdateRequest {
|
|
single_community_enabled: None,
|
|
single_community_name: None,
|
|
direct_messages_disabled,
|
|
guild_create_access,
|
|
premium_mode,
|
|
services,
|
|
}),
|
|
..Default::default()
|
|
}
|
|
}
|
|
|
|
fn build_services_update(form: &MultiValueForm) -> Option<InstanceServicesUpdateRequest> {
|
|
let parse_tristate = |key: &str| match form.first(key) {
|
|
Some("inherit") => Some(None),
|
|
Some("on") => Some(Some(true)),
|
|
Some("off") => Some(Some(false)),
|
|
_ => None,
|
|
};
|
|
let gif_enabled = parse_tristate("policy_service_gif");
|
|
let youtube_enabled = parse_tristate("policy_service_youtube");
|
|
let bluesky_enabled = parse_tristate("policy_service_bluesky");
|
|
if gif_enabled.is_none() && youtube_enabled.is_none() && bluesky_enabled.is_none() {
|
|
None
|
|
} else {
|
|
Some(InstanceServicesUpdateRequest {
|
|
gif_enabled,
|
|
youtube_enabled,
|
|
bluesky_enabled,
|
|
})
|
|
}
|
|
}
|
|
|
|
fn build_integrations_update(form: &MultiValueForm) -> InstanceConfigUpdateRequest {
|
|
let clean = |key: &str| form.clean(key);
|
|
let smtp_port = form
|
|
.first("integration_smtp_port")
|
|
.and_then(|value| value.trim().parse::<u16>().ok());
|
|
let bluesky_key_id = clean("integration_bluesky_key_id");
|
|
let bluesky_private_key = clean("integration_bluesky_private_key");
|
|
let bluesky_keys = match (bluesky_key_id, bluesky_private_key) {
|
|
(Some(kid), private_key) => Some(vec![InstanceBlueskyKeyIntegrationUpdateRequest {
|
|
kid,
|
|
private_key,
|
|
}]),
|
|
_ => None,
|
|
};
|
|
InstanceConfigUpdateRequest {
|
|
integrations: Some(InstanceIntegrationsUpdateRequest {
|
|
gif: Some(InstanceGifIntegrationUpdateRequest {
|
|
klipy_api_key: clean("integration_klipy_api_key"),
|
|
}),
|
|
youtube: Some(InstanceYoutubeIntegrationUpdateRequest {
|
|
api_key: clean("integration_youtube_api_key"),
|
|
}),
|
|
email: Some(InstanceEmailIntegrationUpdateRequest {
|
|
enabled: Some(form.bool_value("integration_email_enabled")),
|
|
provider: Some("smtp".to_owned()),
|
|
from_email: clean("integration_email_from_email"),
|
|
from_name: clean("integration_email_from_name"),
|
|
smtp: Some(InstanceEmailSmtpIntegrationUpdateRequest {
|
|
host: clean("integration_smtp_host"),
|
|
port: smtp_port,
|
|
username: clean("integration_smtp_username"),
|
|
password: clean("integration_smtp_password"),
|
|
secure: Some(form.bool_value("integration_smtp_secure")),
|
|
}),
|
|
disable_new_ip_authorization: Some(
|
|
form.bool_value("integration_email_disable_new_ip_authorization"),
|
|
),
|
|
}),
|
|
bluesky: Some(InstanceBlueskyIntegrationUpdateRequest {
|
|
enabled: Some(form.bool_value("integration_bluesky_enabled")),
|
|
client_name: clean("integration_bluesky_client_name"),
|
|
client_uri: clean("integration_bluesky_client_uri"),
|
|
logo_uri: clean("integration_bluesky_logo_uri"),
|
|
tos_uri: clean("integration_bluesky_tos_uri"),
|
|
policy_uri: clean("integration_bluesky_policy_uri"),
|
|
keys: bluesky_keys,
|
|
}),
|
|
}),
|
|
..Default::default()
|
|
}
|
|
}
|
|
|
|
fn build_media_update(form: &MultiValueForm) -> InstanceConfigUpdateRequest {
|
|
let parse_f64 = |key: &str| {
|
|
form.first(key)
|
|
.and_then(|value| value.trim().parse::<f64>().ok())
|
|
};
|
|
InstanceConfigUpdateRequest {
|
|
media: Some(InstanceMediaUpdateRequest {
|
|
attachment_decay: Some(InstanceAttachmentDecayUpdateRequest {
|
|
enabled: Some(form.bool_value("media_attachment_decay_enabled")),
|
|
min_size_mb: parse_f64("media_attachment_decay_min_size_mb"),
|
|
max_size_mb: parse_f64("media_attachment_decay_max_size_mb"),
|
|
max_eligible_size_mb: parse_f64("media_attachment_decay_max_eligible_size_mb"),
|
|
min_lifetime_days: form.parse_u32("media_attachment_decay_min_lifetime_days"),
|
|
max_lifetime_days: form.parse_u32("media_attachment_decay_max_lifetime_days"),
|
|
curve: parse_f64("media_attachment_decay_curve"),
|
|
renew_threshold_days: form.parse_u32("media_attachment_decay_renew_threshold_days"),
|
|
renew_window_days: form.parse_u32("media_attachment_decay_renew_window_days"),
|
|
}),
|
|
}),
|
|
..Default::default()
|
|
}
|
|
}
|
|
|
|
fn build_smtp_test_request(form: &MultiValueForm) -> Result<InstanceEmailSmtpTestRequest, String> {
|
|
let host = form
|
|
.clean("integration_smtp_host")
|
|
.ok_or_else(|| "SMTP host is required".to_owned())?;
|
|
let port = form
|
|
.first("integration_smtp_port")
|
|
.and_then(|value| value.trim().parse::<u16>().ok())
|
|
.ok_or_else(|| "SMTP port must be between 1 and 65535".to_owned())?;
|
|
let username = form
|
|
.clean("integration_smtp_username")
|
|
.ok_or_else(|| "SMTP username is required".to_owned())?;
|
|
let password = form
|
|
.clean("integration_smtp_password")
|
|
.ok_or_else(|| "SMTP password is required for validation".to_owned())?;
|
|
Ok(InstanceEmailSmtpTestRequest {
|
|
host,
|
|
port,
|
|
username,
|
|
password,
|
|
secure: form.bool_value("integration_smtp_secure"),
|
|
})
|
|
}
|
|
|
|
fn build_single_community_update(enabled: bool) -> InstanceConfigUpdateRequest {
|
|
InstanceConfigUpdateRequest {
|
|
policy: Some(InstancePolicyUpdateRequest {
|
|
single_community_enabled: Some(enabled),
|
|
..Default::default()
|
|
}),
|
|
..Default::default()
|
|
}
|
|
}
|
|
|
|
fn build_create_registration_url_request(
|
|
form: &MultiValueForm,
|
|
) -> Result<CreateRegistrationUrlRequest, &'static str> {
|
|
let label = form.clean("registration_url_label");
|
|
if label
|
|
.as_ref()
|
|
.is_some_and(|value| value.chars().count() > 120)
|
|
{
|
|
return Err("Label must be 120 characters or fewer");
|
|
}
|
|
Ok(CreateRegistrationUrlRequest {
|
|
label,
|
|
expires_at: parse_registration_url_expires_at(form)?,
|
|
max_uses: parse_registration_url_max_uses(form)?,
|
|
approval_required: form.bool_value("registration_url_approval_required"),
|
|
})
|
|
}
|
|
|
|
fn parse_registration_url_expires_at(
|
|
form: &MultiValueForm,
|
|
) -> Result<Option<String>, &'static str> {
|
|
let Some(value) = form
|
|
.first("registration_url_expires_in_days")
|
|
.and_then(clean_string)
|
|
else {
|
|
return Ok(None);
|
|
};
|
|
let days = value
|
|
.parse::<i64>()
|
|
.map_err(|_| "Expires in days must be a positive whole number")?;
|
|
if days < 1 {
|
|
return Err("Expires in days must be a positive whole number");
|
|
}
|
|
let expires_at = time::OffsetDateTime::now_utc()
|
|
.checked_add(time::Duration::days(days))
|
|
.ok_or("Expiration is too far in the future")?;
|
|
expires_at
|
|
.format(&time::format_description::well_known::Rfc3339)
|
|
.map(Some)
|
|
.map_err(|_| "Failed to format expiration timestamp")
|
|
}
|
|
|
|
fn parse_registration_url_max_uses(form: &MultiValueForm) -> Result<Option<u64>, &'static str> {
|
|
let Some(value) = form
|
|
.first("registration_url_max_uses")
|
|
.and_then(clean_string)
|
|
else {
|
|
return Ok(None);
|
|
};
|
|
let max_uses = value
|
|
.parse::<u64>()
|
|
.map_err(|_| "Max uses must be a positive whole number")?;
|
|
if max_uses == 0 || max_uses > 1_000_000 {
|
|
return Err("Max uses must be between 1 and 1,000,000");
|
|
}
|
|
Ok(Some(max_uses))
|
|
}
|
|
|
|
fn build_limit_filters(form: &MultiValueForm) -> Option<LimitRuleFilters> {
|
|
let traits = form.list_values_any(&["traits[]", "traits"]);
|
|
let guild_features = form.list_values_any(&["guild_features[]", "guild_features"]);
|
|
if traits.is_empty() && guild_features.is_empty() {
|
|
None
|
|
} else {
|
|
Some(LimitRuleFilters {
|
|
traits,
|
|
guild_features,
|
|
})
|
|
}
|
|
}
|
|
|
|
fn update_limit_rule_values(
|
|
rule: &mut LimitRule,
|
|
form: &MultiValueForm,
|
|
limit_keys: &[String],
|
|
fallback_limits: Option<&std::collections::BTreeMap<String, u64>>,
|
|
) {
|
|
let mut limits = std::collections::BTreeMap::new();
|
|
for key in limit_keys {
|
|
if let Some(parsed) = form.parse_u64(key) {
|
|
limits.insert(key.clone(), parsed);
|
|
}
|
|
}
|
|
if limits.is_empty()
|
|
&& let Some(defaults) = fallback_limits
|
|
{
|
|
limits.extend(defaults.clone());
|
|
}
|
|
rule.limits = limits;
|
|
rule.filters = build_limit_filters(form);
|
|
}
|
|
|
|
fn limit_config_result<T, E: std::fmt::Display>(
|
|
result: Result<T, E>,
|
|
success_message: &'static str,
|
|
error_message: &'static str,
|
|
) -> FlashData {
|
|
match result {
|
|
Ok(_) => FlashData::success(success_message),
|
|
Err(error) => {
|
|
tracing::warn!(%error, "admin API request failed: update limit config");
|
|
FlashData::error(error_message)
|
|
}
|
|
}
|
|
}
|
|
|
|
pub async fn limit_config_post(
|
|
State(state): State<AppState>,
|
|
auth: axum::Extension<crate::middleware::auth::AuthContext>,
|
|
Query(aq): Query<ActionQuery>,
|
|
request: Request,
|
|
) -> Response {
|
|
let config = state.config();
|
|
let base = &config.base_path;
|
|
let form = match MultiValueForm::from_request(request).await {
|
|
Some(form) => form,
|
|
None => {
|
|
return redirect_back_with_flash(
|
|
base,
|
|
"/limit-config",
|
|
FlashData::error("Invalid form data"),
|
|
config.secure_cookies(),
|
|
);
|
|
}
|
|
};
|
|
let client = AdminApiClient::new(state.http_client(), config, &auth.0.session);
|
|
let action = aq.action.as_deref().unwrap_or("");
|
|
let secure_cookies = config.secure_cookies();
|
|
let current = match client.get_limit_config().await {
|
|
Ok(current) => current,
|
|
Err(error) => {
|
|
tracing::warn!(%error, "admin API request failed: fetch current limit configuration");
|
|
return redirect_back_with_flash(
|
|
base,
|
|
"/limit-config",
|
|
FlashData::error("Failed to fetch current limit configuration"),
|
|
secure_cookies,
|
|
);
|
|
}
|
|
};
|
|
let mut limit_config = current.limit_config;
|
|
match action {
|
|
"update" => {
|
|
let rule_id = match aq.rule.as_deref().and_then(clean_string) {
|
|
Some(rule_id) => rule_id,
|
|
None => {
|
|
return redirect_back_with_flash(
|
|
base,
|
|
"/limit-config",
|
|
FlashData::error("Rule not found"),
|
|
secure_cookies,
|
|
);
|
|
}
|
|
};
|
|
let Some(rule) = limit_config
|
|
.rules
|
|
.iter_mut()
|
|
.find(|rule| rule.id == rule_id)
|
|
else {
|
|
return redirect_back_with_flash(
|
|
base,
|
|
"/limit-config",
|
|
FlashData::error("Rule not found"),
|
|
secure_cookies,
|
|
);
|
|
};
|
|
let fallback = current
|
|
.defaults
|
|
.get(&rule_id)
|
|
.or_else(|| current.defaults.get("default"));
|
|
update_limit_rule_values(rule, &form, ¤t.limit_keys, fallback);
|
|
let request = LimitConfigUpdateRequest { limit_config };
|
|
let result = client.update_limit_config(&request).await;
|
|
let flash = limit_config_result(
|
|
result,
|
|
"Limit configuration updated",
|
|
"Failed to update limit configuration",
|
|
);
|
|
return redirect_back_with_flash(base, "/limit-config", flash, secure_cookies);
|
|
}
|
|
"delete" => {
|
|
let rule_id = match aq.rule.as_deref().and_then(clean_string) {
|
|
Some(rule_id) => rule_id,
|
|
None => {
|
|
return redirect_back_with_flash(
|
|
base,
|
|
"/limit-config",
|
|
FlashData::error("Rule not found"),
|
|
secure_cookies,
|
|
);
|
|
}
|
|
};
|
|
if rule_id == "default" {
|
|
return redirect_back_with_flash(
|
|
base,
|
|
"/limit-config",
|
|
FlashData::error("The default rule cannot be deleted"),
|
|
secure_cookies,
|
|
);
|
|
}
|
|
let old_len = limit_config.rules.len();
|
|
limit_config.rules.retain(|rule| rule.id != rule_id);
|
|
if limit_config.rules.len() == old_len {
|
|
return redirect_back_with_flash(
|
|
base,
|
|
"/limit-config",
|
|
FlashData::error("Rule not found"),
|
|
secure_cookies,
|
|
);
|
|
}
|
|
let request = LimitConfigUpdateRequest { limit_config };
|
|
let result = client.update_limit_config(&request).await;
|
|
let flash =
|
|
limit_config_result(result, "Limit rule deleted", "Failed to delete limit rule");
|
|
return redirect_back_with_flash(base, "/limit-config", flash, secure_cookies);
|
|
}
|
|
"create" => {
|
|
let rule_id = match form.clean("rule_id") {
|
|
Some(rule_id) => rule_id,
|
|
None => {
|
|
return redirect_back_with_flash(
|
|
base,
|
|
"/limit-config",
|
|
FlashData::error("Rule ID is required"),
|
|
secure_cookies,
|
|
);
|
|
}
|
|
};
|
|
if rule_id == "default" {
|
|
return redirect_back_with_flash(
|
|
base,
|
|
"/limit-config",
|
|
FlashData::error("The default rule ID is reserved"),
|
|
secure_cookies,
|
|
);
|
|
}
|
|
if limit_config.rules.iter().any(|rule| rule.id == rule_id) {
|
|
return redirect_back_with_flash(
|
|
base,
|
|
"/limit-config",
|
|
FlashData::error("Rule ID already exists"),
|
|
secure_cookies,
|
|
);
|
|
}
|
|
let limits = current.defaults.get("default").cloned().unwrap_or_default();
|
|
limit_config.rules.push(LimitRule {
|
|
id: rule_id,
|
|
filters: build_limit_filters(&form),
|
|
limits,
|
|
modified_fields: None,
|
|
});
|
|
let request = LimitConfigUpdateRequest { limit_config };
|
|
let result = client.update_limit_config(&request).await;
|
|
let flash =
|
|
limit_config_result(result, "Limit rule created", "Failed to create limit rule");
|
|
return redirect_back_with_flash(base, "/limit-config", flash, secure_cookies);
|
|
}
|
|
_ => {}
|
|
}
|
|
redirect_back_with_flash(
|
|
base,
|
|
"/limit-config",
|
|
FlashData::success("Limit config updated"),
|
|
secure_cookies,
|
|
)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn build_sso_update_keeps_repeated_allowed_domains() {
|
|
let form = MultiValueForm::parse(
|
|
b"sso_enabled=true&sso_auto_provision=on&sso_allowed_domains%5B%5D=example.com&sso_allowed_domains%5B%5D=example.org&sso_display_name= Fluxer ",
|
|
);
|
|
let request = build_sso_update(&form);
|
|
let sso = request.sso.expect("sso update");
|
|
assert_eq!(sso.enabled, Some(true));
|
|
assert_eq!(sso.enforced, Some(false));
|
|
assert_eq!(sso.auto_provision, Some(true));
|
|
assert_eq!(sso.display_name, Some(Some("Fluxer".to_owned())));
|
|
assert_eq!(
|
|
sso.allowed_domains,
|
|
Some(vec!["example.com".to_owned(), "example.org".to_owned()])
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn build_sso_update_splits_delimited_allowed_domains() {
|
|
let form = MultiValueForm::parse(
|
|
b"sso_enabled=true&sso_auto_provision=on&sso_allowed_domains=example.com%0Aexample.org%2Cexample.net",
|
|
);
|
|
let request = build_sso_update(&form);
|
|
let sso = request.sso.expect("sso update");
|
|
assert_eq!(
|
|
sso.allowed_domains,
|
|
Some(vec![
|
|
"example.com".to_owned(),
|
|
"example.org".to_owned(),
|
|
"example.net".to_owned()
|
|
])
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn build_limit_filters_accepts_repeated_and_delimited_values() {
|
|
let form = MultiValueForm::parse(
|
|
b"traits%5B%5D=staff&traits%5B%5D=partner%2Cvip&guild_features=COMMUNITY%0ANEWS",
|
|
);
|
|
let filters = build_limit_filters(&form).expect("filters");
|
|
assert_eq!(
|
|
filters.traits,
|
|
vec!["staff".to_owned(), "partner".to_owned(), "vip".to_owned()]
|
|
);
|
|
assert_eq!(
|
|
filters.guild_features,
|
|
vec!["COMMUNITY".to_owned(), "NEWS".to_owned()]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn parse_experiment_user_ids_splits_newlines_and_commas() {
|
|
assert_eq!(
|
|
parse_experiment_user_ids(" 1 ,2\n3\r\n 4 ,, 5 ", "Included user IDs")
|
|
.expect("valid IDs"),
|
|
vec![
|
|
"1".to_owned(),
|
|
"2".to_owned(),
|
|
"3".to_owned(),
|
|
"4".to_owned(),
|
|
"5".to_owned()
|
|
]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn parse_experiment_user_ids_dedupes_preserving_order() {
|
|
assert_eq!(
|
|
parse_experiment_user_ids("20,10,20,10,30", "Included user IDs").expect("valid IDs"),
|
|
vec!["20".to_owned(), "10".to_owned(), "30".to_owned()]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn parse_experiment_user_ids_rejects_non_digit_and_overlong_values() {
|
|
for value in [
|
|
"abc",
|
|
"12a",
|
|
"-1",
|
|
"1.0",
|
|
"999999999999999999999",
|
|
"<script>",
|
|
] {
|
|
assert_eq!(
|
|
parse_experiment_user_ids(&format!("123,{value}"), "Included user IDs")
|
|
.expect_err("invalid ID"),
|
|
"Included user IDs entry 2 must contain 1 to 20 decimal digits",
|
|
"{value}"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn parse_experiment_user_ids_rejects_exceeding_the_cap() {
|
|
let value = (0..EXPERIMENT_MAX_TARGETED_USERS)
|
|
.map(|index| index.to_string())
|
|
.collect::<Vec<_>>()
|
|
.join("\n");
|
|
let ids = parse_experiment_user_ids(&format!("{value}\n999"), "Included user IDs")
|
|
.expect("valid IDs at cap");
|
|
assert_eq!(ids.len(), EXPERIMENT_MAX_TARGETED_USERS);
|
|
assert_eq!(ids.last(), Some(&"999".to_owned()));
|
|
assert_eq!(
|
|
parse_experiment_user_ids(&format!("{value}\n1000"), "Included user IDs")
|
|
.expect_err("too many IDs"),
|
|
"Included user IDs must contain at most 1000 unique IDs"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn build_domain_migration_update_reads_the_rollout_fields() {
|
|
let form = MultiValueForm::parse(
|
|
b"domain_migration_enabled=true&domain_migration_rollout_basis_points=%20250%20&domain_migration_rollout_salt=%20domain-migration-v2%20&domain_migration_included_user_ids=1500000000000000001%0A1500000000000000002&domain_migration_excluded_user_ids=1500000000000000003%2C%201500000000000000004&domain_migration_anonymous_rollout_basis_points=%20100%20&domain_migration_standalone_forwarding=true&domain_migration_included_guild_ids=1500000000000000005%0A1500000000000000006%2C1500000000000000005&domain_migration_include_premium_users=true",
|
|
);
|
|
let update = build_domain_migration_update(&form)
|
|
.expect("valid form")
|
|
.domain_migration
|
|
.expect("domain migration update");
|
|
assert_eq!(update.enabled, Some(true));
|
|
assert_eq!(update.rollout_basis_points, Some(250));
|
|
assert_eq!(update.rollout_salt, Some("domain-migration-v2".to_owned()));
|
|
assert_eq!(
|
|
update.included_user_ids,
|
|
Some(vec![
|
|
"1500000000000000001".to_owned(),
|
|
"1500000000000000002".to_owned()
|
|
])
|
|
);
|
|
assert_eq!(
|
|
update.excluded_user_ids,
|
|
Some(vec![
|
|
"1500000000000000003".to_owned(),
|
|
"1500000000000000004".to_owned()
|
|
])
|
|
);
|
|
assert_eq!(update.anonymous_rollout_basis_points, Some(100));
|
|
assert_eq!(update.standalone_forwarding, Some(true));
|
|
assert_eq!(update.include_premium_users, Some(true));
|
|
assert_eq!(
|
|
update.included_guild_ids,
|
|
Some(vec![
|
|
"1500000000000000005".to_owned(),
|
|
"1500000000000000006".to_owned()
|
|
])
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn build_domain_migration_update_leaves_the_feature_inert_when_nothing_is_submitted() {
|
|
let form = MultiValueForm::parse(b"_csrf=token");
|
|
let request = build_domain_migration_update(&form).expect("valid form");
|
|
assert_eq!(
|
|
serde_json::to_value(request).expect("serializable update"),
|
|
serde_json::json!({"domain_migration": {
|
|
"enabled": false,
|
|
"included_user_ids": [],
|
|
"included_guild_ids": [],
|
|
"include_premium_users": false,
|
|
"excluded_user_ids": [],
|
|
"standalone_forwarding": false,
|
|
}})
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn build_domain_migration_update_rejects_invalid_rollout_fields() {
|
|
for (form, message) in [
|
|
(
|
|
"domain_migration_rollout_basis_points=10001",
|
|
"Rollout basis points must be a whole number between 0 and 10000",
|
|
),
|
|
(
|
|
"domain_migration_anonymous_rollout_basis_points=10001",
|
|
"Anonymous rollout basis points must be a whole number between 0 and 10000",
|
|
),
|
|
(
|
|
"domain_migration_anonymous_rollout_basis_points=abc",
|
|
"Anonymous rollout basis points must be a whole number between 0 and 10000",
|
|
),
|
|
(
|
|
"domain_migration_rollout_salt=%20%20",
|
|
"Rollout salt must be between 1 and 64 characters",
|
|
),
|
|
(
|
|
format!("domain_migration_rollout_salt={}", "x".repeat(65)).as_str(),
|
|
"Rollout salt must be between 1 and 64 characters",
|
|
),
|
|
(
|
|
"domain_migration_rollout_salt=caf%C3%A9",
|
|
"Rollout salt must use printable ASCII",
|
|
),
|
|
(
|
|
"domain_migration_included_user_ids=123%2Cinvalid",
|
|
"Included user IDs entry 2 must contain 1 to 20 decimal digits",
|
|
),
|
|
(
|
|
"domain_migration_excluded_user_ids=123%2Cinvalid",
|
|
"Excluded user IDs entry 2 must contain 1 to 20 decimal digits",
|
|
),
|
|
] {
|
|
let form = MultiValueForm::parse(form.as_bytes());
|
|
assert_eq!(
|
|
build_domain_migration_update(&form).expect_err("invalid rollout field"),
|
|
message
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn build_push_relay_update_reads_the_consent_checkbox() {
|
|
let unchecked = build_push_relay_update(&MultiValueForm::parse(b"_csrf=token"));
|
|
assert_eq!(
|
|
serde_json::to_value(&unchecked).expect("serialize update"),
|
|
serde_json::json!({"push_relay": {"relay_consent_accepted": false}})
|
|
);
|
|
|
|
let checked = build_push_relay_update(&MultiValueForm::parse(
|
|
b"_csrf=token&push_relay_consent_accepted=true",
|
|
));
|
|
assert_eq!(
|
|
serde_json::to_value(&checked).expect("serialize update"),
|
|
serde_json::json!({"push_relay": {"relay_consent_accepted": true}})
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn build_captcha_update_reads_the_switch_and_difficulty_fields() {
|
|
let form = MultiValueForm::parse(
|
|
b"captcha_enabled=true&captcha_cost=%202000%20&captcha_max_counter=400",
|
|
);
|
|
let update = build_captcha_update(&form)
|
|
.expect("valid form")
|
|
.captcha
|
|
.expect("captcha update");
|
|
assert_eq!(update.enabled, Some(true));
|
|
assert_eq!(update.cost, Some(2000));
|
|
assert_eq!(update.max_counter, Some(400));
|
|
}
|
|
|
|
#[test]
|
|
fn build_captcha_update_turns_the_check_off_when_the_box_is_unchecked() {
|
|
let form = MultiValueForm::parse(b"_csrf=token");
|
|
let request = build_captcha_update(&form).expect("valid form");
|
|
assert_eq!(
|
|
serde_json::to_value(request).expect("serializable update"),
|
|
serde_json::json!({"captcha": {"enabled": false}})
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn build_captcha_update_rejects_difficulty_outside_the_supported_range() {
|
|
for (form, message) in [
|
|
(
|
|
"captcha_cost=999",
|
|
"Cost must be a whole number between 1000 and 20000",
|
|
),
|
|
(
|
|
"captcha_cost=20001",
|
|
"Cost must be a whole number between 1000 and 20000",
|
|
),
|
|
(
|
|
"captcha_max_counter=99",
|
|
"Maximum counter must be a whole number between 100 and 20000",
|
|
),
|
|
(
|
|
"captcha_max_counter=20001",
|
|
"Maximum counter must be a whole number between 100 and 20000",
|
|
),
|
|
] {
|
|
let form = MultiValueForm::parse(form.as_bytes());
|
|
assert_eq!(
|
|
build_captcha_update(&form).expect_err("invalid field"),
|
|
message
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn domain_migration_update_rejects_an_invalid_included_guild_id() {
|
|
let form = MultiValueForm::parse(
|
|
b"domain_migration_included_guild_ids=1500000000000000005%0Anot-a-guild",
|
|
);
|
|
assert_eq!(
|
|
build_domain_migration_update(&form).expect_err("invalid guild id"),
|
|
"Included guild IDs entry 2 must contain 1 to 20 decimal digits"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn build_plutonium_page_update_reads_the_rollout_fields() {
|
|
let form = MultiValueForm::parse(
|
|
b"plutonium_page_enabled=true&plutonium_page_rollout_basis_points=%20500%20&plutonium_page_rollout_salt=%20plutonium-page-v2%20&plutonium_page_included_user_ids=1500000000000000001&plutonium_page_excluded_user_ids=1500000000000000002&plutonium_page_included_guild_ids=1500000000000000005%0A1500000000000000006%2C1500000000000000005&plutonium_page_include_premium_users=true",
|
|
);
|
|
let update = build_plutonium_page_update(&form)
|
|
.expect("valid form")
|
|
.plutonium_page
|
|
.expect("plutonium page update");
|
|
assert_eq!(update.enabled, Some(true));
|
|
assert_eq!(update.rollout_basis_points, Some(500));
|
|
assert_eq!(update.rollout_salt, Some("plutonium-page-v2".to_owned()));
|
|
assert_eq!(update.include_premium_users, Some(true));
|
|
assert_eq!(
|
|
update.included_guild_ids,
|
|
Some(vec![
|
|
"1500000000000000005".to_owned(),
|
|
"1500000000000000006".to_owned()
|
|
])
|
|
);
|
|
assert_eq!(
|
|
update.included_user_ids,
|
|
Some(vec!["1500000000000000001".to_owned()])
|
|
);
|
|
assert_eq!(
|
|
update.excluded_user_ids,
|
|
Some(vec!["1500000000000000002".to_owned()])
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn build_plutonium_page_update_leaves_the_feature_inert_when_nothing_is_submitted() {
|
|
let form = MultiValueForm::parse(b"_csrf=token");
|
|
let request = build_plutonium_page_update(&form).expect("valid form");
|
|
assert_eq!(
|
|
serde_json::to_value(request).expect("serializable update"),
|
|
serde_json::json!({"plutonium_page": {
|
|
"enabled": false,
|
|
"included_user_ids": [],
|
|
"included_guild_ids": [],
|
|
"include_premium_users": false,
|
|
"excluded_user_ids": [],
|
|
}})
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn build_plutonium_page_update_rejects_invalid_rollout_fields() {
|
|
for (form, message) in [
|
|
(
|
|
"plutonium_page_rollout_basis_points=10001",
|
|
"Rollout basis points must be a whole number between 0 and 10000",
|
|
),
|
|
(
|
|
"plutonium_page_included_guild_ids=1500000000000000005%0Anot-a-guild",
|
|
"Included guild IDs entry 2 must contain 1 to 20 decimal digits",
|
|
),
|
|
] {
|
|
let form = MultiValueForm::parse(form.as_bytes());
|
|
assert_eq!(
|
|
build_plutonium_page_update(&form).expect_err("invalid field"),
|
|
message
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn build_experiment_delivery_update_leaves_both_fields_unchanged_when_absent() {
|
|
let form = MultiValueForm::parse(b"_csrf=token");
|
|
let request = build_experiment_delivery_update(&form).expect("valid form");
|
|
assert_eq!(
|
|
serde_json::to_value(request).expect("serializable update"),
|
|
serde_json::json!({"experiment_delivery": {}})
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn build_experiment_delivery_update_rejects_invalid_numbers() {
|
|
for (key, message, below_min, above_max) in [
|
|
(
|
|
"experiment_delivery_poll_interval_seconds",
|
|
"Poll interval must be a whole number between 60 and 86400",
|
|
"59",
|
|
"86401",
|
|
),
|
|
(
|
|
"experiment_delivery_poll_jitter_percent",
|
|
"Poll jitter must be a whole number between 0 and 50",
|
|
"-1",
|
|
"51",
|
|
),
|
|
] {
|
|
for value in [
|
|
"",
|
|
"%20%20",
|
|
"abc",
|
|
"-1",
|
|
"1.5",
|
|
"9999999999999999999999999",
|
|
below_min,
|
|
above_max,
|
|
] {
|
|
let form = MultiValueForm::parse(format!("{key}={value}").as_bytes());
|
|
assert_eq!(
|
|
build_experiment_delivery_update(&form).expect_err("invalid number"),
|
|
message,
|
|
"{key}={value}"
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn build_experiment_delivery_update_accepts_inclusive_bounds() {
|
|
for (interval, jitter) in [(60, 0), (86_400, 50)] {
|
|
let form = MultiValueForm::parse(format!("experiment_delivery_poll_interval_seconds={interval}&experiment_delivery_poll_jitter_percent={jitter}").as_bytes());
|
|
let request = build_experiment_delivery_update(&form).expect("valid form");
|
|
assert_eq!(
|
|
serde_json::to_value(request).expect("serializable update"),
|
|
serde_json::json!({"experiment_delivery": {"poll_interval_seconds": interval, "poll_jitter_percent": jitter}})
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn build_experiment_delivery_update_accepts_padded_numbers() {
|
|
let form = MultiValueForm::parse(
|
|
b"experiment_delivery_poll_interval_seconds=%20900%20&experiment_delivery_poll_jitter_percent=%2025%20",
|
|
);
|
|
let update = build_experiment_delivery_update(&form)
|
|
.expect("valid form")
|
|
.experiment_delivery
|
|
.expect("experiment delivery update");
|
|
assert_eq!(update.poll_interval_seconds, Some(900));
|
|
assert_eq!(update.poll_jitter_percent, Some(25));
|
|
}
|
|
|
|
#[test]
|
|
fn update_limit_rule_values_reads_checked_limit_keys() {
|
|
let form = MultiValueForm::parse(b"message_send=1&traits%5B%5D=trial");
|
|
let mut rule = LimitRule {
|
|
id: "trial".to_owned(),
|
|
filters: None,
|
|
limits: std::collections::BTreeMap::new(),
|
|
modified_fields: None,
|
|
};
|
|
update_limit_rule_values(&mut rule, &form, &["message_send".to_owned()], None);
|
|
assert_eq!(rule.limits.get("message_send"), Some(&1));
|
|
assert_eq!(
|
|
rule.filters.expect("filters").traits,
|
|
vec!["trial".to_owned()]
|
|
);
|
|
}
|
|
}
|