mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-11 21:18:29 +09:00
Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
44bc29b101 | ||
|
|
b07dcc368d | ||
|
|
0967bf3136 | ||
|
|
edc913096a | ||
|
|
cca8227ca6 | ||
|
|
45b78a13c2 | ||
|
|
6acd7f3d22 | ||
|
|
7d2c51a57f |
No files matched your search
Generated
+10
@@ -1803,6 +1803,7 @@ dependencies = [
|
||||
"http-body-util",
|
||||
"hyper",
|
||||
"libc",
|
||||
"memmap2",
|
||||
"moka",
|
||||
"parking_lot",
|
||||
"percent-encoding",
|
||||
@@ -2892,6 +2893,15 @@ version = "2.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
|
||||
|
||||
[[package]]
|
||||
name = "memmap2"
|
||||
version = "0.9.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d1219ed1b7f229ee7104d281dd01d6802fe28bb6e95d292942c4daacdeb798c0"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mime"
|
||||
version = "0.3.17"
|
||||
|
||||
Vendored
+1
-1
@@ -104,7 +104,7 @@ FLUXER_EMAIL_SMTP_HOST=mailpit
|
||||
FLUXER_EMAIL_SMTP_PORT=1025
|
||||
FLUXER_EMAIL_SMTP_USERNAME=dev
|
||||
FLUXER_EMAIL_SMTP_PASSWORD=dev
|
||||
FLUXER_EMAIL_SMTP_SECURE=false
|
||||
FLUXER_EMAIL_SMTP_TLS_MODE=none
|
||||
FLUXER_SEARCH_ENGINE=meilisearch
|
||||
FLUXER_SEARCH_URL=http://meilisearch:7700
|
||||
FLUXER_SEARCH_API_KEY=fluxer-dev-meilisearch
|
||||
|
||||
@@ -318,7 +318,13 @@ FLUXER_EMAIL_SMTP_HOST=
|
||||
FLUXER_EMAIL_SMTP_PORT=587
|
||||
FLUXER_EMAIL_SMTP_USERNAME=
|
||||
FLUXER_EMAIL_SMTP_PASSWORD=
|
||||
FLUXER_EMAIL_SMTP_SECURE=true
|
||||
# How the SMTP connection is encrypted: implicit, starttls, opportunistic or
|
||||
# none. Leave it empty for implicit TLS on port 465 and required STARTTLS on any
|
||||
# other port.
|
||||
FLUXER_EMAIL_SMTP_TLS_MODE=
|
||||
# The older switch, read only while the mode above is empty. true is implicit
|
||||
# and false is opportunistic.
|
||||
#FLUXER_EMAIL_SMTP_SECURE=false
|
||||
#FLUXER_EMAIL_WEBHOOK_SECRET=
|
||||
|
||||
FLUXER_DISCOVERY_ENABLED=true
|
||||
@@ -630,7 +636,8 @@ FLUXER_DISCOVERY_ENABLED=true
|
||||
#FLUXER_MEDIA_PROXY_UPLOAD_RELAY_SPOOL_CHUNK_BYTES=1048576
|
||||
#FLUXER_MEDIA_PROXY_UPLOAD_RELAY_SPOOL_MAX_TOTAL_BYTES=8589934592
|
||||
|
||||
# app-proxy index upstream and manifest scope.
|
||||
# app-proxy discovery refresh, index upstream and manifest scope.
|
||||
#DISCOVERY_REFRESH_INTERVAL_MS=60000
|
||||
#FLUXER_APP_PROXY_INDEX_UPSTREAM_URL=
|
||||
#FLUXER_APP_PROXY_SAME_ORIGIN_HOSTS=
|
||||
#FLUXER_APP_PROXY_MANIFEST_SCOPE_EXTENSIONS=
|
||||
@@ -121,6 +121,7 @@ x-fluxer-env: &fluxer-env
|
||||
FLUXER_EMAIL_SMTP_PORT: ${FLUXER_EMAIL_SMTP_PORT:-}
|
||||
FLUXER_EMAIL_SMTP_USERNAME: ${FLUXER_EMAIL_SMTP_USERNAME:-}
|
||||
FLUXER_EMAIL_SMTP_PASSWORD: ${FLUXER_EMAIL_SMTP_PASSWORD:-}
|
||||
FLUXER_EMAIL_SMTP_TLS_MODE: ${FLUXER_EMAIL_SMTP_TLS_MODE:-}
|
||||
FLUXER_EMAIL_SMTP_SECURE: ${FLUXER_EMAIL_SMTP_SECURE:-}
|
||||
|
||||
FLUXER_STRIPE_ENABLED: ${FLUXER_STRIPE_ENABLED:-}
|
||||
@@ -660,6 +661,13 @@ services:
|
||||
FLUXER_S3_BUCKET_UPLOADS: ${FLUXER_S3_BUCKET_UPLOADS:-}
|
||||
FLUXER_STATIC_CDN_ENDPOINT: ${FLUXER_STATIC_CDN_ENDPOINT:-}
|
||||
FLUXER_MEDIA_ENDPOINT: ${FLUXER_MEDIA_ENDPOINT:-}
|
||||
FLUXER_APP_ICON_URL: ${FLUXER_APP_ICON_URL:-}
|
||||
FLUXER_APP_SYMBOL_URL: ${FLUXER_APP_SYMBOL_URL:-}
|
||||
FLUXER_APP_LOGO_URL: ${FLUXER_APP_LOGO_URL:-}
|
||||
FLUXER_APP_WORDMARK_URL: ${FLUXER_APP_WORDMARK_URL:-}
|
||||
FLUXER_APP_FAVICON_URL: ${FLUXER_APP_FAVICON_URL:-}
|
||||
DISCOVERY_UPSTREAM_URL: http://edge:8088/.well-known/fluxer
|
||||
DISCOVERY_REFRESH_INTERVAL_MS: ${DISCOVERY_REFRESH_INTERVAL_MS:-}
|
||||
FLUXER_APP_PROXY_INDEX_UPSTREAM_URL: ${FLUXER_APP_PROXY_INDEX_UPSTREAM_URL:-}
|
||||
FLUXER_APP_PROXY_SAME_ORIGIN_HOSTS: ${FLUXER_APP_PROXY_SAME_ORIGIN_HOSTS:-}
|
||||
FLUXER_APP_PROXY_MANIFEST_SCOPE_EXTENSIONS: ${FLUXER_APP_PROXY_MANIFEST_SCOPE_EXTENSIONS:-}
|
||||
|
||||
Generated
+66
-13
@@ -4144,10 +4144,10 @@
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
|
||||
}
|
||||
},
|
||||
"description": "Validates that an SMTP configuration can authenticate and accept a connection. Requires INSTANCE_CONFIG_UPDATE permission.",
|
||||
"description": "Validates that an SMTP configuration can authenticate and accept a connection. Fields left out of the body come from the saved configuration, and the saved password is reused only for the saved host and username. An empty body tests what outgoing email uses and fails when email is off or incomplete. Requires INSTANCE_CONFIG_UPDATE permission.",
|
||||
"security": [{"adminApiKey": []}],
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"required": false,
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/InstanceEmailSmtpTestRequest"}}}
|
||||
}
|
||||
}
|
||||
@@ -11523,9 +11523,34 @@
|
||||
"port": {"nullable": true, "type": "integer", "minimum": 1, "maximum": 65535},
|
||||
"username": {"nullable": true, "type": "string"},
|
||||
"password_set": {"type": "boolean"},
|
||||
"secure": {"nullable": true, "type": "boolean"}
|
||||
"tls_mode": {
|
||||
"nullable": true,
|
||||
"description": "Saved TLS mode, or null to pick one from the port",
|
||||
"x-enumNames": ["IMPLICIT", "STARTTLS", "OPPORTUNISTIC", "NONE"],
|
||||
"x-enumDescriptions": [
|
||||
"TLS from the first byte, usually on port 465",
|
||||
"Plain connection that must upgrade with STARTTLS, usually on port 587",
|
||||
"Plain connection upgraded with STARTTLS when the server offers it",
|
||||
"No TLS, even when the server offers STARTTLS"
|
||||
],
|
||||
"enum": ["implicit", "starttls", "opportunistic", "none"],
|
||||
"type": "string"
|
||||
},
|
||||
"effective_tls_mode": {
|
||||
"nullable": true,
|
||||
"description": "How the SMTP connection is encrypted",
|
||||
"x-enumNames": ["IMPLICIT", "STARTTLS", "OPPORTUNISTIC", "NONE"],
|
||||
"x-enumDescriptions": [
|
||||
"TLS from the first byte, usually on port 465",
|
||||
"Plain connection that must upgrade with STARTTLS, usually on port 587",
|
||||
"Plain connection upgraded with STARTTLS when the server offers it",
|
||||
"No TLS, even when the server offers STARTTLS"
|
||||
],
|
||||
"enum": ["implicit", "starttls", "opportunistic", "none"],
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": ["host", "port", "username", "password_set", "secure"],
|
||||
"required": ["host", "port", "username", "password_set", "tls_mode", "effective_tls_mode"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"disable_new_ip_authorization": {"type": "boolean"},
|
||||
@@ -11788,13 +11813,24 @@
|
||||
"InstanceEmailSmtpTestRequest": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"host": {"type": "string", "minLength": 1, "maxLength": 255},
|
||||
"port": {"type": "integer", "minimum": 1, "maximum": 65535},
|
||||
"username": {"type": "string", "minLength": 1, "maxLength": 320},
|
||||
"password": {"type": "string", "minLength": 1, "maxLength": 4096},
|
||||
"secure": {"default": true, "type": "boolean"}
|
||||
},
|
||||
"required": ["host", "port", "username", "password"]
|
||||
"host": {"nullable": true, "type": "string", "minLength": 1, "maxLength": 255},
|
||||
"port": {"nullable": true, "type": "integer", "minimum": 1, "maximum": 65535},
|
||||
"username": {"nullable": true, "type": "string", "minLength": 1, "maxLength": 320},
|
||||
"password": {"nullable": true, "type": "string", "minLength": 1, "maxLength": 4096},
|
||||
"tls_mode": {
|
||||
"nullable": true,
|
||||
"description": "How the SMTP connection is encrypted",
|
||||
"x-enumNames": ["IMPLICIT", "STARTTLS", "OPPORTUNISTIC", "NONE"],
|
||||
"x-enumDescriptions": [
|
||||
"TLS from the first byte, usually on port 465",
|
||||
"Plain connection that must upgrade with STARTTLS, usually on port 587",
|
||||
"Plain connection upgraded with STARTTLS when the server offers it",
|
||||
"No TLS, even when the server offers STARTTLS"
|
||||
],
|
||||
"enum": ["implicit", "starttls", "opportunistic", "none"],
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"InstanceEmailSmtpTestResponse": {
|
||||
"type": "object",
|
||||
@@ -11869,7 +11905,11 @@
|
||||
"logo_url": {"nullable": true, "type": "string", "maxLength": 2048},
|
||||
"wordmark_url": {"nullable": true, "type": "string", "maxLength": 2048},
|
||||
"favicon_url": {"nullable": true, "type": "string", "maxLength": 2048},
|
||||
"theme_color": {"nullable": true, "type": "string", "maxLength": 64},
|
||||
"theme_color": {
|
||||
"nullable": true,
|
||||
"type": "string",
|
||||
"pattern": "^(?:#?(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6}))?$"
|
||||
},
|
||||
"status_page_url": {"nullable": true, "type": "string", "maxLength": 2048},
|
||||
"status_page_incident_history_url": {"nullable": true, "type": "string", "maxLength": 2048},
|
||||
"premium_product_name": {"nullable": true, "type": "string", "minLength": 1, "maxLength": 40},
|
||||
@@ -11923,7 +11963,20 @@
|
||||
"port": {"nullable": true, "type": "integer", "minimum": 1, "maximum": 65535},
|
||||
"username": {"nullable": true, "type": "string", "maxLength": 320},
|
||||
"password": {"nullable": true, "type": "string", "maxLength": 4096},
|
||||
"secure": {"nullable": true, "type": "boolean"}
|
||||
"tls_mode": {
|
||||
"nullable": true,
|
||||
"description": "How the SMTP connection is encrypted",
|
||||
"x-enumNames": ["IMPLICIT", "STARTTLS", "OPPORTUNISTIC", "NONE"],
|
||||
"x-enumDescriptions": [
|
||||
"TLS from the first byte, usually on port 465",
|
||||
"Plain connection that must upgrade with STARTTLS, usually on port 587",
|
||||
"Plain connection upgraded with STARTTLS when the server offers it",
|
||||
"No TLS, even when the server offers STARTTLS"
|
||||
],
|
||||
"enum": ["implicit", "starttls", "opportunistic", "none"],
|
||||
"type": "string"
|
||||
},
|
||||
"secure": {"description": "Deprecated, use tls_mode", "nullable": true, "type": "boolean"}
|
||||
}
|
||||
},
|
||||
"disable_new_ip_authorization": {"nullable": true, "type": "boolean"}
|
||||
|
||||
@@ -4,7 +4,7 @@ use super::client::{AdminApiClient, ApiResult};
|
||||
use super::types::{
|
||||
AccountIdentitySettings, CreateRegistrationUrlRequest, CreateRegistrationUrlResponse,
|
||||
InstanceAccountIdentityDiscovery, InstanceConfigResponse, InstanceConfigUpdateRequest,
|
||||
InstanceEmailSmtpTestRequest, InstanceEmailSmtpTestResponse, InstancePremiumDiscovery,
|
||||
InstanceEmailSmtpTestResponse, InstancePremiumDiscovery,
|
||||
};
|
||||
|
||||
impl AdminApiClient {
|
||||
@@ -34,11 +34,10 @@ impl AdminApiClient {
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn test_instance_smtp_config(
|
||||
pub async fn test_saved_instance_smtp_config(
|
||||
&self,
|
||||
request: &InstanceEmailSmtpTestRequest,
|
||||
) -> ApiResult<InstanceEmailSmtpTestResponse> {
|
||||
self.post_typed("/admin/instance/config/smtp-tests", request)
|
||||
self.post_typed("/admin/instance/config/smtp-tests", &serde_json::json!({}))
|
||||
.await
|
||||
}
|
||||
|
||||
|
||||
@@ -229,7 +229,8 @@ pub struct InstanceEmailSmtpIntegrationResponse {
|
||||
pub username: Option<String>,
|
||||
#[serde(default)]
|
||||
pub password_set: bool,
|
||||
pub secure: Option<bool>,
|
||||
pub tls_mode: Option<String>,
|
||||
pub effective_tls_mode: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
|
||||
@@ -873,7 +874,7 @@ pub struct InstanceEmailSmtpIntegrationUpdateRequest {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub password: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub secure: Option<bool>,
|
||||
pub tls_mode: Option<Option<String>>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Serialize)]
|
||||
@@ -928,15 +929,6 @@ pub struct InstanceAttachmentDecayUpdateRequest {
|
||||
pub renew_window_days: Option<u32>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Serialize)]
|
||||
pub struct InstanceEmailSmtpTestRequest {
|
||||
pub host: String,
|
||||
pub port: u16,
|
||||
pub username: String,
|
||||
pub password: String,
|
||||
pub secure: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
|
||||
pub struct InstanceEmailSmtpTestResponse {
|
||||
#[serde(default)]
|
||||
|
||||
@@ -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 {
|
||||
status: 400,
|
||||
message,
|
||||
|
||||
@@ -14,11 +14,11 @@ use crate::{
|
||||
InstanceAttachmentDecayUpdateRequest, InstanceBlueskyIntegrationUpdateRequest,
|
||||
InstanceBlueskyKeyIntegrationUpdateRequest, InstanceConfigUpdateRequest,
|
||||
InstanceEmailIntegrationUpdateRequest, InstanceEmailSmtpIntegrationUpdateRequest,
|
||||
InstanceEmailSmtpTestRequest, InstanceGifIntegrationUpdateRequest,
|
||||
InstanceEmailSmtpTestResponse, InstanceGifIntegrationUpdateRequest,
|
||||
InstanceIntegrationsUpdateRequest, InstanceMediaUpdateRequest,
|
||||
InstancePolicyUpdateRequest, InstanceRegistrationConfigUpdateRequest,
|
||||
InstanceServicesUpdateRequest, InstanceYoutubeIntegrationUpdateRequest,
|
||||
LimitConfigUpdateRequest, LimitRule, LimitRuleFilters, PremiumMode,
|
||||
LimitConfigUpdateRequest, LimitKeyMetadata, LimitRule, LimitRuleFilters, PremiumMode,
|
||||
PushRelayConfigUpdateRequest, RegistrationMode, SsoConfigUpdateRequest,
|
||||
VOICE_P2P_MAX_PARTICIPANTS_RANGE, VoiceE2eeScope, VoiceP2pConfigUpdateRequest,
|
||||
},
|
||||
@@ -233,21 +233,19 @@ pub async fn instance_config_post(
|
||||
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),
|
||||
},
|
||||
"test_smtp" => {
|
||||
let update = build_integrations_update(&form);
|
||||
match client.update_instance_config(&update).await {
|
||||
Ok(_) => match client.test_saved_instance_smtp_config().await {
|
||||
Ok(response) => smtp_test_flash(response),
|
||||
Err(error) => {
|
||||
tracing::warn!(%error, "admin API request failed: test SMTP config");
|
||||
FlashData::error("Settings saved, but the SMTP test could not run")
|
||||
}
|
||||
},
|
||||
Err(error) => instance_config_result(Err::<(), _>(error)),
|
||||
}
|
||||
}
|
||||
"disable_single_community" => {
|
||||
let update = build_single_community_update(false);
|
||||
instance_config_result(client.update_instance_config(&update).await)
|
||||
@@ -405,16 +403,33 @@ fn render_fragment_with_toast(markup: Markup, flash: &FlashData) -> 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 {
|
||||
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")
|
||||
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"),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn smtp_test_flash(response: InstanceEmailSmtpTestResponse) -> FlashData {
|
||||
if response.ok {
|
||||
return FlashData::success("Settings saved and SMTP connection verified");
|
||||
}
|
||||
let reason = response
|
||||
.error
|
||||
.unwrap_or_else(|| "the server gave no reason".to_owned());
|
||||
FlashData::error(format!(
|
||||
"Settings saved, but the SMTP test failed: {reason}"
|
||||
))
|
||||
}
|
||||
|
||||
fn build_sso_update(form: &MultiValueForm) -> InstanceConfigUpdateRequest {
|
||||
let flag = |key: &str| form.bool_value(key);
|
||||
let get = |key: &str| Some(form.clean(key));
|
||||
@@ -891,7 +906,9 @@ fn build_integrations_update(form: &MultiValueForm) -> InstanceConfigUpdateReque
|
||||
port: smtp_port,
|
||||
username: clean("integration_smtp_username"),
|
||||
password: clean("integration_smtp_password"),
|
||||
secure: Some(form.bool_value("integration_smtp_secure")),
|
||||
tls_mode: form
|
||||
.first("integration_smtp_tls_mode")
|
||||
.map(|value| Some(value.trim().to_owned()).filter(|mode| !mode.is_empty())),
|
||||
}),
|
||||
disable_new_ip_authorization: Some(
|
||||
form.bool_value("integration_email_disable_new_ip_authorization"),
|
||||
@@ -934,29 +951,6 @@ fn build_media_update(form: &MultiValueForm) -> InstanceConfigUpdateRequest {
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
@@ -1042,6 +1036,7 @@ fn update_limit_rule_values(
|
||||
rule: &mut LimitRule,
|
||||
form: &MultiValueForm,
|
||||
limit_keys: &[String],
|
||||
metadata: &std::collections::BTreeMap<String, LimitKeyMetadata>,
|
||||
fallback_limits: Option<&std::collections::BTreeMap<String, u64>>,
|
||||
) {
|
||||
let mut limits = std::collections::BTreeMap::new();
|
||||
@@ -1055,12 +1050,19 @@ fn update_limit_rule_values(
|
||||
{
|
||||
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.filters = build_limit_filters(form);
|
||||
}
|
||||
|
||||
fn limit_config_result<T, E: std::fmt::Display>(
|
||||
result: Result<T, E>,
|
||||
fn limit_config_result<T>(
|
||||
result: Result<T, crate::api::client::ApiError>,
|
||||
success_message: &'static str,
|
||||
error_message: &'static str,
|
||||
) -> FlashData {
|
||||
@@ -1068,7 +1070,10 @@ fn limit_config_result<T, E: std::fmt::Display>(
|
||||
Ok(_) => FlashData::success(success_message),
|
||||
Err(error) => {
|
||||
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 +1142,13 @@ pub async fn limit_config_post(
|
||||
.defaults
|
||||
.get(&rule_id)
|
||||
.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 result = client.update_limit_config(&request).await;
|
||||
let flash = limit_config_result(
|
||||
@@ -1147,6 +1158,43 @@ pub async fn limit_config_post(
|
||||
);
|
||||
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" => {
|
||||
let rule_id = match aq.rule.as_deref().and_then(clean_string) {
|
||||
Some(rule_id) => rule_id,
|
||||
@@ -1310,6 +1358,49 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_integrations_update_sends_the_smtp_tls_mode_only_when_the_form_has_it() {
|
||||
let smtp_update = |body: &[u8]| {
|
||||
build_integrations_update(&MultiValueForm::parse(body))
|
||||
.integrations
|
||||
.and_then(|integrations| integrations.email)
|
||||
.and_then(|email| email.smtp)
|
||||
.expect("smtp update")
|
||||
};
|
||||
let body = |update| serde_json::to_value(update).expect("serialize smtp update");
|
||||
|
||||
let chosen = smtp_update(b"integration_email_present=1&integration_smtp_tls_mode=none");
|
||||
assert_eq!(body(chosen), serde_json::json!({"tls_mode": "none"}));
|
||||
|
||||
let automatic = smtp_update(b"integration_email_present=1&integration_smtp_tls_mode=");
|
||||
assert_eq!(body(automatic), serde_json::json!({"tls_mode": null}));
|
||||
|
||||
let from_an_older_page =
|
||||
smtp_update(b"integration_email_present=1&integration_smtp_secure=true");
|
||||
assert_eq!(body(from_an_older_page), serde_json::json!({}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn smtp_test_flash_says_the_settings_were_saved_either_way() {
|
||||
let passed = smtp_test_flash(InstanceEmailSmtpTestResponse {
|
||||
ok: true,
|
||||
error: None,
|
||||
});
|
||||
assert_eq!(
|
||||
passed.message,
|
||||
"Settings saved and SMTP connection verified"
|
||||
);
|
||||
|
||||
let failed = smtp_test_flash(InstanceEmailSmtpTestResponse {
|
||||
ok: false,
|
||||
error: Some("wrong version number".to_owned()),
|
||||
});
|
||||
assert_eq!(
|
||||
failed.message,
|
||||
"Settings saved, but the SMTP test failed: wrong version number"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_sso_update_keeps_repeated_allowed_domains() {
|
||||
let form = MultiValueForm::parse(
|
||||
@@ -1360,6 +1451,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]
|
||||
fn parse_experiment_user_ids_splits_newlines_and_commas() {
|
||||
assert_eq!(
|
||||
@@ -1823,7 +2017,13 @@ mod tests {
|
||||
limits: std::collections::BTreeMap::new(),
|
||||
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.filters.expect("filters").traits,
|
||||
|
||||
@@ -151,6 +151,10 @@ pub fn danger_button(label: &str) -> Markup {
|
||||
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(
|
||||
label: &str,
|
||||
name: &str,
|
||||
|
||||
@@ -9,7 +9,7 @@ use crate::{
|
||||
ExperimentDeliveryConfigResponse, GatewayRolloutConfigResponse, InstanceConfigResponse,
|
||||
InstanceIntegrationsResponse, InstanceMediaResponse, InstancePolicyResponse,
|
||||
InstanceRegistrationResponse, LimitConfigResponse, PendingRegistrationResponse,
|
||||
PushRelayConfigResponse, RegistrationUrlResponse, SsoConfigResponse, TagStyle,
|
||||
PremiumMode, PushRelayConfigResponse, RegistrationUrlResponse, SsoConfigResponse, TagStyle,
|
||||
VOICE_P2P_DEFAULT_SALT, VOICE_P2P_MAX_PARTICIPANTS_RANGE, VoiceP2pConfigResponse,
|
||||
},
|
||||
config::AdminConfig,
|
||||
@@ -158,6 +158,7 @@ pub fn instance_config_page(
|
||||
csrf_token,
|
||||
&instance_config.policy,
|
||||
&instance_config.app_public.branding.premium_product_name,
|
||||
instance_config.billing.enabled == Some(true),
|
||||
))
|
||||
},
|
||||
))
|
||||
@@ -199,7 +200,12 @@ pub fn instance_config_page(
|
||||
"Media & retention",
|
||||
"Attachment expiry rules that can be changed without editing environment variables.",
|
||||
html! {
|
||||
(media_config_section(base, csrf_token, &instance_config.media))
|
||||
(media_config_section(
|
||||
base,
|
||||
csrf_token,
|
||||
&instance_config.media,
|
||||
instance_config.self_hosted,
|
||||
))
|
||||
},
|
||||
))
|
||||
(config_group(
|
||||
@@ -264,6 +270,7 @@ fn policy_config_section(
|
||||
csrf_token: &str,
|
||||
policy: &InstancePolicyResponse,
|
||||
premium_name: &str,
|
||||
billing_enabled: bool,
|
||||
) -> Markup {
|
||||
section_card_with_description(
|
||||
"Community & Policy",
|
||||
@@ -274,7 +281,7 @@ fn policy_config_section(
|
||||
div class="space-y-8" {
|
||||
(single_community_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))
|
||||
(services_form(base, csrf_token, policy))
|
||||
}
|
||||
@@ -373,6 +380,7 @@ fn premium_mode_form(
|
||||
csrf_token: &str,
|
||||
policy: &InstancePolicyResponse,
|
||||
premium_name: &str,
|
||||
billing_enabled: bool,
|
||||
) -> Markup {
|
||||
let mirror_label = format!("Mirror (Free and {premium_name} tiers)");
|
||||
let everyone_label = format!("Everyone (every member gets {premium_name} limits)");
|
||||
@@ -386,6 +394,16 @@ fn premium_mode_form(
|
||||
("mirror", mirror_label.as_str()),
|
||||
("everyone", everyone_label.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! {
|
||||
(submit_button("Save premium model"))
|
||||
}))
|
||||
@@ -528,6 +546,16 @@ fn password_input(name: &str, label: &str, helper: Option<&str>) -> Markup {
|
||||
)
|
||||
}
|
||||
|
||||
fn smtp_tls_mode_summary(mode: &str) -> &str {
|
||||
match mode {
|
||||
"implicit" => "implicit TLS",
|
||||
"starttls" => "required STARTTLS",
|
||||
"opportunistic" => "STARTTLS when the server offers it",
|
||||
"none" => "no encryption",
|
||||
other => other,
|
||||
}
|
||||
}
|
||||
|
||||
fn account_identity_section(account_identity: &AccountIdentityConfigResponse) -> Markup {
|
||||
let description = match account_identity.mode {
|
||||
AccountIdentityMode::Username => {
|
||||
@@ -671,15 +699,34 @@ fn integrations_config_section(
|
||||
))
|
||||
(password_input("integration_smtp_password", "SMTP password", Some("Leave blank to keep the current password.")))
|
||||
}
|
||||
(checkbox("integration_smtp_secure", "true", "Use TLS", integrations.email.smtp.secure.unwrap_or(true), true))
|
||||
(select_input("integration_smtp_tls_mode", "SMTP encryption", &[
|
||||
("", "Automatic"),
|
||||
("implicit", "Implicit TLS"),
|
||||
("starttls", "STARTTLS, required"),
|
||||
("opportunistic", "STARTTLS, when the server offers it"),
|
||||
("none", "None"),
|
||||
], integrations.email.smtp.tls_mode.as_deref().unwrap_or("")))
|
||||
p class="text-xs text-neutral-500" {
|
||||
"Automatic follows FLUXER_EMAIL_SMTP_TLS_MODE when it is set, then the older FLUXER_EMAIL_SMTP_SECURE, where true means implicit TLS on any port. With neither set it uses implicit TLS on port 465 and requires STARTTLS on every other port."
|
||||
}
|
||||
@if let Some(mode) = integrations.email.smtp.effective_tls_mode.as_deref() {
|
||||
p class="text-xs text-neutral-500" {
|
||||
"Outgoing email currently uses " (smtp_tls_mode_summary(mode))
|
||||
@if let Some(port) = integrations.email.smtp.port { " on port " (port) }
|
||||
"."
|
||||
}
|
||||
}
|
||||
(checkbox("integration_email_disable_new_ip_authorization", "true", "Disable new IP login authorization", integrations.email.disable_new_ip_authorization, true))
|
||||
div class="flex flex-wrap gap-2" {
|
||||
button type="submit"
|
||||
formaction={(base) "/instance-config?action=test_smtp"}
|
||||
class="inline-flex w-fit items-center justify-center gap-2 rounded-lg border border-neutral-300 bg-neutral-50 px-4 py-2 font-medium text-base text-neutral-700 transition-all duration-150 hover:border-neutral-400 hover:text-neutral-900 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-white" {
|
||||
span { "Test SMTP connection" }
|
||||
span { "Save and test SMTP" }
|
||||
}
|
||||
}
|
||||
p class="text-xs text-neutral-500" {
|
||||
"The test saves this form first, then connects with the saved settings, the same ones outgoing email uses."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -739,7 +786,12 @@ fn integrations_config_section(
|
||||
)
|
||||
}
|
||||
|
||||
fn media_config_section(base: &str, csrf_token: &str, media: &InstanceMediaResponse) -> Markup {
|
||||
fn media_config_section(
|
||||
base: &str,
|
||||
csrf_token: &str,
|
||||
media: &InstanceMediaResponse,
|
||||
self_hosted: bool,
|
||||
) -> Markup {
|
||||
let decay = &media.attachment_decay;
|
||||
let effective = &decay.effective;
|
||||
let enabled = decay.enabled.unwrap_or(effective.enabled);
|
||||
@@ -767,9 +819,14 @@ fn media_config_section(base: &str, csrf_token: &str, media: &InstanceMediaRespo
|
||||
.renew_window_days
|
||||
.unwrap_or(effective.renew_window_days)
|
||||
.to_string();
|
||||
let description = if self_hosted {
|
||||
"Expire eligible attachments using size-based lifetimes. This is enabled by default and applies at runtime. On a self-hosted instance, changes also apply to attachments uploaded earlier, which are kept for at least the renew window before a new or shorter expiry deletes them."
|
||||
} else {
|
||||
"Expire eligible attachments using size-based lifetimes. This is enabled by default and applies at runtime. Changes apply to attachments uploaded from then on."
|
||||
};
|
||||
section_card_with_description(
|
||||
"Media Expiry",
|
||||
"Expire eligible attachments using size-based lifetimes. This is disabled by default and applies at runtime.",
|
||||
description,
|
||||
html! {
|
||||
form method="post" action={(base) "/instance-config?action=update_media"} {
|
||||
(csrf_input(csrf_token))
|
||||
@@ -893,9 +950,9 @@ fn app_public_config_section(
|
||||
))
|
||||
(text_input(
|
||||
"app_theme_color",
|
||||
"Theme Color",
|
||||
"Theme Color (hex)",
|
||||
app_public.branding.theme_color.as_deref().unwrap_or(""),
|
||||
"#5865f2",
|
||||
"#4641d9",
|
||||
))
|
||||
}
|
||||
div class="grid grid-cols-1 gap-4 sm:grid-cols-2" {
|
||||
|
||||
@@ -8,7 +8,10 @@ use crate::{
|
||||
middleware::auth::AuthContext,
|
||||
templates::{
|
||||
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},
|
||||
tooltip,
|
||||
},
|
||||
@@ -81,7 +84,7 @@ fn render_limit_page(
|
||||
div class="space-y-6" {
|
||||
(rule_tabs(config, &rules, active_rule_id, can_update))
|
||||
@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))
|
||||
} @else {
|
||||
div class="rounded-lg border border-neutral-200 bg-white p-4 shadow-sm" {
|
||||
@@ -151,11 +154,18 @@ fn rule_tabs(
|
||||
}
|
||||
|
||||
fn rule_header(
|
||||
config: &AdminConfig,
|
||||
base: &str,
|
||||
csrf_token: &str,
|
||||
rule: &LimitRule,
|
||||
can_update: bool,
|
||||
has_defaults: bool,
|
||||
) -> Markup {
|
||||
let can_reset = can_update
|
||||
&& has_defaults
|
||||
&& rule
|
||||
.modified_fields
|
||||
.as_ref()
|
||||
.is_some_and(|fields| !fields.is_empty());
|
||||
card_with_header(
|
||||
"",
|
||||
html! {
|
||||
@@ -176,11 +186,20 @@ fn rule_header(
|
||||
}
|
||||
}
|
||||
}
|
||||
@if can_update && rule.id != "default" {
|
||||
form method="post"
|
||||
action={(config.base_path) "/limit-config?action=delete&rule=" (rule.id)} {
|
||||
(csrf_input(csrf_token))
|
||||
(danger_button("Delete Rule"))
|
||||
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" {
|
||||
form method="post"
|
||||
action={(base) "/limit-config?action=delete&rule=" (rule.id)} {
|
||||
(csrf_input(csrf_token))
|
||||
(danger_button("Delete Rule"))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -112,9 +112,9 @@ fn render_user_detail(
|
||||
@if let Some(banner) = banner_url {
|
||||
div class="mb-4 space-y-2" {
|
||||
a href=(banner) target="_blank" rel="noreferrer noopener"
|
||||
class="block rounded-lg focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-primary focus-visible:ring-offset-2" {
|
||||
class="block max-w-2xl rounded-lg focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-primary focus-visible:ring-offset-2" {
|
||||
img src=(banner) alt={(user.username) "'s banner"}
|
||||
class="h-32 w-full rounded-lg border border-neutral-200 bg-neutral-50 object-cover sm:h-48"
|
||||
class="aspect-[17/6] w-full rounded-lg border border-neutral-200 bg-neutral-50 object-cover"
|
||||
loading="lazy";
|
||||
}
|
||||
@if let Some(hash) = &user.banner {
|
||||
|
||||
@@ -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" {
|
||||
(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! {
|
||||
(submit_button("Save Traits"))
|
||||
|
||||
@@ -500,7 +500,8 @@ fn deserialize_instance_config_response_with_unknown_keys() {
|
||||
"port": 587,
|
||||
"username": "fluxer",
|
||||
"password_set": true,
|
||||
"secure": true
|
||||
"tls_mode": null,
|
||||
"effective_tls_mode": "starttls"
|
||||
},
|
||||
"disable_new_ip_authorization": false,
|
||||
"effective_disable_new_ip_authorization": false
|
||||
|
||||
@@ -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]
|
||||
async fn report_resolve_form_sends_the_chosen_resolution_and_refuses_none() {
|
||||
let received = Arc::new(Mutex::new(Vec::<Value>::new()));
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fluxer/constants": "workspace:*",
|
||||
"@fluxer/i18n": "workspace:*",
|
||||
"@fluxer/logger": "workspace:*",
|
||||
"itty-time": "catalog:",
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {SmtpEmailProvider} from '@pkgs/email/src/SmtpEmailProvider';
|
||||
import {beforeEach, describe, expect, it, vi} from 'vitest';
|
||||
|
||||
const {createTransport} = vi.hoisted(() => ({createTransport: vi.fn()}));
|
||||
|
||||
vi.mock('nodemailer', () => ({default: {createTransport}}));
|
||||
|
||||
describe('SmtpEmailProvider', () => {
|
||||
beforeEach(() => {
|
||||
createTransport.mockReset();
|
||||
createTransport.mockReturnValue({sendMail: vi.fn(), verify: vi.fn()});
|
||||
});
|
||||
|
||||
it.each([
|
||||
{port: 465, tlsMode: undefined, secure: true, requireTLS: false, ignoreTLS: false},
|
||||
{port: 587, tlsMode: undefined, secure: false, requireTLS: true, ignoreTLS: false},
|
||||
{port: 587, tlsMode: null, secure: false, requireTLS: true, ignoreTLS: false},
|
||||
{port: 587, tlsMode: 'implicit', secure: true, requireTLS: false, ignoreTLS: false},
|
||||
{port: 465, tlsMode: 'starttls', secure: false, requireTLS: true, ignoreTLS: false},
|
||||
{port: 25, tlsMode: 'opportunistic', secure: false, requireTLS: false, ignoreTLS: false},
|
||||
{port: 587, tlsMode: 'none', secure: false, requireTLS: false, ignoreTLS: true},
|
||||
] as const)('maps TLS mode $tlsMode on port $port to nodemailer options', ({port, tlsMode, ...expected}) => {
|
||||
new SmtpEmailProvider({host: 'smtp.example.com', port, username: 'user', password: 'pass', tlsMode});
|
||||
expect(createTransport).toHaveBeenCalledWith(expect.objectContaining(expected));
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {resolveSmtpTlsMode, type SmtpTlsMode, SmtpTlsModes} from '@fluxer/constants/src/SmtpConstants';
|
||||
import {createLogger} from '@fluxer/logger/src/Logger';
|
||||
import type {EmailMessage, IEmailProvider} from '@pkgs/email/src/EmailProviderTypes';
|
||||
import nodemailer, {type Transporter} from 'nodemailer';
|
||||
@@ -11,7 +12,7 @@ interface SmtpEmailConfig {
|
||||
port: number;
|
||||
username: string;
|
||||
password: string;
|
||||
secure?: boolean;
|
||||
tlsMode?: SmtpTlsMode | null;
|
||||
connectionTimeoutMs?: number;
|
||||
greetingTimeoutMs?: number;
|
||||
socketTimeoutMs?: number;
|
||||
@@ -19,12 +20,20 @@ interface SmtpEmailConfig {
|
||||
|
||||
export class SmtpEmailProvider implements IEmailProvider {
|
||||
private readonly transporter: Transporter;
|
||||
private readonly host: string;
|
||||
private readonly port: number;
|
||||
private readonly tlsMode: SmtpTlsMode;
|
||||
|
||||
constructor(config: SmtpEmailConfig) {
|
||||
this.host = config.host;
|
||||
this.port = config.port;
|
||||
this.tlsMode = resolveSmtpTlsMode(config.tlsMode, config.port);
|
||||
this.transporter = nodemailer.createTransport({
|
||||
host: config.host,
|
||||
port: config.port,
|
||||
secure: config.secure ?? true,
|
||||
secure: this.tlsMode === SmtpTlsModes.IMPLICIT,
|
||||
requireTLS: this.tlsMode === SmtpTlsModes.STARTTLS,
|
||||
ignoreTLS: this.tlsMode === SmtpTlsModes.NONE,
|
||||
auth: {
|
||||
user: config.username,
|
||||
pass: config.password,
|
||||
@@ -52,7 +61,7 @@ export class SmtpEmailProvider implements IEmailProvider {
|
||||
logger.debug({to: message.to}, 'Email sent via SMTP');
|
||||
return true;
|
||||
} catch (error) {
|
||||
logger.error({error}, 'SMTP send failed');
|
||||
logger.error({error, host: this.host, port: this.port, tlsMode: this.tlsMode}, 'SMTP send failed');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import {DonationRateLimitConfigs} from '@app/api/rate_limit_configs/DonationRate
|
||||
import {parseIpBanEntry} from '@app/api/utils/IpRangeUtils';
|
||||
import type {WorkerTaskName} from '@app/api/worker/WorkerLaneConfig';
|
||||
import type {MasterConfig} from '@fluxer/config/src/MasterConfig';
|
||||
import {smtpTlsModeFromSecure} from '@fluxer/constants/src/SmtpConstants';
|
||||
import {parseIpAddress} from '@fluxer/ip_utils/src/IpAddress';
|
||||
import {parseGeoipSourceConfig, resolveGeoipRuntimeSourceConfig} from '@pkgs/geoip/src/GeoipStartup';
|
||||
|
||||
@@ -280,7 +281,8 @@ export function buildAPIConfigFromMaster(master: MasterConfig): APIConfig {
|
||||
port: master.integrations.email.smtp.port,
|
||||
username: master.integrations.email.smtp.username,
|
||||
password: master.integrations.email.smtp.password,
|
||||
secure: master.integrations.email.smtp.secure ?? true,
|
||||
tlsMode:
|
||||
master.integrations.email.smtp.tls_mode ?? smtpTlsModeFromSecure(master.integrations.email.smtp.secure),
|
||||
}
|
||||
: undefined,
|
||||
},
|
||||
|
||||
@@ -2,9 +2,11 @@
|
||||
|
||||
import {AdminAuditReadActions} from '@app/api/admin/AdminAuditActions';
|
||||
import {recordAdminRead, recordAdminWrite} from '@app/api/admin/AdminAuditRecorder';
|
||||
import {startAttachmentDecayReconcile} from '@app/api/attachment/AttachmentDecayReconcileRun';
|
||||
import {createUserID} from '@app/api/BrandedTypes';
|
||||
import {Config} from '@app/api/Config';
|
||||
import {
|
||||
type InstanceAttachmentDecayEffectiveConfig,
|
||||
type InstancePolicyConfig,
|
||||
REGISTRATION_PENDING_APPROVAL_TRAIT,
|
||||
REGISTRATION_REJECTED_TRAIT,
|
||||
@@ -13,6 +15,7 @@ import {deriveSsoRedirectUri, normalizeAndValidateSsoConfig} from '@app/api/inst
|
||||
import {requireAdminACL} from '@app/api/middleware/AdminMiddleware';
|
||||
import {RateLimitMiddleware} from '@app/api/middleware/RateLimitMiddleware';
|
||||
import {OpenAPI} from '@app/api/middleware/ResponseTypeMiddleware';
|
||||
import {getKVClient, getWorkerService} from '@app/api/middleware/ServiceRegistry';
|
||||
import {
|
||||
getGatewayRolloutConfigPublisher,
|
||||
getInstanceConfigRepository,
|
||||
@@ -22,6 +25,7 @@ import {RateLimitConfigs} from '@app/api/RateLimitConfig';
|
||||
import type {HonoApp, HonoEnv} from '@app/api/types/HonoEnv';
|
||||
import {Validator} from '@app/api/Validator';
|
||||
import {AdminACLs} from '@fluxer/constants/src/AdminACLs';
|
||||
import {resolveSmtpTlsMode, type SmtpTlsMode, smtpTlsModeFromSecure} from '@fluxer/constants/src/SmtpConstants';
|
||||
import {InputValidationError} from '@fluxer/errors/src/domains/core/InputValidationError';
|
||||
import {InstancePolicyTransitionNotAllowedError} from '@fluxer/errors/src/domains/core/InstancePolicyTransitionNotAllowedError';
|
||||
import {
|
||||
@@ -47,11 +51,37 @@ import type {Context} from 'hono';
|
||||
import {createMiddleware} from 'hono/factory';
|
||||
|
||||
const INSTANCE_BRANDING_ENTITY_ID = 0n;
|
||||
const ATTACHMENT_EXPIRY_RULE_FIELDS = [
|
||||
'enabled',
|
||||
'min_size_mb',
|
||||
'max_size_mb',
|
||||
'max_eligible_size_mb',
|
||||
'min_lifetime_days',
|
||||
'max_lifetime_days',
|
||||
'curve',
|
||||
] as const satisfies ReadonlyArray<keyof InstanceAttachmentDecayEffectiveConfig>;
|
||||
|
||||
function attachmentExpiryRulesChanged(
|
||||
previous: InstanceAttachmentDecayEffectiveConfig,
|
||||
next: InstanceAttachmentDecayEffectiveConfig,
|
||||
): boolean {
|
||||
return ATTACHMENT_EXPIRY_RULE_FIELDS.some((field) => previous[field] !== next[field]);
|
||||
}
|
||||
|
||||
function readOptionalField<T extends object, K extends keyof T>(value: T, key: K): T[K] | undefined {
|
||||
return Object.hasOwn(value, key) ? value[key] : undefined;
|
||||
}
|
||||
|
||||
function readSmtpTlsModeUpdate(smtp: {
|
||||
tls_mode?: SmtpTlsMode | null;
|
||||
secure?: boolean | null;
|
||||
}): SmtpTlsMode | null | undefined {
|
||||
const tlsMode = readOptionalField(smtp, 'tls_mode');
|
||||
if (tlsMode !== undefined) return tlsMode;
|
||||
const secure = readOptionalField(smtp, 'secure');
|
||||
return secure === undefined ? undefined : smtpTlsModeFromSecure(secure);
|
||||
}
|
||||
|
||||
function mergeOptionalField<T>(currentValue: T, nextValue: T | undefined): T {
|
||||
return nextValue === undefined ? currentValue : nextValue;
|
||||
}
|
||||
@@ -535,7 +565,7 @@ export function InstanceConfigAdminController(app: HonoApp) {
|
||||
port: readOptionalField(data.integrations.email.smtp, 'port'),
|
||||
username: readOptionalField(data.integrations.email.smtp, 'username'),
|
||||
password: readOptionalField(data.integrations.email.smtp, 'password'),
|
||||
secure: readOptionalField(data.integrations.email.smtp, 'secure'),
|
||||
tls_mode: readSmtpTlsModeUpdate(data.integrations.email.smtp),
|
||||
})
|
||||
: undefined,
|
||||
}
|
||||
@@ -556,6 +586,7 @@ export function InstanceConfigAdminController(app: HonoApp) {
|
||||
});
|
||||
}
|
||||
if (data.media) {
|
||||
const previousAttachmentDecay = await instanceConfigRepository.getEffectiveAttachmentDecayConfig();
|
||||
await instanceConfigRepository.setInstanceMediaConfig({
|
||||
attachment_decay: data.media.attachment_decay
|
||||
? omitUndefinedFields({
|
||||
@@ -571,6 +602,10 @@ export function InstanceConfigAdminController(app: HonoApp) {
|
||||
})
|
||||
: undefined,
|
||||
});
|
||||
const attachmentDecay = await instanceConfigRepository.getEffectiveAttachmentDecayConfig();
|
||||
if (attachmentDecay.enabled && attachmentExpiryRulesChanged(previousAttachmentDecay, attachmentDecay)) {
|
||||
await startAttachmentDecayReconcile(getKVClient(), getWorkerService());
|
||||
}
|
||||
}
|
||||
if (data.policy) {
|
||||
await applyInstancePolicyUpdate(
|
||||
@@ -665,22 +700,19 @@ export function InstanceConfigAdminController(app: HonoApp) {
|
||||
operationId: 'create_admin_instance_smtp_test',
|
||||
summary: 'Run an SMTP configuration test',
|
||||
description:
|
||||
'Validates that an SMTP configuration can authenticate and accept a connection. Requires INSTANCE_CONFIG_UPDATE permission.',
|
||||
'Validates that an SMTP configuration can authenticate and accept a connection. Fields left out of the body come from the saved configuration, and the saved password is reused only for the saved host and username. An empty body tests what outgoing email uses and fails when email is off or incomplete. Requires INSTANCE_CONFIG_UPDATE permission.',
|
||||
responseSchema: InstanceEmailSmtpTestResponse,
|
||||
statusCode: 200,
|
||||
security: 'adminApiKey',
|
||||
tags: 'Admin',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const data = ctx.req.valid('json');
|
||||
const {smtp, problem} = await instanceConfigRepository.getSmtpTestConfig(ctx.req.valid('json'));
|
||||
let result: InstanceEmailSmtpTestResponse;
|
||||
try {
|
||||
if (problem) throw new Error(problem);
|
||||
const provider = new SmtpEmailProvider({
|
||||
host: data.host,
|
||||
port: data.port,
|
||||
username: data.username,
|
||||
password: data.password,
|
||||
secure: data.secure,
|
||||
...smtp,
|
||||
connectionTimeoutMs: 10000,
|
||||
greetingTimeoutMs: 10000,
|
||||
socketTimeoutMs: 10000,
|
||||
@@ -694,7 +726,7 @@ export function InstanceConfigAdminController(app: HonoApp) {
|
||||
targetType: 'instance_config',
|
||||
targetId: 0n,
|
||||
action: 'test_smtp_connection',
|
||||
metadata: {port: data.port, secure: data.secure, ok: result.ok},
|
||||
metadata: {port: smtp.port, tls_mode: resolveSmtpTlsMode(smtp.tlsMode, smtp.port), ok: result.ok},
|
||||
});
|
||||
return ctx.json(result);
|
||||
},
|
||||
|
||||
@@ -362,4 +362,17 @@ describe('instance config billing on a hosted instance', () => {
|
||||
}).execute();
|
||||
expect(updated.app_public.branding.theme_color).toBe('#123456');
|
||||
});
|
||||
|
||||
it('rejects a theme colour that is not hex', async () => {
|
||||
const admin = await createAdmin(context.harness);
|
||||
for (const theme_color of ['rgb(123, 44, 228)', 'rebeccapurple', '#12345']) {
|
||||
await patchConfig(context.harness, admin, {app_public: {branding: {theme_color}}})
|
||||
.expect(HTTP_STATUS.BAD_REQUEST, APIErrorCodes.INVALID_FORM_BODY)
|
||||
.execute();
|
||||
}
|
||||
const cleared = await patchConfig(context.harness, admin, {
|
||||
app_public: {branding: {theme_color: null}},
|
||||
}).execute();
|
||||
expect(cleared.app_public.branding.theme_color).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,200 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import type {TestAccount} from '@app/api/auth/tests/AuthTestUtils';
|
||||
import {createTestAccount, setUserACLs} from '@app/api/auth/tests/AuthTestUtils';
|
||||
import {getConfig} from '@app/api/Config';
|
||||
import {type ApiTestHarness, createApiTestHarness} from '@app/api/test/ApiTestHarness';
|
||||
import {createBuilder} from '@app/api/test/TestRequestBuilder';
|
||||
import {AdminACLs} from '@fluxer/constants/src/AdminACLs';
|
||||
import type {
|
||||
InstanceConfigResponse,
|
||||
InstanceEmailSmtpTestResponse,
|
||||
} from '@fluxer/schema/src/domains/admin/AdminSchemas';
|
||||
import nodemailer, {type Transporter} from 'nodemailer';
|
||||
import {afterAll, beforeAll, beforeEach, describe, expect, it, vi} from 'vitest';
|
||||
|
||||
describe('instance SMTP settings', () => {
|
||||
let harness: ApiTestHarness;
|
||||
let admin: TestAccount;
|
||||
const verify = vi.fn();
|
||||
const createTransport = vi.spyOn(nodemailer, 'createTransport');
|
||||
|
||||
beforeAll(async () => {
|
||||
harness = await createApiTestHarness();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await harness.reset();
|
||||
createTransport.mockReset();
|
||||
createTransport.mockReturnValue({verify} as unknown as Transporter);
|
||||
verify.mockReset();
|
||||
verify.mockResolvedValue(true);
|
||||
admin = await setUserACLs(harness, await createTestAccount(harness), [
|
||||
AdminACLs.AUTHENTICATE,
|
||||
AdminACLs.INSTANCE_CONFIG_VIEW,
|
||||
AdminACLs.INSTANCE_CONFIG_UPDATE,
|
||||
]);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await harness.shutdown();
|
||||
createTransport.mockRestore();
|
||||
});
|
||||
|
||||
async function patchSmtp(smtp: Record<string, unknown>) {
|
||||
const config = await createBuilder<InstanceConfigResponse>(harness, admin.token)
|
||||
.patch('/admin/instance/config')
|
||||
.body({integrations: {email: {smtp}}})
|
||||
.execute();
|
||||
return config.integrations.email.smtp;
|
||||
}
|
||||
|
||||
async function runSmtpTest(body: Record<string, unknown>) {
|
||||
return await createBuilder<InstanceEmailSmtpTestResponse>(harness, admin.token)
|
||||
.post('/admin/instance/config/smtp-tests')
|
||||
.body(body)
|
||||
.execute();
|
||||
}
|
||||
|
||||
it('tests the saved settings when the request names none', async () => {
|
||||
await patchSmtp({
|
||||
host: 'smtp.example.com',
|
||||
port: 587,
|
||||
username: 'mailer',
|
||||
password: 'saved-password',
|
||||
tls_mode: 'implicit',
|
||||
});
|
||||
await expect(runSmtpTest({})).resolves.toEqual({ok: true, error: null});
|
||||
expect(createTransport).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
host: 'smtp.example.com',
|
||||
port: 587,
|
||||
secure: true,
|
||||
requireTLS: false,
|
||||
ignoreTLS: false,
|
||||
auth: {user: 'mailer', pass: 'saved-password'},
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('reports the failure a send with the saved settings would hit', async () => {
|
||||
await patchSmtp({host: 'smtp.example.com', port: 587, username: 'mailer', password: 'saved-password'});
|
||||
verify.mockRejectedValue(new Error('wrong version number'));
|
||||
await expect(runSmtpTest({})).resolves.toEqual({ok: false, error: 'wrong version number'});
|
||||
});
|
||||
|
||||
it('fills a draft for the saved host with the saved password', async () => {
|
||||
await patchSmtp({
|
||||
host: 'smtp.example.com',
|
||||
port: 465,
|
||||
username: 'mailer',
|
||||
password: 'saved-password',
|
||||
tls_mode: 'implicit',
|
||||
});
|
||||
await expect(runSmtpTest({host: 'SMTP.example.com', port: 2525, tls_mode: null})).resolves.toEqual({
|
||||
ok: true,
|
||||
error: null,
|
||||
});
|
||||
expect(createTransport).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
host: 'SMTP.example.com',
|
||||
port: 2525,
|
||||
secure: false,
|
||||
ignoreTLS: true,
|
||||
auth: {user: 'mailer', pass: 'saved-password'},
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it.each([{host: 'draft.example.com'}, {username: 'someone-else'}, {host: 'draft.example.com', password: null}])(
|
||||
'keeps the saved password away from the draft %j',
|
||||
async (draft) => {
|
||||
await patchSmtp({host: 'smtp.example.com', port: 465, username: 'mailer', password: 'saved-password'});
|
||||
await expect(runSmtpTest(draft)).resolves.toEqual({
|
||||
ok: false,
|
||||
error: 'Enter the SMTP password to test a different host or username',
|
||||
});
|
||||
expect(createTransport).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
|
||||
it('tests a draft for another host with the password it brings', async () => {
|
||||
await patchSmtp({host: 'smtp.example.com', port: 465, username: 'mailer', password: 'saved-password'});
|
||||
await runSmtpTest({host: 'draft.example.com', username: 'drafter', password: 'draft-password'});
|
||||
expect(createTransport).toHaveBeenCalledWith(
|
||||
expect.objectContaining({host: 'draft.example.com', auth: {user: 'drafter', pass: 'draft-password'}}),
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
[{enabled: false}, 'Email is turned off'],
|
||||
[{provider: 'none'}, 'The email provider is not SMTP'],
|
||||
])('fails the saved test when %j stops outgoing email', async (email, error) => {
|
||||
await patchSmtp({host: 'smtp.example.com', port: 587, username: 'mailer', password: 'saved-password'});
|
||||
await createBuilder<InstanceConfigResponse>(harness, admin.token)
|
||||
.patch('/admin/instance/config')
|
||||
.body({integrations: {email}})
|
||||
.execute();
|
||||
await expect(runSmtpTest({})).resolves.toEqual({ok: false, error});
|
||||
expect(createTransport).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
[{fromEmail: ''}, 'No from address is set'],
|
||||
[{smtp: {host: '', port: 587, username: 'mailer', password: 'pass', tlsMode: null}}, 'No SMTP host is configured'],
|
||||
[
|
||||
{smtp: {host: 'relay.internal', port: 25, username: '', password: '', tlsMode: null}},
|
||||
'The SMTP username and password are both required',
|
||||
],
|
||||
])('fails the saved test when the environment leaves %j', async (environment, error) => {
|
||||
const original = {...getConfig().email};
|
||||
Object.assign(getConfig().email, environment);
|
||||
try {
|
||||
await expect(runSmtpTest({})).resolves.toEqual({ok: false, error});
|
||||
expect(createTransport).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
Object.assign(getConfig().email, original);
|
||||
}
|
||||
});
|
||||
|
||||
it('picks the TLS mode from the port until one is saved', async () => {
|
||||
const original = getConfig().email.smtp;
|
||||
getConfig().email.smtp = original ? {...original, tlsMode: null} : original;
|
||||
try {
|
||||
expect(await patchSmtp({host: 'smtp.example.com', port: 587})).toMatchObject({
|
||||
tls_mode: null,
|
||||
effective_tls_mode: 'starttls',
|
||||
});
|
||||
expect(await patchSmtp({port: 465})).toMatchObject({tls_mode: null, effective_tls_mode: 'implicit'});
|
||||
expect(await patchSmtp({tls_mode: 'none'})).toMatchObject({tls_mode: 'none', effective_tls_mode: 'none'});
|
||||
} finally {
|
||||
getConfig().email.smtp = original;
|
||||
}
|
||||
});
|
||||
|
||||
it('reads the legacy secure flag and lets the mode replace it', async () => {
|
||||
expect(await patchSmtp({host: 'smtp.example.com', port: 587, secure: true})).toMatchObject({
|
||||
tls_mode: 'implicit',
|
||||
});
|
||||
expect(await patchSmtp({secure: false})).toMatchObject({tls_mode: 'opportunistic'});
|
||||
expect(await patchSmtp({tls_mode: 'starttls'})).toMatchObject({tls_mode: 'starttls'});
|
||||
});
|
||||
|
||||
it('clears every saved field so the environment applies again', async () => {
|
||||
await patchSmtp({
|
||||
host: 'smtp.example.com',
|
||||
port: 465,
|
||||
username: 'mailer',
|
||||
password: 'saved-password',
|
||||
tls_mode: 'implicit',
|
||||
});
|
||||
const environment = getConfig().email.smtp;
|
||||
expect(await patchSmtp({host: null, port: null, username: null, password: null, tls_mode: null})).toMatchObject({
|
||||
host: environment?.host,
|
||||
port: environment?.port,
|
||||
username: environment?.username,
|
||||
tls_mode: null,
|
||||
effective_tls_mode: 'none',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -102,13 +102,13 @@ export const InstanceConfigAdminAuditCases: ReadonlyArray<AdminAuditCoverageCase
|
||||
return {
|
||||
request: {
|
||||
path: '/admin/instance/config/smtp-tests',
|
||||
body: {host: '127.0.0.1', port: 1, username: 'coverage', password: 'coverage-password', secure: false},
|
||||
body: {host: '127.0.0.1', port: 1, username: 'coverage', password: 'coverage-password', tls_mode: 'none'},
|
||||
},
|
||||
expected: {
|
||||
action: 'test_smtp_connection',
|
||||
targetType: 'instance_config',
|
||||
targetId: '0',
|
||||
metadata: {port: '1', secure: 'false', ok: 'false'},
|
||||
metadata: {port: '1', tls_mode: 'none', ok: 'false'},
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {randomUUID} from 'node:crypto';
|
||||
import {Config} from '@app/api/Config';
|
||||
import type {WorkerTaskName} from '@app/api/worker/WorkerLaneConfig';
|
||||
import type {IKVProvider} from '@pkgs/kv_client/src/IKVProvider';
|
||||
import type {IWorkerService} from '@pkgs/worker/src/contracts/IWorkerService';
|
||||
import {seconds} from 'itty-time';
|
||||
|
||||
const RUN_KEY = 'attachment_decay:reconcile_run';
|
||||
const RUN_TTL_SECONDS = seconds('1 day');
|
||||
|
||||
export function attachmentDecayReconcileJobKey(runId: string, chunk: number): string {
|
||||
return `${runId}:${chunk}`;
|
||||
}
|
||||
|
||||
export async function startAttachmentDecayReconcile(
|
||||
kvClient: IKVProvider,
|
||||
workerService: IWorkerService<WorkerTaskName>,
|
||||
): Promise<void> {
|
||||
if (!Config.instance.selfHosted) return;
|
||||
const runId = randomUUID();
|
||||
await kvClient.setex(RUN_KEY, RUN_TTL_SECONDS, runId);
|
||||
await workerService.addJob(
|
||||
'reconcileAttachmentDecay',
|
||||
{runId, chunk: 0},
|
||||
{jobKey: attachmentDecayReconcileJobKey(runId, 0)},
|
||||
);
|
||||
}
|
||||
|
||||
export async function holdAttachmentDecayReconcileRun(kvClient: IKVProvider, runId: string): Promise<boolean> {
|
||||
return (
|
||||
(await kvClient.extendLock(RUN_KEY, runId, RUN_TTL_SECONDS)) ||
|
||||
(await kvClient.acquireLock(RUN_KEY, runId, RUN_TTL_SECONDS))
|
||||
);
|
||||
}
|
||||
|
||||
export async function finishAttachmentDecayReconcileRun(kvClient: IKVProvider, runId: string): Promise<void> {
|
||||
await kvClient.releaseLock(RUN_KEY, runId);
|
||||
}
|
||||
|
||||
export async function isAttachmentDecayReconcileRunning(kvClient: IKVProvider): Promise<boolean> {
|
||||
if (!Config.instance.selfHosted) return false;
|
||||
return (await kvClient.exists(RUN_KEY)) > 0;
|
||||
}
|
||||
@@ -7,9 +7,11 @@ import {
|
||||
fetchMany,
|
||||
fetchManyInChunks,
|
||||
fetchOne,
|
||||
fetchPage,
|
||||
} from '@app/api/database/CassandraQueryExecution';
|
||||
import {AttachmentDecayByExpiry, AttachmentDecayById} from '@app/api/Tables';
|
||||
import {AttachmentDecayByExpiry, AttachmentDecayById, ChannelMessageBuckets} from '@app/api/Tables';
|
||||
import type {AttachmentDecayRow} from '@app/api/types/AttachmentDecayTypes';
|
||||
import {BACKGROUND_READ_TIMEOUT_MS} from '@pkgs/cassandra/src/Client';
|
||||
|
||||
interface AttachmentDecayExpiryRow {
|
||||
expiry_bucket: number;
|
||||
@@ -26,6 +28,7 @@ const FETCH_BY_ID_CQL = AttachmentDecayById.selectCql({
|
||||
const FETCH_BY_IDS_CQL = AttachmentDecayById.selectCql({
|
||||
where: AttachmentDecayById.where.in('attachment_id', 'attachment_ids'),
|
||||
});
|
||||
const SCAN_CHANNELS_WITH_MESSAGES_CQL = ChannelMessageBuckets.selectCql({columns: ['channel_id']});
|
||||
const createFetchExpiredByBucketQuery = (limit: number) =>
|
||||
AttachmentDecayByExpiry.select({
|
||||
where: [
|
||||
@@ -74,6 +77,18 @@ export class AttachmentDecayRepository {
|
||||
return map;
|
||||
}
|
||||
|
||||
async scanChannelsWithMessagesPage(
|
||||
limit: number,
|
||||
pageState: string | null,
|
||||
): Promise<{channelIds: Array<ChannelID>; pageState: string | null}> {
|
||||
const page = await fetchPage<{channel_id: ChannelID}>(
|
||||
SCAN_CHANNELS_WITH_MESSAGES_CQL,
|
||||
{},
|
||||
{pageSize: limit, pageState, readTimeout: BACKGROUND_READ_TIMEOUT_MS},
|
||||
);
|
||||
return {channelIds: [...new Set(page.rows.map((row) => row.channel_id))], pageState: page.pageState};
|
||||
}
|
||||
|
||||
async fetchExpiredByBucket(bucket: number, currentTime: Date, limit = 200): Promise<Array<AttachmentDecayExpiryRow>> {
|
||||
const query = createFetchExpiredByBucketQuery(limit);
|
||||
return fetchMany(query.bind({expiry_bucket: bucket, current_time: currentTime}));
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import type {AttachmentDecayRepository} from '@app/api/attachment/AttachmentDecayRepository';
|
||||
import {type AttachmentDecayPayload, AttachmentDecayService} from '@app/api/attachment/AttachmentDecayService';
|
||||
import type {AttachmentID} from '@app/api/BrandedTypes';
|
||||
import {createAttachmentID, createChannelID, createMessageID} from '@app/api/BrandedTypes';
|
||||
import type {InstanceAttachmentDecayEffectiveConfig} from '@app/api/instance/InstanceConfigRepository';
|
||||
import type {AttachmentDecayRow} from '@app/api/types/AttachmentDecayTypes';
|
||||
import {ms} from 'itty-time';
|
||||
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
|
||||
|
||||
const NOW = new Date('2026-10-10T00:00:00.000Z');
|
||||
const ATTACHMENT_ID = createAttachmentID(7001n);
|
||||
const CONFIG: InstanceAttachmentDecayEffectiveConfig = {
|
||||
enabled: true,
|
||||
min_size_mb: 5,
|
||||
max_size_mb: 500,
|
||||
max_eligible_size_mb: 500,
|
||||
min_lifetime_days: 14,
|
||||
max_lifetime_days: 365,
|
||||
curve: 0.5,
|
||||
renew_threshold_days: 30,
|
||||
renew_window_days: 30,
|
||||
};
|
||||
|
||||
function daysFromNow(days: number): Date {
|
||||
return new Date(NOW.getTime() + days * ms('1 day'));
|
||||
}
|
||||
|
||||
function payload(uploadedAt: Date, sizeBytes = 1024n): AttachmentDecayPayload {
|
||||
return {
|
||||
attachmentId: ATTACHMENT_ID,
|
||||
channelId: createChannelID(7002n),
|
||||
messageId: createMessageID(7003n),
|
||||
filename: 'file.png',
|
||||
sizeBytes,
|
||||
uploadedAt,
|
||||
};
|
||||
}
|
||||
|
||||
function createService(config: InstanceAttachmentDecayEffectiveConfig = CONFIG) {
|
||||
const rows = new Map<AttachmentID, AttachmentDecayRow>();
|
||||
const repo = {
|
||||
async fetchByIds(ids: Array<AttachmentID>) {
|
||||
return new Map(ids.flatMap((id) => (rows.has(id) ? [[id, rows.get(id)!] as const] : [])));
|
||||
},
|
||||
async upsert(record: AttachmentDecayRow) {
|
||||
rows.set(record.attachment_id, record);
|
||||
},
|
||||
async deleteRecords({attachment_id}: {attachment_id: AttachmentID}) {
|
||||
rows.delete(attachment_id);
|
||||
},
|
||||
} as unknown as AttachmentDecayRepository;
|
||||
return {rows, service: new AttachmentDecayService(repo, async () => config)};
|
||||
}
|
||||
|
||||
async function fileExists(): Promise<boolean> {
|
||||
return true;
|
||||
}
|
||||
|
||||
async function fileMissing(): Promise<boolean> {
|
||||
return false;
|
||||
}
|
||||
|
||||
function seed(rows: Map<AttachmentID, AttachmentDecayRow>, uploadedAt: Date, expiresAt: Date): void {
|
||||
rows.set(ATTACHMENT_ID, {
|
||||
attachment_id: ATTACHMENT_ID,
|
||||
channel_id: createChannelID(7002n),
|
||||
message_id: createMessageID(7003n),
|
||||
filename: 'file.png',
|
||||
size_bytes: 1024n,
|
||||
uploaded_at: uploadedAt,
|
||||
expires_at: expiresAt,
|
||||
last_accessed_at: uploadedAt,
|
||||
cost: 1,
|
||||
lifetime_days: 1,
|
||||
status: null,
|
||||
});
|
||||
}
|
||||
|
||||
describe('AttachmentDecayService.reconcile', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(NOW);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('writes nothing while expiry is off', async () => {
|
||||
const {rows, service} = createService({...CONFIG, enabled: false});
|
||||
|
||||
expect(await service.reconcile([payload(daysFromNow(-10))], fileExists)).toBe(0);
|
||||
expect(rows.size).toBe(0);
|
||||
});
|
||||
|
||||
it('gives an untracked attachment the expiry its upload date implies', async () => {
|
||||
const {rows, service} = createService();
|
||||
|
||||
expect(await service.reconcile([payload(daysFromNow(-100))], fileExists)).toBe(1);
|
||||
expect(rows.get(ATTACHMENT_ID)?.expires_at).toEqual(daysFromNow(265));
|
||||
});
|
||||
|
||||
it('gives an untracked attachment that is already overdue the renew window', async () => {
|
||||
const {rows, service} = createService();
|
||||
|
||||
await service.reconcile([payload(daysFromNow(-1000))], fileExists);
|
||||
|
||||
expect(rows.get(ATTACHMENT_ID)?.expires_at).toEqual(daysFromNow(30));
|
||||
});
|
||||
|
||||
it('gives an attachment whose expiry passed unnoticed the renew window', async () => {
|
||||
const {rows, service} = createService();
|
||||
seed(rows, daysFromNow(-1000), daysFromNow(-200));
|
||||
|
||||
await service.reconcile([payload(daysFromNow(-1000))], fileExists);
|
||||
|
||||
expect(rows.get(ATTACHMENT_ID)?.expires_at).toEqual(daysFromNow(30));
|
||||
});
|
||||
|
||||
it('leaves an untracked attachment alone when its file is gone', async () => {
|
||||
const {rows, service} = createService();
|
||||
|
||||
expect(await service.reconcile([payload(daysFromNow(-100))], fileMissing)).toBe(0);
|
||||
expect(rows.size).toBe(0);
|
||||
});
|
||||
|
||||
it('keeps a passed expiry when the file is gone', async () => {
|
||||
const {rows, service} = createService();
|
||||
seed(rows, daysFromNow(-1000), daysFromNow(-200));
|
||||
|
||||
expect(await service.reconcile([payload(daysFromNow(-1000))], fileMissing)).toBe(0);
|
||||
expect(rows.get(ATTACHMENT_ID)?.expires_at).toEqual(daysFromNow(-200));
|
||||
});
|
||||
|
||||
it('does not look for the file of an attachment that is still pending', async () => {
|
||||
const {rows, service} = createService({...CONFIG, max_lifetime_days: 730});
|
||||
seed(rows, daysFromNow(-100), daysFromNow(265));
|
||||
|
||||
await service.reconcile([payload(daysFromNow(-100))], fileMissing);
|
||||
|
||||
expect(rows.get(ATTACHMENT_ID)?.expires_at).toEqual(daysFromNow(630));
|
||||
});
|
||||
|
||||
it('extends a tracked attachment when the lifetime grows', async () => {
|
||||
const {rows, service} = createService({...CONFIG, max_lifetime_days: 730});
|
||||
seed(rows, daysFromNow(-100), daysFromNow(265));
|
||||
|
||||
await service.reconcile([payload(daysFromNow(-100))], fileExists);
|
||||
|
||||
expect(rows.get(ATTACHMENT_ID)?.expires_at).toEqual(daysFromNow(630));
|
||||
});
|
||||
|
||||
it('shortens a tracked attachment no further than the renew window', async () => {
|
||||
const {rows, service} = createService({...CONFIG, max_lifetime_days: 60});
|
||||
seed(rows, daysFromNow(-100), daysFromNow(265));
|
||||
|
||||
await service.reconcile([payload(daysFromNow(-100))], fileExists);
|
||||
|
||||
expect(rows.get(ATTACHMENT_ID)?.expires_at).toEqual(daysFromNow(30));
|
||||
});
|
||||
|
||||
it('keeps an expiry that already falls inside the renew window', async () => {
|
||||
const {rows, service} = createService({...CONFIG, max_lifetime_days: 60});
|
||||
seed(rows, daysFromNow(-100), daysFromNow(12));
|
||||
|
||||
expect(await service.reconcile([payload(daysFromNow(-100))], fileExists)).toBe(0);
|
||||
expect(rows.get(ATTACHMENT_ID)?.expires_at).toEqual(daysFromNow(12));
|
||||
});
|
||||
|
||||
it('drops the record of an attachment that is no longer eligible', async () => {
|
||||
const {rows, service} = createService({...CONFIG, max_eligible_size_mb: 1});
|
||||
seed(rows, daysFromNow(-100), daysFromNow(265));
|
||||
|
||||
expect(await service.reconcile([payload(daysFromNow(-100), 2n * 1024n * 1024n)], fileExists)).toBe(1);
|
||||
expect(rows.size).toBe(0);
|
||||
});
|
||||
|
||||
it('settles after one pass', async () => {
|
||||
const {service} = createService({...CONFIG, max_lifetime_days: 60});
|
||||
|
||||
expect(await service.reconcile([payload(daysFromNow(-100))], fileExists)).toBe(1);
|
||||
expect(await service.reconcile([payload(daysFromNow(-100))], fileExists)).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
} from '@app/api/utils/AttachmentDecay';
|
||||
import {ms} from 'itty-time';
|
||||
|
||||
interface AttachmentDecayPayload {
|
||||
export interface AttachmentDecayPayload {
|
||||
attachmentId: AttachmentID;
|
||||
channelId: ChannelID;
|
||||
messageId: MessageID;
|
||||
@@ -27,7 +27,7 @@ interface AttachmentDecayPayload {
|
||||
|
||||
type AttachmentDecayConfigResolver = () => Promise<InstanceAttachmentDecayEffectiveConfig>;
|
||||
|
||||
async function resolveDefaultAttachmentDecayConfig(): Promise<InstanceAttachmentDecayEffectiveConfig> {
|
||||
export async function resolveDefaultAttachmentDecayConfig(): Promise<InstanceAttachmentDecayEffectiveConfig> {
|
||||
const {getInstanceConfigRepository} = await import('@app/api/middleware/ServiceSingletons');
|
||||
return getInstanceConfigRepository().getEffectiveAttachmentDecayConfig();
|
||||
}
|
||||
@@ -108,26 +108,85 @@ export class AttachmentDecayService {
|
||||
expiresAt = renewed;
|
||||
}
|
||||
}
|
||||
const lifetimeDays = Math.round((expiresAt.getTime() - uploadedAt.getTime()) / ms('1 day'));
|
||||
const cost = computeCost({
|
||||
sizeBytes: attachment.sizeBytes,
|
||||
lifetimeDays,
|
||||
pricePerTBPerMonth: DEFAULT_DECAY_CONSTANTS.PRICE_PER_TB_PER_MONTH,
|
||||
});
|
||||
await this.repo.upsert({
|
||||
attachment_id: attachment.attachmentId,
|
||||
channel_id: attachment.channelId,
|
||||
message_id: attachment.messageId,
|
||||
filename: attachment.filename,
|
||||
size_bytes: attachment.sizeBytes,
|
||||
uploaded_at: uploadedAt,
|
||||
expires_at: expiresAt,
|
||||
last_accessed_at: now,
|
||||
cost,
|
||||
lifetime_days: lifetimeDays,
|
||||
await this.writeRecord(attachment, {
|
||||
uploadedAt,
|
||||
expiresAt,
|
||||
lastAccessedAt: now,
|
||||
status: existing?.status ?? null,
|
||||
expiry_bucket: getExpiryBucket(expiresAt),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async reconcile(
|
||||
attachments: Array<AttachmentDecayPayload>,
|
||||
fileExists: (attachment: AttachmentDecayPayload) => Promise<boolean>,
|
||||
): Promise<number> {
|
||||
if (attachments.length === 0) return 0;
|
||||
const config = await this.resolveConfig();
|
||||
if (!config.enabled) return 0;
|
||||
const rules = buildDecayRules(config);
|
||||
const existingRecords = await this.repo.fetchByIds(attachments.map((a) => a.attachmentId));
|
||||
const now = new Date();
|
||||
const windowEnd = new Date(now.getTime() + config.renew_window_days * ms('1 day'));
|
||||
const seen = new Set<AttachmentID>();
|
||||
let changed = 0;
|
||||
for (const attachment of attachments) {
|
||||
if (seen.has(attachment.attachmentId)) continue;
|
||||
seen.add(attachment.attachmentId);
|
||||
const existing = existingRecords.get(attachment.attachmentId);
|
||||
const uploadedAt = existing?.uploaded_at ?? attachment.uploadedAt;
|
||||
const decay = computeDecay({sizeBytes: attachment.sizeBytes, uploadedAt, rules});
|
||||
if (!decay) {
|
||||
if (existing) {
|
||||
await this.repo.deleteRecords({
|
||||
expiry_bucket: getExpiryBucket(existing.expires_at),
|
||||
expires_at: existing.expires_at,
|
||||
attachment_id: existing.attachment_id,
|
||||
});
|
||||
changed++;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
const stillPending = existing != null && existing.expires_at.getTime() > now.getTime();
|
||||
if (!stillPending && !(await fileExists(attachment))) continue;
|
||||
const floor =
|
||||
stillPending && existing.expires_at.getTime() < windowEnd.getTime() ? existing.expires_at : windowEnd;
|
||||
const expiresAt = extendExpiry(floor, decay.expiresAt);
|
||||
if (existing && existing.expires_at.getTime() === expiresAt.getTime()) continue;
|
||||
await this.writeRecord(attachment, {
|
||||
uploadedAt,
|
||||
expiresAt,
|
||||
lastAccessedAt: existing?.last_accessed_at ?? uploadedAt,
|
||||
status: existing?.status ?? null,
|
||||
});
|
||||
changed++;
|
||||
}
|
||||
return changed;
|
||||
}
|
||||
|
||||
private async writeRecord(
|
||||
attachment: AttachmentDecayPayload,
|
||||
state: {uploadedAt: Date; expiresAt: Date; lastAccessedAt: Date; status: string | null},
|
||||
): Promise<void> {
|
||||
const lifetimeDays = Math.round((state.expiresAt.getTime() - state.uploadedAt.getTime()) / ms('1 day'));
|
||||
const cost = computeCost({
|
||||
sizeBytes: attachment.sizeBytes,
|
||||
lifetimeDays,
|
||||
pricePerTBPerMonth: DEFAULT_DECAY_CONSTANTS.PRICE_PER_TB_PER_MONTH,
|
||||
});
|
||||
await this.repo.upsert({
|
||||
attachment_id: attachment.attachmentId,
|
||||
channel_id: attachment.channelId,
|
||||
message_id: attachment.messageId,
|
||||
filename: attachment.filename,
|
||||
size_bytes: attachment.sizeBytes,
|
||||
uploaded_at: state.uploadedAt,
|
||||
expires_at: state.expiresAt,
|
||||
last_accessed_at: state.lastAccessedAt,
|
||||
cost,
|
||||
lifetime_days: lifetimeDays,
|
||||
status: state.status,
|
||||
expiry_bucket: getExpiryBucket(state.expiresAt),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {tryDiscoverOidcProviderMetadata} from '@app/api/auth/services/SsoUtils';
|
||||
import {Config} from '@app/api/Config';
|
||||
import {resetSsoRequestUrlPolicyForTesting} from '@app/api/instance/SsoConfigValidation';
|
||||
import {server} from '@app/api/test/msw/server';
|
||||
import {HttpResponse, http} from 'msw';
|
||||
import {afterEach, beforeEach, describe, expect, it} from 'vitest';
|
||||
|
||||
const PRIVATE_ISSUER = 'http://10.20.30.40:5556/dex';
|
||||
|
||||
describe('tryDiscoverOidcProviderMetadata', () => {
|
||||
const previousAllowPrivateAddresses = Config.auth.ssoAllowPrivateAddresses;
|
||||
let discoveryRequests = 0;
|
||||
|
||||
beforeEach(() => {
|
||||
discoveryRequests = 0;
|
||||
server.use(
|
||||
http.get(`${PRIVATE_ISSUER}/.well-known/openid-configuration`, () => {
|
||||
discoveryRequests++;
|
||||
return HttpResponse.json({
|
||||
issuer: PRIVATE_ISSUER,
|
||||
authorization_endpoint: `${PRIVATE_ISSUER}/auth`,
|
||||
token_endpoint: `${PRIVATE_ISSUER}/token`,
|
||||
userinfo_endpoint: `${PRIVATE_ISSUER}/userinfo`,
|
||||
jwks_uri: `${PRIVATE_ISSUER}/keys`,
|
||||
});
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
Config.auth.ssoAllowPrivateAddresses = previousAllowPrivateAddresses;
|
||||
resetSsoRequestUrlPolicyForTesting();
|
||||
});
|
||||
|
||||
it('discovers endpoints on a private issuer when private addresses are allowed', async () => {
|
||||
Config.auth.ssoAllowPrivateAddresses = true;
|
||||
resetSsoRequestUrlPolicyForTesting();
|
||||
await expect(tryDiscoverOidcProviderMetadata(PRIVATE_ISSUER)).resolves.toEqual({
|
||||
issuer: PRIVATE_ISSUER,
|
||||
authorization_endpoint: `${PRIVATE_ISSUER}/auth`,
|
||||
token_endpoint: `${PRIVATE_ISSUER}/token`,
|
||||
userinfo_endpoint: `${PRIVATE_ISSUER}/userinfo`,
|
||||
jwks_uri: `${PRIVATE_ISSUER}/keys`,
|
||||
});
|
||||
expect(discoveryRequests).toBe(1);
|
||||
});
|
||||
|
||||
it('refuses a private issuer when private addresses are not allowed', async () => {
|
||||
Config.auth.ssoAllowPrivateAddresses = false;
|
||||
resetSsoRequestUrlPolicyForTesting();
|
||||
await expect(tryDiscoverOidcProviderMetadata(PRIVATE_ISSUER)).resolves.toBeNull();
|
||||
expect(discoveryRequests).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {getSsoRequestUrlPolicy} from '@app/api/instance/SsoConfigValidation';
|
||||
import {Logger} from '@app/api/Logger';
|
||||
import {EXTERNAL_RESPONSE_LIMITS} from '@app/api/utils/ExternalResponseLimits';
|
||||
import * as FetchUtils from '@app/api/utils/FetchUtils';
|
||||
@@ -68,13 +69,16 @@ function normalizeIssuerForCompare(value: string): string {
|
||||
export async function tryDiscoverOidcProviderMetadata(issuer: string): Promise<DiscoveredOidcProviderMetadata | null> {
|
||||
try {
|
||||
const url = buildDiscoveryUrl(issuer);
|
||||
const resp = await FetchUtils.sendRequest({
|
||||
url: url.toString(),
|
||||
method: 'GET',
|
||||
headers: {Accept: 'application/json'},
|
||||
timeout: ms('10 seconds'),
|
||||
serviceName: 'sso_oidc_discovery',
|
||||
});
|
||||
const resp = await FetchUtils.sendRequest(
|
||||
{
|
||||
url: url.toString(),
|
||||
method: 'GET',
|
||||
headers: {Accept: 'application/json'},
|
||||
timeout: ms('10 seconds'),
|
||||
serviceName: 'sso_oidc_discovery',
|
||||
},
|
||||
{requestUrlPolicy: getSsoRequestUrlPolicy()},
|
||||
);
|
||||
if (resp.status < 200 || resp.status >= 300) {
|
||||
FetchUtils.discardResponseBody(resp.stream, resp.status);
|
||||
return null;
|
||||
|
||||
@@ -249,6 +249,7 @@ export class AttachmentProcessingService {
|
||||
uploadFilename: attachment.upload_filename,
|
||||
filename: attachment.filename,
|
||||
nsfwMode,
|
||||
sizeBytes: uploadedFile.contentLength,
|
||||
});
|
||||
if (metadata) {
|
||||
applyFinalObjectMetadata = true;
|
||||
@@ -406,14 +407,18 @@ export class AttachmentProcessingService {
|
||||
uploadFilename: string;
|
||||
filename: string;
|
||||
nsfwMode: MediaProxyNsfwMode;
|
||||
sizeBytes: number;
|
||||
}): Promise<MediaProxyMetadataResponse | null> {
|
||||
try {
|
||||
const metadata = await this.mediaService.getMetadata({
|
||||
type: 'upload',
|
||||
upload_filename: params.uploadFilename,
|
||||
filename: params.filename,
|
||||
nsfw: params.nsfwMode,
|
||||
});
|
||||
const metadata = await this.mediaService.getMetadata(
|
||||
{
|
||||
type: 'upload',
|
||||
upload_filename: params.uploadFilename,
|
||||
filename: params.filename,
|
||||
nsfw: params.nsfwMode,
|
||||
},
|
||||
{sizeBytes: params.sizeBytes},
|
||||
);
|
||||
if (metadata) {
|
||||
return metadata;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import type {AttachmentDecayPayload} from '@app/api/attachment/AttachmentDecayService';
|
||||
import type {AttachmentID, ChannelID, MessageID, UserID} from '@app/api/BrandedTypes';
|
||||
import {createAttachmentID, userIdToChannelId} from '@app/api/BrandedTypes';
|
||||
import {Config} from '@app/api/Config';
|
||||
@@ -472,6 +473,22 @@ export function collectMessageAttachments(message: Message): Array<Attachment> {
|
||||
return [...message.attachments, ...message.messageSnapshots.flatMap((snapshot) => snapshot.attachments)];
|
||||
}
|
||||
|
||||
export function attachmentDecayPayloadsForMessage(message: Message): Array<AttachmentDecayPayload> {
|
||||
const attachments = collectMessageAttachments(message);
|
||||
if (attachments.length === 0) return [];
|
||||
const ownerMessageId = isCrosspostCopy(message) ? (message.reference?.messageId ?? message.id) : message.id;
|
||||
const uploadedAt = snowflakeToDate(ownerMessageId);
|
||||
const storageChannelId = attachmentStorageChannelId(message);
|
||||
return attachments.map((attachment) => ({
|
||||
attachmentId: attachment.id,
|
||||
channelId: storageChannelId,
|
||||
messageId: ownerMessageId,
|
||||
filename: attachment.filename,
|
||||
sizeBytes: attachment.size,
|
||||
uploadedAt,
|
||||
}));
|
||||
}
|
||||
|
||||
export function countedThreadMessages(channel: Channel, messageIds: Array<MessageID>): number {
|
||||
if (!channel.isThread()) return 0;
|
||||
const starterId = channel.id.toString();
|
||||
|
||||
@@ -5,11 +5,15 @@ import {
|
||||
MESSAGE_BUILD_BATCH_MAX_BYTES,
|
||||
MessageResponseDataService,
|
||||
} from '@app/api/channel/services/message/MessageResponseDataService';
|
||||
import {
|
||||
type InstanceAttachmentDecayEffectiveConfig,
|
||||
InstanceConfigRepository,
|
||||
} from '@app/api/instance/InstanceConfigRepository';
|
||||
import {Message} from '@app/api/models/Message';
|
||||
import {MessageTypes} from '@fluxer/constants/src/ChannelConstants';
|
||||
import type {NatsConnection} from '@nats-io/transport-node';
|
||||
import type {INatsConnectionManager} from '@pkgs/nats/src/INatsConnectionManager';
|
||||
import {describe, expect, it} from 'vitest';
|
||||
import {afterEach, describe, expect, it, vi} from 'vitest';
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
const decoder = new TextDecoder();
|
||||
@@ -129,6 +133,10 @@ function batchSizes(connectionManager: FakeConnectionManager): Array<number> {
|
||||
}
|
||||
|
||||
describe('MessageResponseDataService', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('omits reactions from broadcast message response requests', async () => {
|
||||
const connectionManager = new FakeConnectionManager();
|
||||
const service = new MessageResponseDataService(connectionManager);
|
||||
@@ -161,6 +169,19 @@ describe('MessageResponseDataService', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('tells the shard whether attachment expiry is on', async () => {
|
||||
const connectionManager = new FakeConnectionManager();
|
||||
const service = new MessageResponseDataService(connectionManager);
|
||||
const decayConfig = vi.spyOn(InstanceConfigRepository.prototype, 'getEffectiveAttachmentDecayConfig');
|
||||
|
||||
for (const enabled of [true, false]) {
|
||||
decayConfig.mockResolvedValue({enabled} as InstanceAttachmentDecayEffectiveConfig);
|
||||
await service.buildMessageForChannel({channel: {guildId: null}, message: makeMessage()});
|
||||
}
|
||||
|
||||
expect(connectionManager.payloads.map((payload) => payload.attachment_decay)).toEqual([true, false]);
|
||||
});
|
||||
|
||||
it('waits longer than the router shard timeout so the inner hop expires first', async () => {
|
||||
const connectionManager = new FakeConnectionManager();
|
||||
const service = new MessageResponseDataService(connectionManager);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {resolveDefaultAttachmentDecayConfig} from '@app/api/attachment/AttachmentDecayService';
|
||||
import type {ChannelID, GuildID, MessageID, UserID} from '@app/api/BrandedTypes';
|
||||
import {createUserID} from '@app/api/BrandedTypes';
|
||||
import {Config} from '@app/api/Config';
|
||||
@@ -49,6 +50,10 @@ type MessageServiceResponse =
|
||||
}
|
||||
| 'NotFound';
|
||||
|
||||
async function attachmentDecayEnabled(): Promise<boolean> {
|
||||
return (await resolveDefaultAttachmentDecayConfig()).enabled;
|
||||
}
|
||||
|
||||
function isMessageResponse(value: unknown): value is MessageResponse {
|
||||
return isJsonRecord(value) && typeof value.id === 'string';
|
||||
}
|
||||
@@ -113,6 +118,7 @@ export class MessageResponseDataService {
|
||||
media_endpoint: Config.endpoints.media,
|
||||
media_proxy_secret_key: Config.mediaProxy.secretKey,
|
||||
attachment_url_secret_base64: Config.mediaProxy.attachmentUrls.secretsBase64[0],
|
||||
attachment_decay: await attachmentDecayEnabled(),
|
||||
include_reactions: true,
|
||||
});
|
||||
if (typeof response === 'object' && 'FoundApiMany' in response) {
|
||||
@@ -157,6 +163,7 @@ export class MessageResponseDataService {
|
||||
media_endpoint: Config.endpoints.media,
|
||||
media_proxy_secret_key: Config.mediaProxy.secretKey,
|
||||
attachment_url_secret_base64: Config.mediaProxy.attachmentUrls.secretsBase64[0],
|
||||
attachment_decay: await attachmentDecayEnabled(),
|
||||
include_reactions: true,
|
||||
nonce: params.nonce,
|
||||
tts: params.tts,
|
||||
@@ -189,6 +196,7 @@ export class MessageResponseDataService {
|
||||
media_endpoint: Config.endpoints.media,
|
||||
media_proxy_secret_key: Config.mediaProxy.secretKey,
|
||||
attachment_url_secret_base64: Config.mediaProxy.attachmentUrls.secretsBase64[0],
|
||||
attachment_decay: await attachmentDecayEnabled(),
|
||||
include_reactions: params.includeReactions ?? true,
|
||||
nonce: params.nonce,
|
||||
tts: params.tts,
|
||||
@@ -258,6 +266,7 @@ export class MessageResponseDataService {
|
||||
media_endpoint: Config.endpoints.media,
|
||||
media_proxy_secret_key: Config.mediaProxy.secretKey,
|
||||
attachment_url_secret_base64: Config.mediaProxy.attachmentUrls.secretsBase64[0],
|
||||
attachment_decay: await attachmentDecayEnabled(),
|
||||
include_reactions: params.includeReactions ?? true,
|
||||
});
|
||||
if (typeof response !== 'object' || !('FoundApiMany' in response)) {
|
||||
|
||||
@@ -1,17 +1,13 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {AttachmentDecayService} from '@app/api/attachment/AttachmentDecayService';
|
||||
import type {AttachmentID, ChannelID, MessageID, UserID} from '@app/api/BrandedTypes';
|
||||
import type {ChannelID, MessageID, UserID} from '@app/api/BrandedTypes';
|
||||
import {mapChannelToResponse} from '@app/api/channel/ChannelMappers';
|
||||
import type {ChannelRepository} from '@app/api/channel/ChannelRepository';
|
||||
import type {AuthenticatedChannel} from '@app/api/channel/services/AuthenticatedChannel';
|
||||
import {getDmChannelIdsForScope} from '@app/api/channel/services/message/DmScopeUtils';
|
||||
import type {MessageChannelAuthService} from '@app/api/channel/services/message/MessageChannelAuthService';
|
||||
import {
|
||||
attachmentStorageChannelId,
|
||||
collectMessageAttachments,
|
||||
isCrosspostCopy,
|
||||
} from '@app/api/channel/services/message/MessageHelpers';
|
||||
import {attachmentDecayPayloadsForMessage} from '@app/api/channel/services/message/MessageHelpers';
|
||||
import type {MessageProcessingService} from '@app/api/channel/services/message/MessageProcessingService';
|
||||
import {
|
||||
createMessageResponseDataService,
|
||||
@@ -283,36 +279,11 @@ export class MessageRetrievalService {
|
||||
}
|
||||
|
||||
private async extendAttachments(messages: Array<Message>): Promise<void> {
|
||||
const payloads = messages.flatMap((message) => {
|
||||
return this.buildAttachmentDecayEntriesForMessage(message);
|
||||
});
|
||||
const payloads = messages.flatMap(attachmentDecayPayloadsForMessage);
|
||||
if (payloads.length === 0) return;
|
||||
await this.attachmentDecayService.extendForAttachments(payloads);
|
||||
}
|
||||
|
||||
private buildAttachmentDecayEntriesForMessage(message: Message): Array<{
|
||||
attachmentId: AttachmentID;
|
||||
channelId: ChannelID;
|
||||
messageId: MessageID;
|
||||
filename: string;
|
||||
sizeBytes: bigint;
|
||||
uploadedAt: Date;
|
||||
}> {
|
||||
const attachments = collectMessageAttachments(message);
|
||||
if (attachments.length === 0) return [];
|
||||
const ownerMessageId = isCrosspostCopy(message) ? (message.reference?.messageId ?? message.id) : message.id;
|
||||
const uploadedAt = snowflakeToDate(ownerMessageId);
|
||||
const storageChannelId = attachmentStorageChannelId(message);
|
||||
return attachments.map((attachment) => ({
|
||||
attachmentId: attachment.id,
|
||||
channelId: storageChannelId,
|
||||
messageId: ownerMessageId,
|
||||
filename: attachment.filename,
|
||||
sizeBytes: attachment.size,
|
||||
uploadedAt,
|
||||
}));
|
||||
}
|
||||
|
||||
private async applyDmScopeToSearchParams({
|
||||
userId,
|
||||
channel,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {createTestAccount, setUserACLs, type TestAccount} from '@app/api/auth/tests/AuthTestUtils';
|
||||
import {createTestAccount, type TestAccount} from '@app/api/auth/tests/AuthTestUtils';
|
||||
import {createWebhookID} from '@app/api/BrandedTypes';
|
||||
import {resetApiServicesForTesting} from '@app/api/CreateApiContext';
|
||||
import {
|
||||
@@ -36,11 +36,8 @@ import syncCrosspostCopies from '@app/api/worker/tasks/SyncCrosspostCopies';
|
||||
import syncCrosspostedMessage from '@app/api/worker/tasks/SyncCrosspostedMessage';
|
||||
import {clearWorkerDependencies, setWorkerDependenciesForTest} from '@app/api/worker/WorkerContext';
|
||||
import type {WorkerDependencies} from '@app/api/worker/WorkerDependencies';
|
||||
import {AdminACLs} from '@fluxer/constants/src/AdminACLs';
|
||||
import {ChannelTypes, Permissions} from '@fluxer/constants/src/ChannelConstants';
|
||||
import {ContentWarningLevel} from '@fluxer/constants/src/GuildConstants';
|
||||
import type {LimitKey} from '@fluxer/constants/src/LimitConfigMetadata';
|
||||
import type {LimitConfigSnapshot} from '@fluxer/limits/src/LimitTypes';
|
||||
import type {FollowedChannelResponse} from '@fluxer/schema/src/domains/channel/ChannelFollowSchemas';
|
||||
import type {ChannelResponse} from '@fluxer/schema/src/domains/channel/ChannelSchemas';
|
||||
import type {GuildResponse} from '@fluxer/schema/src/domains/guild/GuildResponseSchemas';
|
||||
@@ -212,30 +209,6 @@ export async function setGuildFeatures(
|
||||
.execute();
|
||||
}
|
||||
|
||||
export async function setLimitOverride(
|
||||
harness: ApiTestHarness,
|
||||
limits: Partial<Record<LimitKey, number>>,
|
||||
): Promise<() => Promise<void>> {
|
||||
const admin = await setUserACLs(harness, await createTestAccount(harness), [
|
||||
AdminACLs.AUTHENTICATE,
|
||||
AdminACLs.INSTANCE_LIMIT_CONFIG_VIEW,
|
||||
AdminACLs.INSTANCE_LIMIT_CONFIG_UPDATE,
|
||||
]);
|
||||
const current = await createBuilder<{limit_config: LimitConfigSnapshot}>(harness, admin.token)
|
||||
.get('/admin/limit-config')
|
||||
.execute();
|
||||
const writeConfig = async (rules: LimitConfigSnapshot['rules']) => {
|
||||
await createBuilder(harness, admin.token)
|
||||
.put('/admin/limit-config')
|
||||
.body({limit_config: {traitDefinitions: current.limit_config.traitDefinitions, rules}})
|
||||
.execute();
|
||||
};
|
||||
await writeConfig([...current.limit_config.rules, {id: 'announcement_test_override', limits}]);
|
||||
return async () => {
|
||||
await writeConfig(current.limit_config.rules);
|
||||
};
|
||||
}
|
||||
|
||||
const crosspostTaskHandlers: Record<string, WorkerTaskHandler> = {
|
||||
crosspostMessage,
|
||||
crosspostMessageChunk,
|
||||
|
||||
@@ -1,17 +1,35 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {createTestAccount} from '@app/api/auth/tests/AuthTestUtils';
|
||||
import {AttachmentDecayRepository} from '@app/api/attachment/AttachmentDecayRepository';
|
||||
import {createTestAccount, setUserACLs, type TestAccount} from '@app/api/auth/tests/AuthTestUtils';
|
||||
import {createAttachmentID, createChannelID, createMessageID} from '@app/api/BrandedTypes';
|
||||
import {Config, getConfig} from '@app/api/Config';
|
||||
import {
|
||||
createChannel,
|
||||
createGuild,
|
||||
loadFixture,
|
||||
sendMessageWithAttachments,
|
||||
} from '@app/api/channel/tests/AttachmentTestUtils';
|
||||
import {
|
||||
getAssetDeletionQueue,
|
||||
getChannelRepository,
|
||||
getInstanceConfigRepository,
|
||||
} from '@app/api/middleware/ServiceSingletons';
|
||||
import {type ApiTestHarness, createApiTestHarness} from '@app/api/test/ApiTestHarness';
|
||||
import {NoopWorkerService} from '@app/api/test/NoopWorkerService';
|
||||
import {HTTP_STATUS} from '@app/api/test/TestConstants';
|
||||
import {createBuilder, createBuilderWithoutAuth} from '@app/api/test/TestRequestBuilder';
|
||||
import {getExpiryBucket} from '@app/api/utils/AttachmentDecay';
|
||||
import {processExpiredAttachments} from '@app/api/worker/tasks/ExpireAttachments';
|
||||
import reconcileAttachmentDecay, {
|
||||
type ReconcileAttachmentDecayPayload,
|
||||
reconcileAttachmentDecayChunk,
|
||||
} from '@app/api/worker/tasks/ReconcileAttachmentDecay';
|
||||
import {clearWorkerDependencies, setWorkerDependenciesForTest} from '@app/api/worker/WorkerContext';
|
||||
import {AdminACLs} from '@fluxer/constants/src/AdminACLs';
|
||||
import {MessageReferenceTypes} from '@fluxer/constants/src/ChannelConstants';
|
||||
import {afterEach, beforeEach, describe, expect, test} from 'vitest';
|
||||
import type {WorkerTaskHelpers} from '@pkgs/worker/src/contracts/WorkerTask';
|
||||
import {afterEach, beforeEach, describe, expect, test, vi} from 'vitest';
|
||||
|
||||
interface AttachmentDecayRow {
|
||||
attachment_id: string;
|
||||
@@ -24,14 +42,325 @@ interface AttachmentDecayRow {
|
||||
status: string | null;
|
||||
}
|
||||
|
||||
interface ListedMessage {
|
||||
id: string;
|
||||
attachments?: Array<{id: string; url?: string | null; expires_at?: string | null}>;
|
||||
}
|
||||
|
||||
describe('Attachment Decay', () => {
|
||||
let harness: ApiTestHarness;
|
||||
const originalSelfHosted = getConfig().instance.selfHosted;
|
||||
beforeEach(async () => {
|
||||
getConfig().instance.selfHosted = true;
|
||||
harness = await createApiTestHarness();
|
||||
});
|
||||
afterEach(async () => {
|
||||
getConfig().instance.selfHosted = originalSelfHosted;
|
||||
vi.restoreAllMocks();
|
||||
clearWorkerDependencies();
|
||||
await harness?.shutdown();
|
||||
});
|
||||
|
||||
async function createInstanceAdmin(): Promise<TestAccount> {
|
||||
return setUserACLs(harness, await createTestAccount(harness), [
|
||||
AdminACLs.AUTHENTICATE,
|
||||
AdminACLs.INSTANCE_CONFIG_VIEW,
|
||||
AdminACLs.INSTANCE_CONFIG_UPDATE,
|
||||
]);
|
||||
}
|
||||
|
||||
async function setAttachmentExpiry(admin: TestAccount, attachmentDecay: Record<string, unknown>): Promise<void> {
|
||||
await createBuilder(harness, admin.token)
|
||||
.patch('/admin/instance/config')
|
||||
.body({media: {attachment_decay: attachmentDecay}})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
}
|
||||
|
||||
async function listedAttachment(token: string, channelId: string, messageId: string) {
|
||||
const messages = await createBuilder<Array<ListedMessage>>(harness, token)
|
||||
.get(`/channels/${channelId}/messages?limit=10`)
|
||||
.execute();
|
||||
return messages.find((message) => message.id === messageId)?.attachments?.[0];
|
||||
}
|
||||
|
||||
async function fetchDecayRow(attachmentId: string): Promise<AttachmentDecayRow | null> {
|
||||
const {row} = await createBuilderWithoutAuth<{row: AttachmentDecayRow | null}>(harness)
|
||||
.get(`/test/attachment-decay/${attachmentId}`)
|
||||
.execute();
|
||||
return row;
|
||||
}
|
||||
|
||||
function installWorkerDependencies(): void {
|
||||
setWorkerDependenciesForTest({
|
||||
assetDeletionQueue: getAssetDeletionQueue(),
|
||||
channelRepository: getChannelRepository(),
|
||||
instanceConfigRepository: getInstanceConfigRepository(),
|
||||
kvClient: harness.kvProvider,
|
||||
storageService: harness.storageService,
|
||||
});
|
||||
}
|
||||
|
||||
async function enableExpiryAndTakeReconcile(admin: TestAccount): Promise<ReconcileAttachmentDecayPayload> {
|
||||
const addJob = vi.spyOn(NoopWorkerService.prototype, 'addJob');
|
||||
await setAttachmentExpiry(admin, {enabled: true});
|
||||
expect(addJob).toHaveBeenCalledTimes(1);
|
||||
const [task, payload, options] = addJob.mock.calls[0] as unknown as [
|
||||
string,
|
||||
ReconcileAttachmentDecayPayload,
|
||||
{jobKey: string},
|
||||
];
|
||||
expect(task).toBe('reconcileAttachmentDecay');
|
||||
expect(payload).toEqual({runId: expect.any(String), chunk: 0});
|
||||
expect(options).toEqual({jobKey: `${payload.runId}:0`});
|
||||
addJob.mockRestore();
|
||||
return payload;
|
||||
}
|
||||
|
||||
async function runReconcile(
|
||||
first: ReconcileAttachmentDecayPayload,
|
||||
limits?: {messagePageSize: number; messagePages: number},
|
||||
): Promise<number> {
|
||||
let chunks = 0;
|
||||
let next: ReconcileAttachmentDecayPayload | null = first;
|
||||
while (next !== null) {
|
||||
next = await reconcileAttachmentDecayChunk(next, limits);
|
||||
chunks++;
|
||||
}
|
||||
return chunks;
|
||||
}
|
||||
|
||||
async function uploadWhileExpiryIsOff(token: string, channelId: string, filename: string) {
|
||||
const {json} = await sendMessageWithAttachments(
|
||||
harness,
|
||||
token,
|
||||
channelId,
|
||||
{content: filename, attachments: [{id: 0, filename}]},
|
||||
[{index: 0, filename, data: loadFixture('yeah.png')}],
|
||||
);
|
||||
return {messageId: json.id, attachmentId: json.attachments![0].id};
|
||||
}
|
||||
|
||||
test('should stop reporting an expiry once attachment expiry is turned off', async () => {
|
||||
const admin = await createInstanceAdmin();
|
||||
const guild = await createGuild(harness, admin.token, 'Expiry Off Guild');
|
||||
const channelId = guild.system_channel_id!;
|
||||
await setAttachmentExpiry(admin, {enabled: true});
|
||||
const {json} = await sendMessageWithAttachments(
|
||||
harness,
|
||||
admin.token,
|
||||
channelId,
|
||||
{content: 'Uploaded while expiry is on', attachments: [{id: 0, filename: 'on.png'}]},
|
||||
[{index: 0, filename: 'on.png', data: loadFixture('yeah.png')}],
|
||||
);
|
||||
expect((await listedAttachment(admin.token, channelId, json.id))?.expires_at).toBeTruthy();
|
||||
|
||||
await setAttachmentExpiry(admin, {enabled: false});
|
||||
|
||||
const listed = await listedAttachment(admin.token, channelId, json.id);
|
||||
expect(listed?.expires_at ?? null).toBeNull();
|
||||
expect(listed?.url).toBeTruthy();
|
||||
expect(await fetchDecayRow(json.attachments![0].id)).not.toBeNull();
|
||||
});
|
||||
test('should apply an expiry to earlier attachments once attachment expiry is turned on', async () => {
|
||||
const admin = await createInstanceAdmin();
|
||||
const guild = await createGuild(harness, admin.token, 'Expiry On Guild');
|
||||
const channelId = guild.system_channel_id!;
|
||||
await setAttachmentExpiry(admin, {enabled: false});
|
||||
const {json} = await sendMessageWithAttachments(
|
||||
harness,
|
||||
admin.token,
|
||||
channelId,
|
||||
{content: 'Uploaded while expiry is off', attachments: [{id: 0, filename: 'off.png'}]},
|
||||
[{index: 0, filename: 'off.png', data: loadFixture('yeah.png')}],
|
||||
);
|
||||
const attachmentId = json.attachments![0].id;
|
||||
expect(await fetchDecayRow(attachmentId)).toBeNull();
|
||||
|
||||
const reconcile = await enableExpiryAndTakeReconcile(admin);
|
||||
installWorkerDependencies();
|
||||
expect(await runReconcile(reconcile)).toBe(1);
|
||||
const row = await fetchDecayRow(attachmentId);
|
||||
expect(row?.channel_id).toBe(channelId);
|
||||
expect(row?.message_id).toBe(json.id);
|
||||
expect(new Date(row!.expires_at).getTime()).toBeGreaterThan(Date.now());
|
||||
});
|
||||
test('should only queue a reconcile when the expiry rules change', async () => {
|
||||
const admin = await createInstanceAdmin();
|
||||
await setAttachmentExpiry(admin, {enabled: true, max_lifetime_days: 400});
|
||||
const addJob = vi.spyOn(NoopWorkerService.prototype, 'addJob');
|
||||
|
||||
await setAttachmentExpiry(admin, {enabled: true, max_lifetime_days: 400, renew_window_days: 10});
|
||||
expect(addJob).not.toHaveBeenCalled();
|
||||
|
||||
await setAttachmentExpiry(admin, {max_lifetime_days: 200});
|
||||
expect(addJob).toHaveBeenCalledTimes(1);
|
||||
|
||||
await setAttachmentExpiry(admin, {enabled: false});
|
||||
expect(addJob).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
test('should not queue a reconcile on the hosted instance', async () => {
|
||||
const admin = await createInstanceAdmin();
|
||||
await setAttachmentExpiry(admin, {enabled: false});
|
||||
getConfig().instance.selfHosted = false;
|
||||
const addJob = vi.spyOn(NoopWorkerService.prototype, 'addJob');
|
||||
|
||||
await setAttachmentExpiry(admin, {enabled: true});
|
||||
await setAttachmentExpiry(admin, {max_lifetime_days: 200});
|
||||
|
||||
expect(addJob).not.toHaveBeenCalled();
|
||||
expect(await harness.kvProvider.exists('attachment_decay:reconcile_run')).toBe(0);
|
||||
});
|
||||
test('should not run a queued reconcile job on the hosted instance', async () => {
|
||||
const admin = await createInstanceAdmin();
|
||||
const guild = await createGuild(harness, admin.token, 'Hosted Guild');
|
||||
await setAttachmentExpiry(admin, {enabled: false});
|
||||
const upload = await uploadWhileExpiryIsOff(admin.token, guild.system_channel_id!, 'hosted.png');
|
||||
const reconcile = await enableExpiryAndTakeReconcile(admin);
|
||||
installWorkerDependencies();
|
||||
getConfig().instance.selfHosted = false;
|
||||
const addJob = vi.fn();
|
||||
|
||||
await reconcileAttachmentDecay({...reconcile, __jobId: '1'}, {addJob} as unknown as WorkerTaskHelpers);
|
||||
|
||||
expect(addJob).not.toHaveBeenCalled();
|
||||
expect(await fetchDecayRow(upload.attachmentId)).toBeNull();
|
||||
getConfig().instance.selfHosted = true;
|
||||
expect(await runReconcile(reconcile)).toBe(1);
|
||||
expect(await fetchDecayRow(upload.attachmentId)).not.toBeNull();
|
||||
});
|
||||
test('should reconcile in resumable chunks', async () => {
|
||||
const admin = await createInstanceAdmin();
|
||||
const guild = await createGuild(harness, admin.token, 'Chunked Guild');
|
||||
const second = await createChannel(harness, admin.token, guild.id, 'second');
|
||||
await setAttachmentExpiry(admin, {enabled: false});
|
||||
const uploads = [
|
||||
await uploadWhileExpiryIsOff(admin.token, guild.system_channel_id!, 'a.png'),
|
||||
await uploadWhileExpiryIsOff(admin.token, guild.system_channel_id!, 'b.png'),
|
||||
await uploadWhileExpiryIsOff(admin.token, guild.system_channel_id!, 'c.png'),
|
||||
await uploadWhileExpiryIsOff(admin.token, second.id, 'd.png'),
|
||||
await uploadWhileExpiryIsOff(admin.token, second.id, 'e.png'),
|
||||
];
|
||||
const reconcile = await enableExpiryAndTakeReconcile(admin);
|
||||
installWorkerDependencies();
|
||||
|
||||
const next = await reconcileAttachmentDecayChunk(reconcile, {messagePageSize: 1, messagePages: 1});
|
||||
|
||||
expect(next).toMatchObject({runId: reconcile.runId, chunk: 1, cursor: {beforeMessageId: expect.any(String)}});
|
||||
const tracked = await Promise.all(uploads.map((upload) => fetchDecayRow(upload.attachmentId)));
|
||||
expect(tracked.filter((row) => row !== null)).toHaveLength(1);
|
||||
expect(await runReconcile(next!, {messagePageSize: 1, messagePages: 1})).toBeGreaterThan(3);
|
||||
for (const upload of uploads) {
|
||||
expect((await fetchDecayRow(upload.attachmentId))?.message_id).toBe(upload.messageId);
|
||||
}
|
||||
});
|
||||
test('should run a queued reconcile job to the end', async () => {
|
||||
const admin = await createInstanceAdmin();
|
||||
const guild = await createGuild(harness, admin.token, 'Queued Job Guild');
|
||||
await setAttachmentExpiry(admin, {enabled: false});
|
||||
const upload = await uploadWhileExpiryIsOff(admin.token, guild.system_channel_id!, 'queued.png');
|
||||
const reconcile = await enableExpiryAndTakeReconcile(admin);
|
||||
installWorkerDependencies();
|
||||
const addJob = vi.fn();
|
||||
|
||||
await reconcileAttachmentDecay({...reconcile, __jobId: '1'}, {addJob} as unknown as WorkerTaskHelpers);
|
||||
|
||||
expect(addJob).not.toHaveBeenCalled();
|
||||
expect(await fetchDecayRow(upload.attachmentId)).not.toBeNull();
|
||||
expect(await harness.kvProvider.exists('attachment_decay:reconcile_run')).toBe(0);
|
||||
});
|
||||
test('should stop a reconcile once a newer one starts', async () => {
|
||||
const admin = await createInstanceAdmin();
|
||||
const guild = await createGuild(harness, admin.token, 'Superseded Guild');
|
||||
await setAttachmentExpiry(admin, {enabled: false});
|
||||
const upload = await uploadWhileExpiryIsOff(admin.token, guild.system_channel_id!, 'old.png');
|
||||
const first = await enableExpiryAndTakeReconcile(admin);
|
||||
const addJob = vi.spyOn(NoopWorkerService.prototype, 'addJob');
|
||||
await setAttachmentExpiry(admin, {max_lifetime_days: 200});
|
||||
expect(addJob).toHaveBeenCalledTimes(1);
|
||||
installWorkerDependencies();
|
||||
|
||||
expect(await reconcileAttachmentDecayChunk(first)).toBeNull();
|
||||
|
||||
expect(await fetchDecayRow(upload.attachmentId)).toBeNull();
|
||||
});
|
||||
test('should read the stored settings instead of a stale cache', async () => {
|
||||
const admin = await createInstanceAdmin();
|
||||
const guild = await createGuild(harness, admin.token, 'Stale Cache Guild');
|
||||
await setAttachmentExpiry(admin, {enabled: false});
|
||||
const upload = await uploadWhileExpiryIsOff(admin.token, guild.system_channel_id!, 'stale.png');
|
||||
const reconcile = await enableExpiryAndTakeReconcile(admin);
|
||||
installWorkerDependencies();
|
||||
const current = await getInstanceConfigRepository().getEffectiveAttachmentDecayConfig();
|
||||
vi.spyOn(getInstanceConfigRepository(), 'getEffectiveAttachmentDecayConfig').mockResolvedValue({
|
||||
...current,
|
||||
enabled: false,
|
||||
});
|
||||
|
||||
await runReconcile(reconcile);
|
||||
|
||||
expect(await fetchDecayRow(upload.attachmentId)).not.toBeNull();
|
||||
});
|
||||
test('should not give a new expiry to an attachment whose file is gone', async () => {
|
||||
const admin = await createInstanceAdmin();
|
||||
const guild = await createGuild(harness, admin.token, 'Purged Guild');
|
||||
const channelId = guild.system_channel_id!;
|
||||
await setAttachmentExpiry(admin, {enabled: false});
|
||||
const purged = await uploadWhileExpiryIsOff(admin.token, channelId, 'purged.png');
|
||||
const kept = await uploadWhileExpiryIsOff(admin.token, channelId, 'kept.png');
|
||||
await harness.storageService.deleteObject(
|
||||
Config.s3.buckets.cdn,
|
||||
`attachments/${channelId}/${purged.attachmentId}/purged.png`,
|
||||
);
|
||||
const reconcile = await enableExpiryAndTakeReconcile(admin);
|
||||
installWorkerDependencies();
|
||||
|
||||
await runReconcile(reconcile);
|
||||
|
||||
expect(await fetchDecayRow(purged.attachmentId)).toBeNull();
|
||||
expect(await fetchDecayRow(kept.attachmentId)).not.toBeNull();
|
||||
});
|
||||
test('should not delete expired attachments while a reconcile is running', async () => {
|
||||
const admin = await createInstanceAdmin();
|
||||
const guild = await createGuild(harness, admin.token, 'Paused Expiry Guild');
|
||||
const channelId = guild.system_channel_id!;
|
||||
await setAttachmentExpiry(admin, {enabled: true});
|
||||
const {json} = await sendMessageWithAttachments(
|
||||
harness,
|
||||
admin.token,
|
||||
channelId,
|
||||
{content: 'Lapsed while expiry was off', attachments: [{id: 0, filename: 'lapsed.png'}]},
|
||||
[{index: 0, filename: 'lapsed.png', data: loadFixture('yeah.png')}],
|
||||
);
|
||||
const attachmentId = json.attachments![0].id;
|
||||
await setAttachmentExpiry(admin, {enabled: false});
|
||||
const expiresAt = new Date(Date.now() - 60_000);
|
||||
await new AttachmentDecayRepository().upsert({
|
||||
attachment_id: createAttachmentID(BigInt(attachmentId)),
|
||||
channel_id: createChannelID(BigInt(channelId)),
|
||||
message_id: createMessageID(BigInt(json.id)),
|
||||
filename: 'lapsed.png',
|
||||
size_bytes: 1024n,
|
||||
uploaded_at: expiresAt,
|
||||
expires_at: expiresAt,
|
||||
last_accessed_at: expiresAt,
|
||||
cost: 1,
|
||||
lifetime_days: 1,
|
||||
status: null,
|
||||
expiry_bucket: getExpiryBucket(expiresAt),
|
||||
});
|
||||
const reconcile = await enableExpiryAndTakeReconcile(admin);
|
||||
installWorkerDependencies();
|
||||
|
||||
await processExpiredAttachments();
|
||||
|
||||
expect(new Date((await fetchDecayRow(attachmentId))!.expires_at).getTime()).toBeLessThan(Date.now());
|
||||
await runReconcile(reconcile);
|
||||
await processExpiredAttachments();
|
||||
expect(new Date((await fetchDecayRow(attachmentId))!.expires_at).getTime()).toBeGreaterThan(Date.now());
|
||||
const key = `attachments/${channelId}/${attachmentId}/lapsed.png`;
|
||||
expect(harness.storageService.hasObject(Config.s3.buckets.cdn, key)).toBe(true);
|
||||
});
|
||||
test('should create decay metadata when uploading attachment', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, account.token, 'Decay Test Guild');
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {
|
||||
addMemberRole,
|
||||
createChannel,
|
||||
createPermissionOverwrite,
|
||||
createRole,
|
||||
setupTestGuildWithMembers,
|
||||
} from '@app/api/channel/tests/ChannelTestUtils';
|
||||
import {type ApiTestHarness, createApiTestHarness} from '@app/api/test/ApiTestHarness';
|
||||
import {setLimitOverride} from '@app/api/test/LimitTestUtils';
|
||||
import {NoopGatewayService} from '@app/api/test/NoopGatewayService';
|
||||
import {HTTP_STATUS} from '@app/api/test/TestConstants';
|
||||
import {createBuilder} from '@app/api/test/TestRequestBuilder';
|
||||
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
|
||||
import {ChannelTypes, Permissions} from '@fluxer/constants/src/ChannelConstants';
|
||||
import type {ChannelResponse} from '@fluxer/schema/src/domains/channel/ChannelSchemas';
|
||||
import {afterEach, beforeAll, beforeEach, describe, expect, it, vi} from 'vitest';
|
||||
|
||||
describe('Channel create in a category', () => {
|
||||
let harness: ApiTestHarness;
|
||||
beforeAll(async () => {
|
||||
harness = await createApiTestHarness();
|
||||
});
|
||||
beforeEach(async () => {
|
||||
await harness.reset();
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
async function setupCategoryManager(extraAllow = 0n) {
|
||||
const {owner, members, guild} = await setupTestGuildWithMembers(harness, 1);
|
||||
const member = members[0];
|
||||
const category = await createChannel(harness, owner.token, guild.id, 'managed', ChannelTypes.GUILD_CATEGORY);
|
||||
const otherCategory = await createChannel(harness, owner.token, guild.id, 'other', ChannelTypes.GUILD_CATEGORY);
|
||||
const role = await createRole(harness, owner.token, guild.id, {name: 'creators'});
|
||||
await addMemberRole(harness, owner.token, guild.id, member.userId, role.id);
|
||||
await createPermissionOverwrite(harness, owner.token, category.id, role.id, {
|
||||
type: 0,
|
||||
allow: (Permissions.VIEW_CHANNEL | Permissions.MANAGE_CHANNELS | extraAllow).toString(),
|
||||
deny: '0',
|
||||
});
|
||||
return {owner, member, guild, role, category, otherCategory};
|
||||
}
|
||||
|
||||
it('lets a category overwrite grant MANAGE_CHANNELS for that category only', async () => {
|
||||
const {member, guild, category, otherCategory} = await setupCategoryManager();
|
||||
const created = await createBuilder<ChannelResponse>(harness, member.token)
|
||||
.post(`/guilds/${guild.id}/channels`)
|
||||
.body({name: 'member-made', type: ChannelTypes.GUILD_TEXT, parent_id: category.id})
|
||||
.execute();
|
||||
expect(created.parent_id).toBe(category.id);
|
||||
await createBuilder(harness, member.token)
|
||||
.post(`/guilds/${guild.id}/channels`)
|
||||
.body({name: 'root-made', type: ChannelTypes.GUILD_TEXT})
|
||||
.expect(HTTP_STATUS.FORBIDDEN, APIErrorCodes.MISSING_PERMISSIONS)
|
||||
.execute();
|
||||
await createBuilder(harness, member.token)
|
||||
.post(`/guilds/${guild.id}/channels`)
|
||||
.body({name: 'elsewhere', type: ChannelTypes.GUILD_TEXT, parent_id: otherCategory.id})
|
||||
.expect(HTTP_STATUS.FORBIDDEN, APIErrorCodes.MISSING_PERMISSIONS)
|
||||
.execute();
|
||||
});
|
||||
|
||||
it('honors a category deny of MANAGE_CHANNELS for a guild-wide manager', async () => {
|
||||
const {owner, members, guild} = await setupTestGuildWithMembers(harness, 1);
|
||||
const member = members[0];
|
||||
const category = await createChannel(harness, owner.token, guild.id, 'locked', ChannelTypes.GUILD_CATEGORY);
|
||||
const role = await createRole(harness, owner.token, guild.id, {
|
||||
name: 'managers',
|
||||
permissions: Permissions.MANAGE_CHANNELS.toString(),
|
||||
});
|
||||
await addMemberRole(harness, owner.token, guild.id, member.userId, role.id);
|
||||
await createPermissionOverwrite(harness, owner.token, category.id, role.id, {
|
||||
type: 0,
|
||||
allow: '0',
|
||||
deny: Permissions.MANAGE_CHANNELS.toString(),
|
||||
});
|
||||
await createBuilder(harness, member.token)
|
||||
.post(`/guilds/${guild.id}/channels`)
|
||||
.body({name: 'denied', type: ChannelTypes.GUILD_TEXT, parent_id: category.id})
|
||||
.expect(HTTP_STATUS.FORBIDDEN, APIErrorCodes.MISSING_PERMISSIONS)
|
||||
.execute();
|
||||
});
|
||||
|
||||
it('lets a category overwrite grant MANAGE_ROLES for requested overwrites within its own permissions', async () => {
|
||||
const {member, guild, role, category} = await setupCategoryManager(Permissions.MANAGE_ROLES);
|
||||
await createBuilder<ChannelResponse>(harness, member.token)
|
||||
.post(`/guilds/${guild.id}/channels`)
|
||||
.body({
|
||||
name: 'with-overwrites',
|
||||
type: ChannelTypes.GUILD_TEXT,
|
||||
parent_id: category.id,
|
||||
permission_overwrites: [{id: role.id, type: 0, allow: Permissions.VIEW_CHANNEL.toString(), deny: '0'}],
|
||||
})
|
||||
.execute();
|
||||
await createBuilder(harness, member.token)
|
||||
.post(`/guilds/${guild.id}/channels`)
|
||||
.body({
|
||||
name: 'escalate',
|
||||
type: ChannelTypes.GUILD_TEXT,
|
||||
parent_id: category.id,
|
||||
permission_overwrites: [{id: role.id, type: 0, allow: Permissions.ADMINISTRATOR.toString(), deny: '0'}],
|
||||
})
|
||||
.expect(HTTP_STATUS.FORBIDDEN, APIErrorCodes.MISSING_PERMISSIONS)
|
||||
.execute();
|
||||
});
|
||||
|
||||
it('rejects requested overwrites without MANAGE_ROLES on the category', async () => {
|
||||
const {member, guild, role, category} = await setupCategoryManager();
|
||||
await createBuilder(harness, member.token)
|
||||
.post(`/guilds/${guild.id}/channels`)
|
||||
.body({
|
||||
name: 'no-roles',
|
||||
type: ChannelTypes.GUILD_TEXT,
|
||||
parent_id: category.id,
|
||||
permission_overwrites: [{id: role.id, type: 0, allow: Permissions.VIEW_CHANNEL.toString(), deny: '0'}],
|
||||
})
|
||||
.expect(HTTP_STATUS.FORBIDDEN, APIErrorCodes.MISSING_PERMISSIONS)
|
||||
.execute();
|
||||
});
|
||||
|
||||
it('applies a configured max_channels_per_category on create', async () => {
|
||||
const {owner, guild} = await setupTestGuildWithMembers(harness, 0);
|
||||
const category = await createChannel(harness, owner.token, guild.id, 'full', ChannelTypes.GUILD_CATEGORY);
|
||||
const restore = await setLimitOverride(harness, {max_channels_per_category: 0});
|
||||
try {
|
||||
await createBuilder(harness, owner.token)
|
||||
.post(`/guilds/${guild.id}/channels`)
|
||||
.body({name: 'overflow', type: ChannelTypes.GUILD_TEXT, parent_id: category.id})
|
||||
.expect(HTTP_STATUS.BAD_REQUEST, APIErrorCodes.MAX_CATEGORY_CHANNELS)
|
||||
.execute();
|
||||
await createChannel(harness, owner.token, guild.id, 'at-root');
|
||||
} finally {
|
||||
await restore();
|
||||
}
|
||||
});
|
||||
|
||||
it('applies a configured max_guild_channels on create', async () => {
|
||||
const {owner, guild} = await setupTestGuildWithMembers(harness, 0);
|
||||
const restore = await setLimitOverride(harness, {max_guild_channels: 0});
|
||||
try {
|
||||
await createBuilder(harness, owner.token)
|
||||
.post(`/guilds/${guild.id}/channels`)
|
||||
.body({name: 'overflow', type: ChannelTypes.GUILD_TEXT})
|
||||
.expect(HTTP_STATUS.BAD_REQUEST, APIErrorCodes.MAX_GUILD_CHANNELS)
|
||||
.execute();
|
||||
} finally {
|
||||
await restore();
|
||||
}
|
||||
});
|
||||
|
||||
it('lets a raised max_channels_per_category admit a channel past the default', async () => {
|
||||
const {owner, guild} = await setupTestGuildWithMembers(harness, 0);
|
||||
const category = await createChannel(harness, owner.token, guild.id, 'busy', ChannelTypes.GUILD_CATEGORY);
|
||||
vi.spyOn(NoopGatewayService.prototype, 'getCategoryChannelCount').mockResolvedValue(50);
|
||||
await createBuilder(harness, owner.token)
|
||||
.post(`/guilds/${guild.id}/channels`)
|
||||
.body({name: 'fifty-first', type: ChannelTypes.GUILD_TEXT, parent_id: category.id})
|
||||
.expect(HTTP_STATUS.BAD_REQUEST, APIErrorCodes.MAX_CATEGORY_CHANNELS)
|
||||
.execute();
|
||||
const restore = await setLimitOverride(harness, {max_channels_per_category: 500});
|
||||
try {
|
||||
const created = await createBuilder<ChannelResponse>(harness, owner.token)
|
||||
.post(`/guilds/${guild.id}/channels`)
|
||||
.body({name: 'fifty-first', type: ChannelTypes.GUILD_TEXT, parent_id: category.id})
|
||||
.execute();
|
||||
expect(created.parent_id).toBe(category.id);
|
||||
} finally {
|
||||
await restore();
|
||||
}
|
||||
});
|
||||
|
||||
it('lets a raised max_guild_channels admit a channel past the default', async () => {
|
||||
const {owner, guild} = await setupTestGuildWithMembers(harness, 0);
|
||||
vi.spyOn(NoopGatewayService.prototype, 'getChannelCount').mockResolvedValue(500);
|
||||
await createBuilder(harness, owner.token)
|
||||
.post(`/guilds/${guild.id}/channels`)
|
||||
.body({name: 'one-more', type: ChannelTypes.GUILD_TEXT})
|
||||
.expect(HTTP_STATUS.BAD_REQUEST, APIErrorCodes.MAX_GUILD_CHANNELS)
|
||||
.execute();
|
||||
const restore = await setLimitOverride(harness, {max_guild_channels: 1000});
|
||||
try {
|
||||
await createChannel(harness, owner.token, guild.id, 'one-more');
|
||||
} finally {
|
||||
await restore();
|
||||
}
|
||||
});
|
||||
|
||||
it('applies a configured max_channels_per_category when a channel is moved into a category', async () => {
|
||||
const {owner, guild} = await setupTestGuildWithMembers(harness, 0);
|
||||
const category = await createChannel(harness, owner.token, guild.id, 'target', ChannelTypes.GUILD_CATEGORY);
|
||||
const channel = await createChannel(harness, owner.token, guild.id, 'mover');
|
||||
const move = () =>
|
||||
createBuilder(harness, owner.token)
|
||||
.patch(`/guilds/${guild.id}/channels`)
|
||||
.body([{id: channel.id, parent_id: category.id}]);
|
||||
const restoreLowered = await setLimitOverride(harness, {max_channels_per_category: 0});
|
||||
try {
|
||||
await move().expect(HTTP_STATUS.BAD_REQUEST, APIErrorCodes.MAX_CATEGORY_CHANNELS).execute();
|
||||
} finally {
|
||||
await restoreLowered();
|
||||
}
|
||||
vi.spyOn(NoopGatewayService.prototype, 'getCategoryChannelCount').mockResolvedValue(50);
|
||||
await move().expect(HTTP_STATUS.BAD_REQUEST, APIErrorCodes.MAX_CATEGORY_CHANNELS).execute();
|
||||
const restoreRaised = await setLimitOverride(harness, {max_channels_per_category: 500});
|
||||
try {
|
||||
await move().expect(HTTP_STATUS.NO_CONTENT).execute();
|
||||
} finally {
|
||||
await restoreRaised();
|
||||
}
|
||||
const moved = await createBuilder<ChannelResponse>(harness, owner.token).get(`/channels/${channel.id}`).execute();
|
||||
expect(moved.parent_id).toBe(category.id);
|
||||
});
|
||||
});
|
||||
@@ -713,6 +713,7 @@ describe('Crosspost propagation', () => {
|
||||
queued.push(item);
|
||||
},
|
||||
} as unknown as IAssetDeletionQueue,
|
||||
kvClient: harness.kvProvider,
|
||||
instanceConfigRepository: {
|
||||
async getEffectiveAttachmentDecayConfig() {
|
||||
return {enabled: true};
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import type {WorkerTaskName} from '@app/api/worker/WorkerLaneConfig';
|
||||
import type {CachePurgeAdapterName, StoreProductSlotName} from '@fluxer/config/src/MasterConfig';
|
||||
import type {AccountIdentityMode, TagStyle} from '@fluxer/constants/src/AccountIdentityConstants';
|
||||
import type {SmtpTlsMode} from '@fluxer/constants/src/SmtpConstants';
|
||||
|
||||
export type APIWorkerMode = 'all_lanes' | 'single_lane' | 'single_task';
|
||||
export type APIWorkerLaneName = 'realtime' | 'unfurl' | 'lifecycle' | 'batch' | 'crosspost';
|
||||
@@ -163,7 +164,7 @@ export interface APIConfig {
|
||||
port: number;
|
||||
username: string;
|
||||
password: string;
|
||||
secure: boolean;
|
||||
tlsMode: SmtpTlsMode | null;
|
||||
};
|
||||
};
|
||||
blocklistFeeds: {
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import {GuildFeatures} from '@fluxer/constants/src/GuildConstants';
|
||||
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 {DEFAULT_RESTRICTED_LIMITS, DEFAULT_STOCK_LIMITS} from '@fluxer/limits/src/LimitDefaults';
|
||||
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 interface LimitBuildOptions {
|
||||
selfHosted?: boolean;
|
||||
premiumMode?: 'mirror' | 'everyone';
|
||||
}
|
||||
|
||||
export interface StoredLimitConfig extends LimitConfigSnapshot {
|
||||
overridesOnly?: boolean;
|
||||
}
|
||||
|
||||
function cloneLimitConfigSnapshot(config: LimitConfigSnapshot): LimitConfigSnapshot {
|
||||
return structuredClone(config);
|
||||
}
|
||||
|
||||
export function sanitizeLimitConfigForInstance(
|
||||
config: LimitConfigSnapshot,
|
||||
options?: {
|
||||
selfHosted?: boolean;
|
||||
premiumMode?: 'mirror' | 'everyone';
|
||||
},
|
||||
options?: LimitBuildOptions,
|
||||
): LimitConfigSnapshot {
|
||||
const selfHosted = options?.selfHosted ?? false;
|
||||
const premiumMode = options?.premiumMode ?? 'everyone';
|
||||
@@ -51,10 +57,7 @@ export function sanitizeLimitConfigForInstance(
|
||||
};
|
||||
}
|
||||
|
||||
export function createDefaultLimitConfig(options?: {
|
||||
selfHosted?: boolean;
|
||||
premiumMode?: 'mirror' | 'everyone';
|
||||
}): LimitConfigSnapshot {
|
||||
export function createDefaultLimitConfig(options?: LimitBuildOptions): LimitConfigSnapshot {
|
||||
const selfHosted = options?.selfHosted ?? false;
|
||||
const premiumMode = options?.premiumMode ?? 'everyone';
|
||||
const useTiers = !selfHosted || premiumMode === 'mirror';
|
||||
@@ -94,10 +97,7 @@ export function createDefaultLimitConfig(options?: {
|
||||
|
||||
export function mergeWithCurrentDefaults(
|
||||
stored: LimitConfigSnapshot,
|
||||
options?: {
|
||||
selfHosted?: boolean;
|
||||
premiumMode?: 'mirror' | 'everyone';
|
||||
},
|
||||
options?: LimitBuildOptions,
|
||||
): LimitConfigSnapshot {
|
||||
const selfHosted = options?.selfHosted ?? false;
|
||||
const premiumMode = options?.premiumMode ?? 'everyone';
|
||||
@@ -107,7 +107,7 @@ export function mergeWithCurrentDefaults(
|
||||
const mergedRules: Array<LimitRule> = [];
|
||||
const existingRulesMap = new Map<string, LimitRule>();
|
||||
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) {
|
||||
const existingRule = existingRulesMap.get(defaultRule.id);
|
||||
@@ -138,11 +138,136 @@ export function mergeWithCurrentDefaults(
|
||||
});
|
||||
}
|
||||
return {
|
||||
traitDefinitions: stored.traitDefinitions,
|
||||
traitDefinitions: [...new Set([...newDefaults.traitDefinitions, ...stored.traitDefinitions])],
|
||||
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(
|
||||
currentLimits: Partial<Record<LimitKey, number>>,
|
||||
defaultLimits: Partial<Record<LimitKey, number>>,
|
||||
|
||||
@@ -16,7 +16,7 @@ export function createEmailProvider(emailConfig: APIConfig['email']): IEmailProv
|
||||
port: emailConfig.smtp.port,
|
||||
username: emailConfig.smtp.username,
|
||||
password: emailConfig.smtp.password,
|
||||
secure: emailConfig.smtp.secure,
|
||||
tlsMode: emailConfig.smtp.tlsMode,
|
||||
})
|
||||
: null;
|
||||
default:
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import type {ChannelID, GuildID, UserID} from '@app/api/BrandedTypes';
|
||||
import {type ChannelID, createChannelID, type GuildID, type UserID} from '@app/api/BrandedTypes';
|
||||
import {mapChannelToResponse} from '@app/api/channel/ChannelMappers';
|
||||
import type {ChannelRepository} from '@app/api/channel/ChannelRepository';
|
||||
import {withThreadParentFieldsMany} from '@app/api/channel/services/thread/ThreadParentSettings';
|
||||
@@ -112,6 +112,7 @@ export class GuildChannelService {
|
||||
userId: params.userId,
|
||||
guildId: params.guildId,
|
||||
permission: Permissions.MANAGE_CHANNELS,
|
||||
channelId: params.data.parent_id ? createChannelID(params.data.parent_id) : undefined,
|
||||
});
|
||||
const response = await this.channelOps.createChannel(params, auditLogReason);
|
||||
const [masked] = await maskChannelResponseThreadBits(params.guildId, params.viewer, [response]);
|
||||
@@ -155,11 +156,17 @@ export class GuildChannelService {
|
||||
await this.channelOps.sanitizeTextChannelNames(params);
|
||||
}
|
||||
|
||||
private async checkPermission(params: {userId: UserID; guildId: GuildID; permission: bigint}): Promise<void> {
|
||||
private async checkPermission(params: {
|
||||
userId: UserID;
|
||||
guildId: GuildID;
|
||||
permission: bigint;
|
||||
channelId?: ChannelID;
|
||||
}): Promise<void> {
|
||||
const hasPermission = await this.gatewayService.checkPermission({
|
||||
guildId: params.guildId,
|
||||
userId: params.userId,
|
||||
permission: params.permission,
|
||||
channelId: params.channelId,
|
||||
});
|
||||
if (!hasPermission) throw new MissingPermissionsError();
|
||||
const guildData = await this.gatewayService.getGuildData({guildId: params.guildId, userId: params.userId});
|
||||
|
||||
@@ -104,6 +104,7 @@ export class ChannelOperationsService {
|
||||
guildId: params.guildId,
|
||||
userId: params.userId,
|
||||
permission: Permissions.MANAGE_ROLES,
|
||||
channelId: parentId ?? undefined,
|
||||
});
|
||||
if (!canManageRoles) throw new MissingPermissionsError();
|
||||
const basePermissions = await this.gatewayService.getUserPermissions({
|
||||
@@ -736,6 +737,7 @@ export class ChannelOperationsService {
|
||||
ctx,
|
||||
'max_channels_per_category',
|
||||
maxChannels,
|
||||
'guild',
|
||||
);
|
||||
if (count >= maxChannels) {
|
||||
throw new MaxCategoryChannelsError(maxChannels);
|
||||
@@ -747,7 +749,13 @@ export class ChannelOperationsService {
|
||||
let maxChannels = MAX_GUILD_CHANNELS;
|
||||
const guild = await this.guildRepository.findUnique(guildId);
|
||||
const ctx = createLimitMatchContext({user: null, guildFeatures: guild?.features ?? null});
|
||||
maxChannels = resolveLimitSafe(this.limitConfigService.getConfigSnapshot(), ctx, 'max_guild_channels', maxChannels);
|
||||
maxChannels = resolveLimitSafe(
|
||||
this.limitConfigService.getConfigSnapshot(),
|
||||
ctx,
|
||||
'max_guild_channels',
|
||||
maxChannels,
|
||||
'guild',
|
||||
);
|
||||
if (count >= maxChannels) {
|
||||
throw new MaxGuildChannelsError(maxChannels);
|
||||
}
|
||||
|
||||
@@ -95,13 +95,13 @@ export class AvatarService {
|
||||
return null;
|
||||
}
|
||||
const base64Data = base64Image.includes(',') ? base64Image.split(',')[1] : base64Image;
|
||||
const imageBuffer = new Uint8Array(Buffer.from(base64Data, 'base64'));
|
||||
const maxAvatarSize = this.resolveSizeLimit('avatar_max_size', AVATAR_MAX_SIZE);
|
||||
if (imageBuffer.length > maxAvatarSize) {
|
||||
if (Buffer.byteLength(base64Data, 'base64') > maxAvatarSize) {
|
||||
throw InputValidationError.fromCode(errorPath, ValidationErrorCodes.IMAGE_SIZE_EXCEEDS_LIMIT, {
|
||||
maxSize: maxAvatarSize,
|
||||
});
|
||||
}
|
||||
const imageBuffer = new Uint8Array(Buffer.from(base64Data, 'base64'));
|
||||
const kind = this.prefixToKind(prefix);
|
||||
const metadata = this.requireAllowedMetadata({
|
||||
metadata: await this.mediaService.getMetadata({
|
||||
@@ -143,13 +143,13 @@ export class AvatarService {
|
||||
}> {
|
||||
const {errorPath, base64Image, guildFeatures} = params;
|
||||
const base64Data = base64Image.includes(',') ? base64Image.split(',')[1] : base64Image;
|
||||
const imageBuffer = new Uint8Array(Buffer.from(base64Data, 'base64'));
|
||||
const maxEmojiSize = this.resolveSizeLimit('emoji_max_size', EMOJI_MAX_SIZE, guildFeatures);
|
||||
if (imageBuffer.length > maxEmojiSize) {
|
||||
if (Buffer.byteLength(base64Data, 'base64') > maxEmojiSize) {
|
||||
throw InputValidationError.fromCode(errorPath, ValidationErrorCodes.IMAGE_SIZE_EXCEEDS_LIMIT, {
|
||||
maxSize: maxEmojiSize,
|
||||
});
|
||||
}
|
||||
const imageBuffer = new Uint8Array(Buffer.from(base64Data, 'base64'));
|
||||
const metadata = this.requireAllowedMetadata({
|
||||
metadata: await this.mediaService.getMetadata({
|
||||
type: 'base64',
|
||||
@@ -244,13 +244,13 @@ export class AvatarService {
|
||||
}> {
|
||||
const {errorPath, base64Image, guildFeatures} = params;
|
||||
const base64Data = base64Image.includes(',') ? base64Image.split(',')[1] : base64Image;
|
||||
const imageBuffer = new Uint8Array(Buffer.from(base64Data, 'base64'));
|
||||
const maxStickerSize = this.resolveSizeLimit('sticker_max_size', STICKER_MAX_SIZE, guildFeatures);
|
||||
if (imageBuffer.length > maxStickerSize) {
|
||||
if (Buffer.byteLength(base64Data, 'base64') > maxStickerSize) {
|
||||
throw InputValidationError.fromCode(errorPath, ValidationErrorCodes.IMAGE_SIZE_EXCEEDS_LIMIT, {
|
||||
maxSize: maxStickerSize,
|
||||
});
|
||||
}
|
||||
const imageBuffer = new Uint8Array(Buffer.from(base64Data, 'base64'));
|
||||
const metadata = this.requireAllowedMetadata({
|
||||
metadata: await this.mediaService.getMetadata({
|
||||
type: 'base64',
|
||||
|
||||
@@ -291,13 +291,13 @@ export class EntityAssetService {
|
||||
animated: boolean;
|
||||
}> {
|
||||
const base64Data = base64Image.includes(',') ? base64Image.split(',')[1] : base64Image;
|
||||
const imageBuffer = new Uint8Array(Buffer.from(base64Data, 'base64'));
|
||||
const maxAvatarSize = this.resolveAvatarSizeLimit();
|
||||
if (imageBuffer.length > maxAvatarSize) {
|
||||
if (Buffer.byteLength(base64Data, 'base64') > maxAvatarSize) {
|
||||
throw InputValidationError.fromCode(errorPath, ValidationErrorCodes.IMAGE_SIZE_EXCEEDS_LIMIT, {
|
||||
maxSize: maxAvatarSize,
|
||||
});
|
||||
}
|
||||
const imageBuffer = new Uint8Array(Buffer.from(base64Data, 'base64'));
|
||||
const kind = this.mapAssetTypeToAssetKind(assetType);
|
||||
const metadata = this.requireAllowedMetadata({
|
||||
metadata: await this.mediaService.getMetadata({
|
||||
|
||||
@@ -42,6 +42,10 @@ export type MediaProxyMetadataRequest =
|
||||
| MediaProxyMetadataBase64Request
|
||||
| MediaProxyMetadataS3Request;
|
||||
|
||||
export interface MediaProxyMetadataOptions {
|
||||
sizeBytes?: number;
|
||||
}
|
||||
|
||||
export interface MediaProxyMetadataResponse {
|
||||
format: string;
|
||||
content_type: string;
|
||||
@@ -83,7 +87,10 @@ export interface MediaProxyFrameResponse {
|
||||
}
|
||||
|
||||
export abstract class IMediaService {
|
||||
abstract getMetadata(request: MediaProxyMetadataRequest): Promise<MediaProxyMetadataResponse | null>;
|
||||
abstract getMetadata(
|
||||
request: MediaProxyMetadataRequest,
|
||||
options?: MediaProxyMetadataOptions,
|
||||
): Promise<MediaProxyMetadataResponse | null>;
|
||||
|
||||
abstract getExternalMediaProxyURL(url: string): string;
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
MEDIA_PROXY_METADATA_REQUEST_VERSION,
|
||||
type MediaProxyFrameRequest,
|
||||
type MediaProxyFrameResponse,
|
||||
type MediaProxyMetadataOptions,
|
||||
type MediaProxyMetadataRequest,
|
||||
type MediaProxyMetadataResponse,
|
||||
type MediaProxyNsfwMode,
|
||||
@@ -36,6 +37,17 @@ const MEDIA_PROXY_FRAMES_MAX_BYTES = 512 * 1024;
|
||||
const MEDIA_PROXY_SNIFF_MAX_BYTES = 1024;
|
||||
const MEDIA_PROXY_REQUEST_TIMEOUT_MS = ms('30 seconds');
|
||||
const MEDIA_PROXY_SNIFF_TIMEOUT_MS = ms('2 seconds');
|
||||
const MEDIA_PROXY_BUFFERED_INPUT_MAX_BYTES = 500 * 1024 * 1024;
|
||||
const MEDIA_PROXY_SPOOLED_INPUT_BYTES_PER_SECOND = 16 * 1024 * 1024;
|
||||
|
||||
export function metadataRequestTimeoutMs(sizeBytes: number | undefined): number {
|
||||
if (sizeBytes === undefined || sizeBytes <= MEDIA_PROXY_BUFFERED_INPUT_MAX_BYTES) {
|
||||
return MEDIA_PROXY_REQUEST_TIMEOUT_MS;
|
||||
}
|
||||
return (
|
||||
MEDIA_PROXY_REQUEST_TIMEOUT_MS + Math.ceil(sizeBytes / MEDIA_PROXY_SPOOLED_INPUT_BYTES_PER_SECOND) * ms('1 second')
|
||||
);
|
||||
}
|
||||
|
||||
function isMediaProxySniffResponse(value: unknown): value is MediaProxySniffResponse {
|
||||
return isJsonRecord(value) && (value.content_type === null || typeof value.content_type === 'string');
|
||||
@@ -105,9 +117,16 @@ export class MediaService extends IMediaService {
|
||||
this.proxyURL = new URL(Config.endpoints.media);
|
||||
}
|
||||
|
||||
async getMetadata(request: MediaProxyMetadataRequest): Promise<MediaProxyMetadataResponse | null> {
|
||||
async getMetadata(
|
||||
request: MediaProxyMetadataRequest,
|
||||
options: MediaProxyMetadataOptions = {},
|
||||
): Promise<MediaProxyMetadataResponse | null> {
|
||||
const nsfwMode = this.getNsfwMode(request);
|
||||
const response = await this.makeRequest('/_metadata', this.toMetadataWireRequest(request, nsfwMode));
|
||||
const response = await this.makeRequest(
|
||||
'/_metadata',
|
||||
this.toMetadataWireRequest(request, nsfwMode),
|
||||
metadataRequestTimeoutMs(options.sizeBytes),
|
||||
);
|
||||
if (!response) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {metadataRequestTimeoutMs} from '@app/api/infrastructure/MediaService';
|
||||
import {describe, expect, it} from 'vitest';
|
||||
|
||||
const MIB = 1024 * 1024;
|
||||
|
||||
describe('metadataRequestTimeoutMs', () => {
|
||||
it.each([[undefined], [0], [100 * MIB], [500 * MIB]])(
|
||||
'keeps the 30 second window for an input of %s bytes that media-proxy buffers',
|
||||
(sizeBytes) => {
|
||||
expect(metadataRequestTimeoutMs(sizeBytes)).toBe(30_000);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
[500 * MIB + 1, 62_000],
|
||||
[700 * MIB, 74_000],
|
||||
[2 * 1024 * MIB, 158_000],
|
||||
[8 * 1024 * MIB, 542_000],
|
||||
])('widens the window for an input of %s bytes that media-proxy copies to disk first', (sizeBytes, timeoutMs) => {
|
||||
expect(metadataRequestTimeoutMs(sizeBytes)).toBe(timeoutMs);
|
||||
});
|
||||
});
|
||||
@@ -228,15 +228,59 @@ async function stripMetadataWithExiftool(data: Uint8Array, extension: string): P
|
||||
}
|
||||
}
|
||||
|
||||
async function stripVideoMetadataInPlace(inputPath: string, outputPath: string): Promise<void> {
|
||||
await execFilePromise('ffmpeg', [
|
||||
function ffmpegMuxerForContentType(contentType: string): string | null {
|
||||
if (contentType.includes('3gpp')) return '3gp';
|
||||
if (contentType.includes('mp4')) return 'mp4';
|
||||
if (contentType.includes('webm')) return 'webm';
|
||||
if (contentType.includes('quicktime')) return 'mov';
|
||||
if (contentType.includes('x-matroska')) return 'matroska';
|
||||
if (contentType.includes('x-msvideo')) return 'avi';
|
||||
if (contentType.includes('x-flv')) return 'flv';
|
||||
if (contentType.includes('mp2t')) return 'mpegts';
|
||||
if (contentType.includes('mpeg')) return contentType.startsWith('audio/') ? 'mp3' : 'mpeg';
|
||||
if (contentType.includes('x-ms-wmv')) return 'asf';
|
||||
if (contentType.includes('wav')) return 'wav';
|
||||
if (contentType.includes('flac')) return 'flac';
|
||||
if (contentType.includes('aac')) return 'adts';
|
||||
if (contentType.includes('aiff')) return 'aiff';
|
||||
if (contentType.includes('ogg')) return 'ogg';
|
||||
return null;
|
||||
}
|
||||
|
||||
const FFMPEG_FAST_START_MUXERS = new Set(['mp4', 'mov', '3gp']);
|
||||
const FFMPEG_DEMUXER_FOR_MUXER: Readonly<Record<string, string>> = {
|
||||
'3gp': 'mp4',
|
||||
mov: 'mp4',
|
||||
webm: 'matroska',
|
||||
adts: 'aac',
|
||||
};
|
||||
const FFMPEG_STRIP_TIMEOUT_MS = 10 * 60 * 1000;
|
||||
const FFMPEG_MAX_BUFFER_BYTES = 8 * 1024 * 1024;
|
||||
const FFPROBE_TIMEOUT_MS = 30 * 1000;
|
||||
const FFPROBE_MAX_BUFFER_BYTES = 64 * 1024;
|
||||
|
||||
function requireFfmpegMuxer(contentType: string): string {
|
||||
const muxer = ffmpegMuxerForContentType(contentType);
|
||||
if (!muxer) {
|
||||
throw new Error(`No ffmpeg output format for content type ${contentType}`);
|
||||
}
|
||||
return muxer;
|
||||
}
|
||||
|
||||
export function ffmpegMetadataStripArgs(inputPath: string, outputPath: string, contentType: string): Array<string> {
|
||||
const muxer = requireFfmpegMuxer(contentType);
|
||||
return [
|
||||
'-hide_banner',
|
||||
'-loglevel',
|
||||
'warning',
|
||||
'error',
|
||||
'-i',
|
||||
inputPath,
|
||||
'-map',
|
||||
'0',
|
||||
'0:v?',
|
||||
'-map',
|
||||
'0:a?',
|
||||
'-map',
|
||||
'0:s?',
|
||||
'-map_metadata',
|
||||
'-1',
|
||||
'-map_metadata:s',
|
||||
@@ -247,9 +291,29 @@ async function stripVideoMetadataInPlace(inputPath: string, outputPath: string):
|
||||
'-1',
|
||||
'-c',
|
||||
'copy',
|
||||
...(FFMPEG_FAST_START_MUXERS.has(muxer) ? ['-movflags', '+faststart'] : []),
|
||||
'-f',
|
||||
muxer,
|
||||
'-y',
|
||||
outputPath,
|
||||
]);
|
||||
];
|
||||
}
|
||||
|
||||
export function ffmpegContainerMatchesContentType(formatNames: string, contentType: string): boolean {
|
||||
const muxer = requireFfmpegMuxer(contentType);
|
||||
return formatNames
|
||||
.trim()
|
||||
.split(',')
|
||||
.includes(FFMPEG_DEMUXER_FOR_MUXER[muxer] ?? muxer);
|
||||
}
|
||||
|
||||
async function probeFfmpegContainer(inputPath: string): Promise<string> {
|
||||
const {stdout} = await execFilePromise(
|
||||
'ffprobe',
|
||||
['-v', 'error', '-show_entries', 'format=format_name', '-of', 'default=nw=1:nk=1', inputPath],
|
||||
{timeout: FFPROBE_TIMEOUT_MS, maxBuffer: FFPROBE_MAX_BUFFER_BYTES},
|
||||
);
|
||||
return stdout;
|
||||
}
|
||||
|
||||
async function cleanupTempFiles(paths: ReadonlyArray<string>): Promise<
|
||||
@@ -392,8 +456,16 @@ async function stripVideoFileToFile(
|
||||
outputPath: string,
|
||||
contentType: string,
|
||||
): Promise<{contentType: string; width?: number; height?: number}> {
|
||||
await stripVideoMetadataInPlace(inputPath, outputPath);
|
||||
return {contentType: normalizeContentType(contentType)};
|
||||
const normalizedContentType = normalizeContentType(contentType);
|
||||
const container = await probeFfmpegContainer(inputPath);
|
||||
if (!ffmpegContainerMatchesContentType(container, normalizedContentType)) {
|
||||
throw new Error(`Container ${container.trim()} does not match content type ${normalizedContentType}`);
|
||||
}
|
||||
await execFilePromise('ffmpeg', ffmpegMetadataStripArgs(inputPath, outputPath, normalizedContentType), {
|
||||
timeout: FFMPEG_STRIP_TIMEOUT_MS,
|
||||
maxBuffer: FFMPEG_MAX_BUFFER_BYTES,
|
||||
});
|
||||
return {contentType: normalizedContentType};
|
||||
}
|
||||
|
||||
export async function processMediaFile(
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {execFileSync, spawnSync} from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import {
|
||||
ffmpegContainerMatchesContentType,
|
||||
ffmpegMetadataStripArgs,
|
||||
processMediaFile,
|
||||
} from '@app/api/infrastructure/StorageObjectHelpers';
|
||||
import {temporaryFile} from 'tempy';
|
||||
import {afterEach, describe, expect, it} from 'vitest';
|
||||
|
||||
const INPUT_PATH = '/tmp/0123456789abcdef';
|
||||
const OUTPUT_PATH = '/tmp/fedcba9876543210';
|
||||
|
||||
function outputFormat(contentType: string): string | undefined {
|
||||
const args = ffmpegMetadataStripArgs(INPUT_PATH, OUTPUT_PATH, contentType);
|
||||
const flag = args.indexOf('-f');
|
||||
return flag === -1 ? undefined : args[flag + 1];
|
||||
}
|
||||
|
||||
describe('ffmpegMetadataStripArgs', () => {
|
||||
it.each([
|
||||
['video/mp4', 'mp4'],
|
||||
['video/quicktime', 'mov'],
|
||||
['video/3gpp', '3gp'],
|
||||
['video/webm', 'webm'],
|
||||
['video/x-matroska', 'matroska'],
|
||||
['video/x-msvideo', 'avi'],
|
||||
['video/x-flv', 'flv'],
|
||||
['video/mp2t', 'mpegts'],
|
||||
['video/mpeg', 'mpeg'],
|
||||
['video/x-ms-wmv', 'asf'],
|
||||
['video/ogg', 'ogg'],
|
||||
['audio/mpeg', 'mp3'],
|
||||
['audio/mp4', 'mp4'],
|
||||
['audio/ogg', 'ogg'],
|
||||
['audio/webm', 'webm'],
|
||||
['audio/wav', 'wav'],
|
||||
['audio/flac', 'flac'],
|
||||
['audio/aac', 'adts'],
|
||||
['audio/aiff', 'aiff'],
|
||||
])('names the %s output format because the output path has no extension', (contentType, muxer) => {
|
||||
expect(outputFormat(contentType)).toBe(muxer);
|
||||
});
|
||||
|
||||
it('writes to the output path last and reads the input path', () => {
|
||||
const args = ffmpegMetadataStripArgs(INPUT_PATH, OUTPUT_PATH, 'video/mp4');
|
||||
expect(args[args.indexOf('-i') + 1]).toBe(INPUT_PATH);
|
||||
expect(args.at(-1)).toBe(OUTPUT_PATH);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['video/mp4', true],
|
||||
['video/quicktime', true],
|
||||
['video/webm', false],
|
||||
['audio/mpeg', false],
|
||||
])('moves the %s index to the front of the file: %s', (contentType, fastStart) => {
|
||||
const args = ffmpegMetadataStripArgs(INPUT_PATH, OUTPUT_PATH, contentType);
|
||||
expect(args.includes('+faststart')).toBe(fastStart);
|
||||
});
|
||||
|
||||
it('refuses a container it has no output format for', () => {
|
||||
expect(() => ffmpegMetadataStripArgs(INPUT_PATH, OUTPUT_PATH, 'video/x-unknown-container')).toThrow(
|
||||
/output format/,
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps video, audio and subtitle streams and leaves data streams behind', () => {
|
||||
const args = ffmpegMetadataStripArgs(INPUT_PATH, OUTPUT_PATH, 'video/quicktime');
|
||||
const maps = args.flatMap((arg, index) => (arg === '-map' ? [args[index + 1]] : []));
|
||||
expect(maps).toEqual(['0:v?', '0:a?', '0:s?']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ffmpegContainerMatchesContentType', () => {
|
||||
it.each([
|
||||
['mov,mp4,m4a,3gp,3g2,mj2\n', 'video/mp4'],
|
||||
['mov,mp4,m4a,3gp,3g2,mj2\n', 'video/quicktime'],
|
||||
['mov,mp4,m4a,3gp,3g2,mj2\n', 'video/3gpp'],
|
||||
['mov,mp4,m4a,3gp,3g2,mj2\n', 'audio/mp4'],
|
||||
['matroska,webm\n', 'video/webm'],
|
||||
['matroska,webm\n', 'video/x-matroska'],
|
||||
['matroska,webm\n', 'audio/webm'],
|
||||
['avi\n', 'video/x-msvideo'],
|
||||
['flv\n', 'video/x-flv'],
|
||||
['mpegts\n', 'video/mp2t'],
|
||||
['mpeg\n', 'video/mpeg'],
|
||||
['asf\n', 'video/x-ms-wmv'],
|
||||
['ogg\n', 'video/ogg'],
|
||||
['mp3\n', 'audio/mpeg'],
|
||||
['ogg\n', 'audio/ogg'],
|
||||
['wav\n', 'audio/wav'],
|
||||
['flac\n', 'audio/flac'],
|
||||
['aac\n', 'audio/aac'],
|
||||
['aiff\n', 'audio/aiff'],
|
||||
])('accepts the %s container for %s', (formatNames, contentType) => {
|
||||
expect(ffmpegContainerMatchesContentType(formatNames, contentType)).toBe(true);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['mov,mp4,m4a,3gp,3g2,mj2\n', 'video/x-matroska'],
|
||||
['matroska,webm\n', 'video/mp4'],
|
||||
['mp3\n', 'video/mpeg'],
|
||||
['mpeg\n', 'audio/mpeg'],
|
||||
['mpegts\n', 'video/mpeg'],
|
||||
['wav\n', 'audio/flac'],
|
||||
])('refuses the %s container for %s', (formatNames, contentType) => {
|
||||
expect(ffmpegContainerMatchesContentType(formatNames, contentType)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
const hasFfmpeg = ['ffmpeg', 'ffprobe'].every((tool) => spawnSync(tool, ['-version']).status === 0);
|
||||
|
||||
describe.skipIf(!hasFfmpeg)('processMediaFile with ffmpeg', () => {
|
||||
const paths: Array<string> = [];
|
||||
|
||||
function tempPath(): string {
|
||||
const path = temporaryFile();
|
||||
paths.push(path);
|
||||
return path;
|
||||
}
|
||||
|
||||
function generate(args: ReadonlyArray<string>): string {
|
||||
const path = tempPath();
|
||||
execFileSync('ffmpeg', [
|
||||
'-loglevel',
|
||||
'error',
|
||||
'-f',
|
||||
'lavfi',
|
||||
'-i',
|
||||
'testsrc=s=64x48:r=5',
|
||||
'-f',
|
||||
'lavfi',
|
||||
'-i',
|
||||
'sine=f=440',
|
||||
'-t',
|
||||
'1',
|
||||
'-c:v',
|
||||
'mpeg4',
|
||||
'-c:a',
|
||||
'aac',
|
||||
'-metadata',
|
||||
'title=private-title',
|
||||
...args,
|
||||
'-y',
|
||||
path,
|
||||
]);
|
||||
return path;
|
||||
}
|
||||
|
||||
function probe(path: string): {
|
||||
format: {format_name: string; tags?: Record<string, string>};
|
||||
streams: Array<{codec_type: string}>;
|
||||
} {
|
||||
return JSON.parse(
|
||||
execFileSync('ffprobe', ['-v', 'error', '-show_format', '-show_streams', '-of', 'json', path]).toString(),
|
||||
);
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
for (const path of paths.splice(0)) {
|
||||
fs.rmSync(path, {force: true});
|
||||
}
|
||||
});
|
||||
|
||||
it('rewrites a QuickTime file without its tags and data stream at paths that have no extension', async () => {
|
||||
const input = generate(['-timecode', '01:00:00:00', '-f', 'mov']);
|
||||
expect(probe(input).format.tags?.title).toBe('private-title');
|
||||
expect(probe(input).streams.map((stream) => stream.codec_type)).toContain('data');
|
||||
const output = tempPath();
|
||||
|
||||
const processed = await processMediaFile(input, output, 'video/quicktime');
|
||||
|
||||
const stripped = probe(output);
|
||||
expect(processed?.contentType).toBe('video/quicktime');
|
||||
expect(processed?.contentLength).toBe(fs.statSync(output).size);
|
||||
expect(stripped.format.format_name).toContain('mov');
|
||||
expect(stripped.format.tags?.title).toBeUndefined();
|
||||
expect(stripped.streams.map((stream) => stream.codec_type).sort()).toEqual(['audio', 'video']);
|
||||
});
|
||||
|
||||
it('refuses a file whose container is not the one its content type names', async () => {
|
||||
const input = generate(['-f', 'matroska']);
|
||||
|
||||
await expect(processMediaFile(input, tempPath(), 'video/mp4')).rejects.toThrow(/does not match content type/);
|
||||
});
|
||||
});
|
||||
@@ -219,6 +219,33 @@ describe('InstanceConfigRepository', () => {
|
||||
expect(config.branding.product_name).toBe('Kept');
|
||||
});
|
||||
|
||||
it('serves the theme colour as six-digit hex and drops a stored value that is not hex', async () => {
|
||||
setCassandraQueryExecutorForTesting(new InMemoryCassandraQueryExecutor());
|
||||
const repository = createRepository(new MockKVProvider());
|
||||
|
||||
expect((await repository.setAppPublicConfig({branding: {theme_color: '7B2CE4'}})).branding.theme_color).toBe(
|
||||
'#7b2ce4',
|
||||
);
|
||||
expect((await repository.setAppPublicConfig({branding: {theme_color: '#AbC'}})).branding.theme_color).toBe(
|
||||
'#aabbcc',
|
||||
);
|
||||
|
||||
await repository.setConfig(APP_PUBLIC_CONFIG_KEY, JSON.stringify({branding: {theme_color: 'rgb(123, 44, 228)'}}));
|
||||
expect((await repository.getAppPublicConfig()).branding.theme_color).toBeNull();
|
||||
});
|
||||
|
||||
it('treats the black theme colour the setup wizard used to store as unset', async () => {
|
||||
setCassandraQueryExecutorForTesting(new InMemoryCassandraQueryExecutor());
|
||||
const repository = createRepository(new MockKVProvider());
|
||||
|
||||
await repository.setConfig(APP_PUBLIC_CONFIG_KEY, JSON.stringify({branding: {theme_color: '#000000'}}));
|
||||
expect((await repository.getAppPublicConfig()).branding.theme_color).toBeNull();
|
||||
expect((await repository.setAppPublicConfig({branding: {theme_color: '#000'}})).branding.theme_color).toBeNull();
|
||||
expect((await repository.setAppPublicConfig({branding: {theme_color: '#000001'}})).branding.theme_color).toBe(
|
||||
'#000001',
|
||||
);
|
||||
});
|
||||
|
||||
it('stores uploaded branding assets as references and resolves them against the current media endpoint', async () => {
|
||||
setCassandraQueryExecutorForTesting(new CountingInMemoryCassandraQueryExecutor());
|
||||
const repository = createRepository(new MockKVProvider());
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import crypto from 'node:crypto';
|
||||
import {Config} from '@app/api/Config';
|
||||
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 {Db, type PreparedQuery} from '@app/api/database/CassandraTypes';
|
||||
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 {normalizeSsoAllowedEmailDomains} from '@app/api/instance/SsoConfigValidation';
|
||||
import {Logger} from '@app/api/Logger';
|
||||
import {isLimitConfigSnapshot} from '@app/api/limits/LimitConfigValidation';
|
||||
import {isStoredLimitConfig} from '@app/api/limits/LimitConfigValidation';
|
||||
import {
|
||||
getEffectiveBillingConfig,
|
||||
isBillingActive,
|
||||
@@ -50,12 +51,13 @@ import {
|
||||
TagStyles,
|
||||
} from '@fluxer/constants/src/AccountIdentityConstants';
|
||||
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
|
||||
import {resolveSmtpTlsMode, type SmtpTlsMode, smtpTlsModeFromSecure} from '@fluxer/constants/src/SmtpConstants';
|
||||
import {ConflictError} from '@fluxer/errors/src/domains/core/ConflictError';
|
||||
import {ServiceUnavailableError} from '@fluxer/errors/src/domains/core/ServiceUnavailableError';
|
||||
import type {LimitConfigSnapshot} from '@fluxer/limits/src/LimitTypes';
|
||||
import {
|
||||
InstanceConfigResponse,
|
||||
InstanceConfigUpdateRequest,
|
||||
type InstanceEmailSmtpTestRequest,
|
||||
type PendingRegistrationResponse,
|
||||
type RegistrationUrlResponse,
|
||||
} from '@fluxer/schema/src/domains/admin/AdminSchemas';
|
||||
@@ -198,7 +200,7 @@ export interface InstanceBillingAdminConfig {
|
||||
export type InstancePremiumMode = 'mirror' | 'everyone';
|
||||
|
||||
interface LimitConfigInputs {
|
||||
config: LimitConfigSnapshot | null;
|
||||
config: StoredLimitConfig | null;
|
||||
premiumMode: InstancePremiumMode;
|
||||
}
|
||||
|
||||
@@ -229,6 +231,7 @@ interface InstanceEmailSmtpIntegrationConfig {
|
||||
port: number | null;
|
||||
username: string | null;
|
||||
password: string | null;
|
||||
tls_mode: SmtpTlsMode | null;
|
||||
secure: boolean | null;
|
||||
}
|
||||
|
||||
@@ -290,7 +293,8 @@ interface InstanceIntegrationsAdminConfig {
|
||||
port: number | null;
|
||||
username: string | null;
|
||||
password_set: boolean;
|
||||
secure: boolean | null;
|
||||
tls_mode: SmtpTlsMode | null;
|
||||
effective_tls_mode: SmtpTlsMode | null;
|
||||
};
|
||||
disable_new_ip_authorization: boolean;
|
||||
effective_disable_new_ip_authorization: boolean;
|
||||
@@ -345,7 +349,7 @@ interface InstanceIntegrationsConfigPatch {
|
||||
gif?: Partial<InstanceGifIntegrationConfig>;
|
||||
youtube?: Partial<InstanceYoutubeIntegrationConfig>;
|
||||
email?: Partial<Omit<InstanceEmailIntegrationConfig, 'smtp'>> & {
|
||||
smtp?: Partial<InstanceEmailSmtpIntegrationConfig>;
|
||||
smtp?: Partial<Omit<InstanceEmailSmtpIntegrationConfig, 'secure'>>;
|
||||
};
|
||||
bluesky?: Partial<Omit<InstanceBlueskyIntegrationConfig, 'keys'>> & {
|
||||
keys?: Array<Partial<InstanceBlueskyKeyIntegrationConfig>>;
|
||||
@@ -382,7 +386,7 @@ const FETCH_LIMIT_CONFIG_INPUTS_QUERY = InstanceConfiguration.selectCql({
|
||||
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;
|
||||
let parsed: unknown;
|
||||
try {
|
||||
@@ -391,7 +395,7 @@ function parseStoredLimitConfig(raw: string | null): LimitConfigSnapshot | null
|
||||
Logger.error({error}, 'Stored limit configuration is not valid JSON, falling back to default limits');
|
||||
return null;
|
||||
}
|
||||
if (!isLimitConfigSnapshot(parsed)) {
|
||||
if (!isStoredLimitConfig(parsed)) {
|
||||
Logger.error('Stored limit configuration has an invalid shape, falling back to default limits');
|
||||
return null;
|
||||
}
|
||||
@@ -406,6 +410,14 @@ function normalizeOptionalPublicString(value: string | null | undefined, fallbac
|
||||
return value === undefined ? fallback : normalizeOptionalString(value);
|
||||
}
|
||||
|
||||
function normalizeThemeColor(value: unknown): string | null {
|
||||
const match = /^#?([0-9a-f]{3}|[0-9a-f]{6})$/i.exec(normalizeOptionalString(value) ?? '');
|
||||
if (match === null) return null;
|
||||
const digits = match[1].toLowerCase();
|
||||
const color = `#${digits.length === 3 ? digits.replace(/./g, '$&$&') : digits}`;
|
||||
return color === '#000000' ? null : color;
|
||||
}
|
||||
|
||||
function getDefaultPremiumProductName(): string {
|
||||
return Config.instance.selfHosted ? 'Premium' : 'Plutonium';
|
||||
}
|
||||
@@ -419,7 +431,7 @@ function getDefaultAppPublicConfig(): InstanceAppPublicConfig {
|
||||
logo_url: normalizeOptionalString(Config.instance.branding.logoUrl),
|
||||
wordmark_url: normalizeOptionalString(Config.instance.branding.wordmarkUrl),
|
||||
favicon_url: normalizeOptionalString(Config.instance.branding.faviconUrl),
|
||||
theme_color: normalizeOptionalString(Config.instance.branding.themeColor),
|
||||
theme_color: normalizeThemeColor(Config.instance.branding.themeColor),
|
||||
status_page_url: normalizeOptionalString(Config.instance.branding.statusPageUrl),
|
||||
status_page_incident_history_url: normalizeOptionalString(Config.instance.branding.statusPageIncidentHistoryUrl),
|
||||
premium_product_name: getDefaultPremiumProductName(),
|
||||
@@ -736,7 +748,8 @@ function buildAppPublicConfig(config: z.infer<typeof StoredInstanceAppPublicSche
|
||||
logo_url: normalizeOptionalPublicString(branding.logo_url, defaults.branding.logo_url),
|
||||
wordmark_url: normalizeOptionalPublicString(branding.wordmark_url, defaults.branding.wordmark_url),
|
||||
favicon_url: normalizeOptionalPublicString(branding.favicon_url, defaults.branding.favicon_url),
|
||||
theme_color: normalizeOptionalPublicString(branding.theme_color, defaults.branding.theme_color),
|
||||
theme_color:
|
||||
branding.theme_color === undefined ? defaults.branding.theme_color : normalizeThemeColor(branding.theme_color),
|
||||
status_page_url: normalizeOptionalPublicString(branding.status_page_url, defaults.branding.status_page_url),
|
||||
status_page_incident_history_url: normalizeOptionalPublicString(
|
||||
branding.status_page_incident_history_url,
|
||||
@@ -826,6 +839,7 @@ const StoredInstanceIntegrationsSchema = z.object({
|
||||
port: SmtpIntegrationUpdateSchema.shape.port.default(null),
|
||||
username: StoredIntegrationStringSchema,
|
||||
password: StoredIntegrationStringSchema,
|
||||
tls_mode: SmtpIntegrationUpdateSchema.shape.tls_mode.default(null),
|
||||
secure: StoredNullableBooleanSchema,
|
||||
})
|
||||
.prefault({}),
|
||||
@@ -950,19 +964,67 @@ function decodeInstanceMediaConfig(value: unknown): InstanceMediaConfig {
|
||||
return validateStoredConfig(StoredInstanceMediaSchema, value, 'media');
|
||||
}
|
||||
|
||||
function resolveEffectiveAttachmentDecayConfig(media: InstanceMediaConfig): InstanceAttachmentDecayEffectiveConfig {
|
||||
const attachmentDecay = media.attachment_decay;
|
||||
const minSizeMb = attachmentDecay.min_size_mb ?? DEFAULT_DECAY_CONSTANTS.MIN_MB;
|
||||
const configuredMaxSizeMb = attachmentDecay.max_size_mb ?? DEFAULT_DECAY_CONSTANTS.MAX_MB;
|
||||
const maxSizeMb = computeAttachmentDecayMaxSize(minSizeMb, configuredMaxSizeMb);
|
||||
const configuredMaxEligibleSizeMb = attachmentDecay.max_eligible_size_mb ?? DEFAULT_DECAY_CONSTANTS.PLAN_MB;
|
||||
const maxEligibleSizeMb = Math.max(maxSizeMb, configuredMaxEligibleSizeMb);
|
||||
const minLifetimeDays = attachmentDecay.min_lifetime_days ?? DEFAULT_DECAY_CONSTANTS.MIN_DAYS;
|
||||
const configuredMaxLifetimeDays = attachmentDecay.max_lifetime_days ?? DEFAULT_DECAY_CONSTANTS.MAX_DAYS;
|
||||
const maxLifetimeDays =
|
||||
configuredMaxLifetimeDays >= minLifetimeDays
|
||||
? configuredMaxLifetimeDays
|
||||
: Math.max(DEFAULT_DECAY_CONSTANTS.MAX_DAYS, minLifetimeDays);
|
||||
return {
|
||||
enabled: attachmentDecay.enabled ?? Config.attachmentDecayEnabled,
|
||||
min_size_mb: minSizeMb,
|
||||
max_size_mb: maxSizeMb,
|
||||
max_eligible_size_mb: maxEligibleSizeMb,
|
||||
min_lifetime_days: minLifetimeDays,
|
||||
max_lifetime_days: maxLifetimeDays,
|
||||
curve: attachmentDecay.curve ?? DEFAULT_DECAY_CONSTANTS.CURVE,
|
||||
renew_threshold_days: attachmentDecay.renew_threshold_days ?? DEFAULT_RENEWAL_CONSTANTS.RENEW_THRESHOLD_DAYS,
|
||||
renew_window_days: attachmentDecay.renew_window_days ?? DEFAULT_RENEWAL_CONSTANTS.RENEW_WINDOW_DAYS,
|
||||
};
|
||||
}
|
||||
|
||||
function parseStoredInstanceMediaConfig(raw: string | null): InstanceMediaConfig {
|
||||
return decodeInstanceMediaConfig(parseStoredConfigValue(raw, 'media'));
|
||||
}
|
||||
|
||||
type EffectiveSmtpConfig = NonNullable<APIConfig['email']['smtp']>;
|
||||
|
||||
interface SmtpTestConfig {
|
||||
smtp: EffectiveSmtpConfig;
|
||||
problem: string | null;
|
||||
}
|
||||
|
||||
function storedSmtpTlsMode(smtp: InstanceEmailSmtpIntegrationConfig): SmtpTlsMode | null {
|
||||
return smtp.tls_mode ?? smtpTlsModeFromSecure(smtp.secure);
|
||||
}
|
||||
|
||||
function resolveEffectiveSmtpConfig(smtp: InstanceEmailSmtpIntegrationConfig): EffectiveSmtpConfig {
|
||||
return {
|
||||
host: smtp.host ?? Config.email.smtp?.host ?? '',
|
||||
port: smtp.port ?? Config.email.smtp?.port ?? 587,
|
||||
username: smtp.username ?? Config.email.smtp?.username ?? '',
|
||||
password: smtp.password ?? Config.email.smtp?.password ?? '',
|
||||
tlsMode: storedSmtpTlsMode(smtp) ?? Config.email.smtp?.tlsMode ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
function describeIncompleteSmtpConfig(smtp: EffectiveSmtpConfig): string | null {
|
||||
if (!smtp.host.trim()) return 'No SMTP host is configured';
|
||||
if (!smtp.port) return 'No SMTP port is configured';
|
||||
if (!smtp.username.trim() || !smtp.password.trim()) return 'The SMTP username and password are both required';
|
||||
return null;
|
||||
}
|
||||
|
||||
function hasCompleteSmtpConfig(config: APIConfig['email']): boolean {
|
||||
if (config.provider !== 'smtp' || !config.smtp) return false;
|
||||
return Boolean(
|
||||
config.fromEmail.trim() &&
|
||||
config.smtp.host.trim() &&
|
||||
config.smtp.port &&
|
||||
config.smtp.username.trim() &&
|
||||
config.smtp.password.trim(),
|
||||
);
|
||||
return Boolean(config.fromEmail.trim()) && describeIncompleteSmtpConfig(config.smtp) === null;
|
||||
}
|
||||
|
||||
const StoredRegistrationConfigSchema = InstanceRegistrationSchema.extend({
|
||||
@@ -1680,7 +1742,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));
|
||||
}
|
||||
|
||||
@@ -1852,6 +1914,7 @@ export class InstanceConfigRepository {
|
||||
smtp: {
|
||||
...current.email.smtp,
|
||||
...(config.email?.smtp ?? {}),
|
||||
...(config.email?.smtp?.tls_mode === undefined ? {} : {secure: null}),
|
||||
},
|
||||
},
|
||||
bluesky: {
|
||||
@@ -1881,30 +1944,12 @@ export class InstanceConfigRepository {
|
||||
}
|
||||
|
||||
async getEffectiveAttachmentDecayConfig(): Promise<InstanceAttachmentDecayEffectiveConfig> {
|
||||
const media = await this.getInstanceMediaConfig();
|
||||
const attachmentDecay = media.attachment_decay;
|
||||
const minSizeMb = attachmentDecay.min_size_mb ?? DEFAULT_DECAY_CONSTANTS.MIN_MB;
|
||||
const configuredMaxSizeMb = attachmentDecay.max_size_mb ?? DEFAULT_DECAY_CONSTANTS.MAX_MB;
|
||||
const maxSizeMb = computeAttachmentDecayMaxSize(minSizeMb, configuredMaxSizeMb);
|
||||
const configuredMaxEligibleSizeMb = attachmentDecay.max_eligible_size_mb ?? DEFAULT_DECAY_CONSTANTS.PLAN_MB;
|
||||
const maxEligibleSizeMb = Math.max(maxSizeMb, configuredMaxEligibleSizeMb);
|
||||
const minLifetimeDays = attachmentDecay.min_lifetime_days ?? DEFAULT_DECAY_CONSTANTS.MIN_DAYS;
|
||||
const configuredMaxLifetimeDays = attachmentDecay.max_lifetime_days ?? DEFAULT_DECAY_CONSTANTS.MAX_DAYS;
|
||||
const maxLifetimeDays =
|
||||
configuredMaxLifetimeDays >= minLifetimeDays
|
||||
? configuredMaxLifetimeDays
|
||||
: Math.max(DEFAULT_DECAY_CONSTANTS.MAX_DAYS, minLifetimeDays);
|
||||
return {
|
||||
enabled: attachmentDecay.enabled ?? Config.attachmentDecayEnabled,
|
||||
min_size_mb: minSizeMb,
|
||||
max_size_mb: maxSizeMb,
|
||||
max_eligible_size_mb: maxEligibleSizeMb,
|
||||
min_lifetime_days: minLifetimeDays,
|
||||
max_lifetime_days: maxLifetimeDays,
|
||||
curve: attachmentDecay.curve ?? DEFAULT_DECAY_CONSTANTS.CURVE,
|
||||
renew_threshold_days: attachmentDecay.renew_threshold_days ?? DEFAULT_RENEWAL_CONSTANTS.RENEW_THRESHOLD_DAYS,
|
||||
renew_window_days: attachmentDecay.renew_window_days ?? DEFAULT_RENEWAL_CONSTANTS.RENEW_WINDOW_DAYS,
|
||||
};
|
||||
return resolveEffectiveAttachmentDecayConfig(await this.getInstanceMediaConfig());
|
||||
}
|
||||
|
||||
async fetchStoredEffectiveAttachmentDecayConfig(): Promise<InstanceAttachmentDecayEffectiveConfig> {
|
||||
const raw = await this.fetchConfigFromDatabase(INSTANCE_MEDIA_CONFIG_KEY);
|
||||
return resolveEffectiveAttachmentDecayConfig(parseStoredInstanceMediaConfig(raw));
|
||||
}
|
||||
|
||||
async getInstanceMediaAdminConfig(): Promise<InstanceMediaAdminConfig> {
|
||||
@@ -1935,22 +1980,35 @@ export class InstanceConfigRepository {
|
||||
return integrations.youtube.api_key ?? normalizeOptionalString(Config.youtube.apiKey);
|
||||
}
|
||||
|
||||
async getEffectiveEmailConfig(): Promise<APIConfig['email']> {
|
||||
async getSmtpTestConfig(draft: InstanceEmailSmtpTestRequest): Promise<SmtpTestConfig> {
|
||||
const saved = (await this.getInstanceIntegrationsConfig()).email.smtp;
|
||||
const current = resolveEffectiveSmtpConfig(saved);
|
||||
if (Object.values(draft).every((value) => value === undefined)) {
|
||||
return {smtp: current, problem: await this.getEmailSendBlocker()};
|
||||
}
|
||||
const retargeted =
|
||||
(draft.host != null && draft.host.toLowerCase() !== current.host.toLowerCase()) ||
|
||||
(draft.username != null && draft.username !== current.username);
|
||||
const smtp: EffectiveSmtpConfig = {
|
||||
host: draft.host ?? current.host,
|
||||
port: draft.port ?? current.port,
|
||||
username: draft.username ?? current.username,
|
||||
password: draft.password ?? (retargeted ? '' : current.password),
|
||||
tlsMode: draft.tls_mode === undefined ? current.tlsMode : (draft.tls_mode ?? Config.email.smtp?.tlsMode ?? null),
|
||||
};
|
||||
if (retargeted && !smtp.password) {
|
||||
return {smtp, problem: 'Enter the SMTP password to test a different host or username'};
|
||||
}
|
||||
return {smtp, problem: describeIncompleteSmtpConfig(smtp)};
|
||||
}
|
||||
|
||||
private async resolveEmailConfig(): Promise<{email: APIConfig['email']; usernameMode: boolean}> {
|
||||
const integrations = await this.getInstanceIntegrationsConfig();
|
||||
const provider = integrations.email.provider ?? Config.email.provider;
|
||||
const fromEmail = integrations.email.from_email ?? Config.email.fromEmail;
|
||||
const fromName = integrations.email.from_name ?? Config.email.fromName;
|
||||
const smtp =
|
||||
provider === 'smtp'
|
||||
? {
|
||||
host: integrations.email.smtp.host ?? Config.email.smtp?.host ?? '',
|
||||
port: integrations.email.smtp.port ?? Config.email.smtp?.port ?? 587,
|
||||
username: integrations.email.smtp.username ?? Config.email.smtp?.username ?? '',
|
||||
password: integrations.email.smtp.password ?? Config.email.smtp?.password ?? '',
|
||||
secure: integrations.email.smtp.secure ?? Config.email.smtp?.secure ?? true,
|
||||
}
|
||||
: undefined;
|
||||
const next: APIConfig['email'] = {
|
||||
const smtp = provider === 'smtp' ? resolveEffectiveSmtpConfig(integrations.email.smtp) : undefined;
|
||||
const email: APIConfig['email'] = {
|
||||
...Config.email,
|
||||
enabled: integrations.email.enabled ?? Config.email.enabled,
|
||||
provider,
|
||||
@@ -1959,9 +2017,23 @@ export class InstanceConfigRepository {
|
||||
smtp,
|
||||
};
|
||||
const usernameMode = (await this.getAccountIdentityMode()) === AccountIdentityModes.USERNAME;
|
||||
return {email, usernameMode};
|
||||
}
|
||||
|
||||
private async getEmailSendBlocker(): Promise<string | null> {
|
||||
const {email, usernameMode} = await this.resolveEmailConfig();
|
||||
if (usernameMode) return 'Email is off while accounts sign in with usernames';
|
||||
if (!email.enabled) return 'Email is turned off';
|
||||
if (email.provider !== 'smtp' || !email.smtp) return 'The email provider is not SMTP';
|
||||
if (!email.fromEmail.trim()) return 'No from address is set';
|
||||
return describeIncompleteSmtpConfig(email.smtp);
|
||||
}
|
||||
|
||||
async getEffectiveEmailConfig(): Promise<APIConfig['email']> {
|
||||
const {email, usernameMode} = await this.resolveEmailConfig();
|
||||
return {
|
||||
...next,
|
||||
enabled: !usernameMode && next.enabled && hasCompleteSmtpConfig(next),
|
||||
...email,
|
||||
enabled: !usernameMode && email.enabled && hasCompleteSmtpConfig(email),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2026,7 +2098,8 @@ export class InstanceConfigRepository {
|
||||
port: email.smtp?.port ?? null,
|
||||
username: email.smtp?.username || null,
|
||||
password_set: secretIsSet(integrations.email.smtp.password) || secretIsSet(Config.email.smtp?.password),
|
||||
secure: email.smtp?.secure ?? null,
|
||||
tls_mode: storedSmtpTlsMode(integrations.email.smtp),
|
||||
effective_tls_mode: email.smtp ? resolveSmtpTlsMode(email.smtp.tlsMode, email.smtp.port) : null,
|
||||
},
|
||||
disable_new_ip_authorization: integrations.email.disable_new_ip_authorization ?? false,
|
||||
effective_disable_new_ip_authorization: integrations.email.disable_new_ip_authorization || !email.enabled,
|
||||
|
||||
@@ -2,10 +2,13 @@
|
||||
|
||||
import {Config} from '@app/api/Config';
|
||||
import {
|
||||
buildStoredLimitConfig,
|
||||
createDefaultLimitConfig,
|
||||
getLegacyLimitConfigKvKey,
|
||||
LIMIT_CONFIG_REFRESH_CHANNEL,
|
||||
type LimitBuildOptions,
|
||||
mergeWithCurrentDefaults,
|
||||
readStoredLimitOverrides,
|
||||
sanitizeLimitConfigForInstance,
|
||||
} from '@app/api/constants/LimitConfig';
|
||||
import {
|
||||
@@ -28,11 +31,6 @@ const MAX_PENDING_OPERATIONS = 32;
|
||||
|
||||
let globalLimitConfigService: LimitConfigService | null = null;
|
||||
|
||||
interface LimitBuildOptions {
|
||||
selfHosted: boolean;
|
||||
premiumMode: InstancePremiumMode;
|
||||
}
|
||||
|
||||
export class LimitConfigService {
|
||||
private premiumMode: InstancePremiumMode = 'everyone';
|
||||
private config: LimitConfigSnapshot = createDefaultLimitConfig({
|
||||
@@ -146,40 +144,60 @@ export class LimitConfigService {
|
||||
this.assertRefreshActive(generation);
|
||||
const {config: stored, premiumMode} = await this.repository.readLimitConfigInputs();
|
||||
this.assertRefreshActive(generation);
|
||||
const buildOptions: LimitBuildOptions = {
|
||||
const buildOptions: Required<LimitBuildOptions> = {
|
||||
selfHosted: Config.instance.selfHosted,
|
||||
premiumMode,
|
||||
};
|
||||
this.config =
|
||||
stored === null
|
||||
? createDefaultLimitConfig(buildOptions)
|
||||
: mergeWithCurrentDefaults(sanitizeLimitConfigForInstance(stored, buildOptions), buildOptions);
|
||||
: mergeWithCurrentDefaults(
|
||||
sanitizeLimitConfigForInstance(readStoredLimitOverrides(stored, buildOptions), buildOptions),
|
||||
buildOptions,
|
||||
);
|
||||
this.premiumMode = premiumMode;
|
||||
setCachedInstancePremiumMode(premiumMode);
|
||||
}
|
||||
|
||||
updateConfig(config: LimitConfigSnapshot): Promise<void> {
|
||||
return this.runOperation(async (generation) => {
|
||||
const policy = await this.repository.readStoredInstancePolicyConfig();
|
||||
const {config: previous, premiumMode} = await this.repository.readLimitConfigInputs();
|
||||
this.assertRefreshActive(generation);
|
||||
const buildOptions: LimitBuildOptions = {
|
||||
const stored = buildStoredLimitConfig(config, previous, {
|
||||
selfHosted: Config.instance.selfHosted,
|
||||
premiumMode: policy.premium_mode,
|
||||
};
|
||||
const normalized = mergeWithCurrentDefaults(sanitizeLimitConfigForInstance(config, buildOptions), buildOptions);
|
||||
await this.repository.setLimitConfig(normalized);
|
||||
premiumMode,
|
||||
});
|
||||
await this.repository.setLimitConfig(stored);
|
||||
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> {
|
||||
return this.runOperation(async (generation) => {
|
||||
if (patch.premium_mode !== undefined) {
|
||||
await this.detachStoredLimitsFromPremiumMode(generation, patch.premium_mode);
|
||||
}
|
||||
await this.repository.setInstancePolicyConfig(patch);
|
||||
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> {
|
||||
this.assertRefreshActive(generation);
|
||||
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 type {LimitConfigSnapshot, LimitRule} from '@fluxer/limits/src/LimitTypes';
|
||||
import type {LimitRule} from '@fluxer/limits/src/LimitTypes';
|
||||
|
||||
function isStringArray(value: unknown): value is Array<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 (
|
||||
isJsonRecord(value) &&
|
||||
(value.version === undefined || typeof value.version === 'number') &&
|
||||
(value.overridesOnly === undefined || typeof value.overridesOnly === 'boolean') &&
|
||||
isStringArray(value.traitDefinitions) &&
|
||||
Array.isArray(value.rules) &&
|
||||
value.rules.every(isLimitRuleSnapshot)
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {createTestAccount, setUserACLs, type TestAccount} from '@app/api/auth/tests/AuthTestUtils';
|
||||
import {VALID_GIF_BASE64, VALID_PNG_BASE64} from '@app/api/emoji/tests/EmojiTestUtils';
|
||||
import {createGuild} from '@app/api/guild/tests/GuildTestUtils';
|
||||
import {ensureSessionStarted} from '@app/api/message/tests/MessageTestUtils';
|
||||
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 type {LimitKey} from '@fluxer/constants/src/LimitConfigMetadata';
|
||||
import {AVATAR_MAX_SIZE, EMOJI_MAX_SIZE, STICKER_MAX_SIZE} from '@fluxer/constants/src/LimitConstants';
|
||||
import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes';
|
||||
import type {LimitConfigSnapshot} from '@fluxer/limits/src/LimitTypes';
|
||||
import {afterAll, beforeAll, beforeEach, describe, expect, it} from 'vitest';
|
||||
|
||||
const RAISED_EXPRESSION_SIZE = EMOJI_MAX_SIZE * 4;
|
||||
const RAISED_AVATAR_SIZE = AVATAR_MAX_SIZE + 2 * 1024 * 1024;
|
||||
|
||||
interface LimitConfigReadResponse {
|
||||
limit_config: LimitConfigSnapshot;
|
||||
}
|
||||
|
||||
interface ValidationErrorResponse {
|
||||
errors?: Array<{path?: string; code?: string; message?: string}>;
|
||||
}
|
||||
|
||||
function paddedImage(mime: string, base64: string, byteLength: number): string {
|
||||
const image = Buffer.from(base64, 'base64');
|
||||
const padded = Buffer.concat([image, Buffer.alloc(byteLength - image.length)]);
|
||||
return `data:${mime};base64,${padded.toString('base64')}`;
|
||||
}
|
||||
|
||||
describe('configured image size limits', () => {
|
||||
let harness: ApiTestHarness;
|
||||
|
||||
beforeAll(async () => {
|
||||
harness = await createApiTestHarness();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await harness.reset();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await harness?.shutdown();
|
||||
});
|
||||
|
||||
const raiseLimits = async (limits: Partial<Record<LimitKey, number>>): Promise<void> => {
|
||||
const admin = await setUserACLs(harness, await createTestAccount(harness), [
|
||||
AdminACLs.AUTHENTICATE,
|
||||
AdminACLs.INSTANCE_LIMIT_CONFIG_VIEW,
|
||||
AdminACLs.INSTANCE_LIMIT_CONFIG_UPDATE,
|
||||
]);
|
||||
const current = await createBuilder<LimitConfigReadResponse>(harness, admin.token)
|
||||
.get('/admin/limit-config')
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
await createBuilder(harness, admin.token)
|
||||
.put('/admin/limit-config')
|
||||
.body({
|
||||
limit_config: {
|
||||
traitDefinitions: current.limit_config.traitDefinitions,
|
||||
rules: current.limit_config.rules.map((rule) => ({
|
||||
id: rule.id,
|
||||
filters: rule.filters,
|
||||
limits: rule.id === 'very_large_guild' ? rule.limits : {...rule.limits, ...limits},
|
||||
})),
|
||||
},
|
||||
})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
};
|
||||
|
||||
const createOwner = async (): Promise<TestAccount> => {
|
||||
const owner = await createTestAccount(harness);
|
||||
await ensureSessionStarted(harness, owner.token);
|
||||
return owner;
|
||||
};
|
||||
|
||||
it('accepts emoji and sticker uploads above the stock size once the limits are raised', async () => {
|
||||
await raiseLimits({emoji_max_size: RAISED_EXPRESSION_SIZE, sticker_max_size: RAISED_EXPRESSION_SIZE});
|
||||
const owner = await createOwner();
|
||||
const guild = await createGuild(harness, owner.token, 'Raised expression limits');
|
||||
const image = paddedImage('image/gif', VALID_GIF_BASE64, Math.max(EMOJI_MAX_SIZE, STICKER_MAX_SIZE) * 2);
|
||||
|
||||
await createBuilder(harness, owner.token)
|
||||
.post(`/guilds/${guild.id}/emojis`)
|
||||
.body({name: 'large_emoji', image})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
await createBuilder(harness, owner.token)
|
||||
.post(`/guilds/${guild.id}/stickers`)
|
||||
.body({name: 'large_sticker', description: 'large sticker', tags: [], image})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
});
|
||||
|
||||
it('rejects an emoji over a raised limit with the configured size', async () => {
|
||||
await raiseLimits({emoji_max_size: RAISED_EXPRESSION_SIZE});
|
||||
const owner = await createOwner();
|
||||
const guild = await createGuild(harness, owner.token, 'Raised emoji ceiling');
|
||||
|
||||
const json = await createBuilder<ValidationErrorResponse>(harness, owner.token)
|
||||
.post(`/guilds/${guild.id}/emojis`)
|
||||
.body({name: 'too_large', image: paddedImage('image/gif', VALID_GIF_BASE64, RAISED_EXPRESSION_SIZE + 1)})
|
||||
.expect(HTTP_STATUS.BAD_REQUEST)
|
||||
.execute();
|
||||
expect(json.errors?.[0]).toMatchObject({
|
||||
path: 'image',
|
||||
code: ValidationErrorCodes.IMAGE_SIZE_EXCEEDS_LIMIT,
|
||||
message: `Image size exceeds ${RAISED_EXPRESSION_SIZE} bytes.`,
|
||||
});
|
||||
});
|
||||
|
||||
it('accepts an avatar above the stock size once avatar_max_size is raised', async () => {
|
||||
await raiseLimits({avatar_max_size: RAISED_AVATAR_SIZE});
|
||||
const account = await createOwner();
|
||||
|
||||
const json = await createBuilder<{avatar: string | null}>(harness, account.token)
|
||||
.patch('/users/@me')
|
||||
.body({avatar: paddedImage('image/png', VALID_PNG_BASE64, AVATAR_MAX_SIZE + 1024 * 1024)})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
expect(json.avatar).toBeTruthy();
|
||||
});
|
||||
|
||||
it('rejects an avatar over a raised limit with the configured size', async () => {
|
||||
await raiseLimits({avatar_max_size: RAISED_AVATAR_SIZE});
|
||||
const account = await createOwner();
|
||||
|
||||
const json = await createBuilder<ValidationErrorResponse>(harness, account.token)
|
||||
.patch('/users/@me')
|
||||
.body({avatar: paddedImage('image/png', VALID_PNG_BASE64, RAISED_AVATAR_SIZE + 1)})
|
||||
.expect(HTTP_STATUS.BAD_REQUEST)
|
||||
.execute();
|
||||
expect(json.errors?.[0]).toMatchObject({
|
||||
path: 'avatar',
|
||||
code: ValidationErrorCodes.IMAGE_SIZE_EXCEEDS_LIMIT,
|
||||
message: `Image size exceeds ${RAISED_AVATAR_SIZE} bytes.`,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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});
|
||||
});
|
||||
});
|
||||
@@ -527,7 +527,6 @@ class RequestServices implements RequestScopedServices {
|
||||
get applicationService(): ApplicationService {
|
||||
this.cachedApplicationService ??= new ApplicationService(this.context, {
|
||||
applicationRepository: getApplicationRepository(),
|
||||
channelRepository: getChannelRepository(),
|
||||
userCacheService: getUserCacheService(),
|
||||
entityAssetService: getEntityAssetService(),
|
||||
discriminatorService: getDiscriminatorService(),
|
||||
|
||||
@@ -1,62 +0,0 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {createUserID, type UserID} from '@app/api/BrandedTypes';
|
||||
import type {ChannelRepository} from '@app/api/channel/ChannelRepository';
|
||||
import {MessageAnonymizationService} from '@app/api/channel/services/message/MessageAnonymizationService';
|
||||
import {EMPTY_USER_ROW} from '@app/api/database/types/UserTypes';
|
||||
import type {ISnowflakeService} from '@app/api/infrastructure/ISnowflakeService';
|
||||
import {Logger} from '@app/api/Logger';
|
||||
import type {UserRepository} from '@app/api/user/repositories/UserRepository';
|
||||
import {
|
||||
DELETED_USER_DISCRIMINATOR,
|
||||
DELETED_USER_GLOBAL_NAME,
|
||||
DELETED_USER_USERNAME,
|
||||
UserFlags,
|
||||
} from '@fluxer/constants/src/UserConstants';
|
||||
|
||||
interface RemapAuthorMessagesToDeletedUserParams {
|
||||
originalAuthorId: UserID;
|
||||
channelRepository: ChannelRepository;
|
||||
userRepository: UserRepository;
|
||||
snowflakeService: ISnowflakeService;
|
||||
}
|
||||
|
||||
async function createDeletedMessageAuthorUser(params: {
|
||||
userRepository: UserRepository;
|
||||
snowflakeService: ISnowflakeService;
|
||||
}): Promise<UserID> {
|
||||
const deletedUserId = createUserID(await params.snowflakeService.generate());
|
||||
await params.userRepository.create({
|
||||
...EMPTY_USER_ROW,
|
||||
user_id: deletedUserId,
|
||||
username: DELETED_USER_USERNAME,
|
||||
discriminator: DELETED_USER_DISCRIMINATOR,
|
||||
global_name: DELETED_USER_GLOBAL_NAME,
|
||||
bot: false,
|
||||
system: false,
|
||||
flags: UserFlags.DELETED,
|
||||
});
|
||||
await params.userRepository.deleteUserSecondaryIndices(deletedUserId);
|
||||
return deletedUserId;
|
||||
}
|
||||
|
||||
export async function remapAuthorMessagesToDeletedUser(
|
||||
params: RemapAuthorMessagesToDeletedUserParams,
|
||||
): Promise<UserID | null> {
|
||||
const {originalAuthorId, channelRepository, userRepository, snowflakeService} = params;
|
||||
const hasMessages = await channelRepository.messages.listMessagesByAuthor(originalAuthorId, 1);
|
||||
if (hasMessages.length === 0) {
|
||||
return null;
|
||||
}
|
||||
const replacementAuthorId = await createDeletedMessageAuthorUser({
|
||||
userRepository,
|
||||
snowflakeService,
|
||||
});
|
||||
const anonymizationService = new MessageAnonymizationService(channelRepository);
|
||||
await anonymizationService.anonymizeMessagesByAuthor(originalAuthorId, replacementAuthorId);
|
||||
Logger.info(
|
||||
{originalAuthorId: originalAuthorId.toString(), replacementAuthorId: replacementAuthorId.toString()},
|
||||
'Remapped authored messages to deleted user id',
|
||||
);
|
||||
return replacementAuthorId;
|
||||
}
|
||||
@@ -3,7 +3,6 @@
|
||||
import type {ApiContext} from '@app/api/ApiContext';
|
||||
import type {ApplicationID, UserID} from '@app/api/BrandedTypes';
|
||||
import {applicationIdToUserId} from '@app/api/BrandedTypes';
|
||||
import type {ChannelRepository} from '@app/api/channel/ChannelRepository';
|
||||
import type {ApplicationRow} from '@app/api/database/types/OAuth2Types';
|
||||
import type {UserRow} from '@app/api/database/types/UserTypes';
|
||||
import {contentModerationService} from '@app/api/infrastructure/ContentModerationService';
|
||||
@@ -14,7 +13,6 @@ import {Logger} from '@app/api/Logger';
|
||||
import {profileSubstringBlocklistCache} from '@app/api/middleware/ProfileSubstringBlocklistCache';
|
||||
import type {Application} from '@app/api/models/Application';
|
||||
import type {User} from '@app/api/models/User';
|
||||
import {remapAuthorMessagesToDeletedUser} from '@app/api/oauth/ApplicationMessageAuthorAnonymization';
|
||||
import type {BotAuthService} from '@app/api/oauth/BotAuthService';
|
||||
import {generateOAuthTokenSecret} from '@app/api/oauth/OAuthTokenSecret';
|
||||
import type {ApplicationRepository} from '@app/api/oauth/repositories/ApplicationRepository';
|
||||
@@ -43,7 +41,6 @@ import type {BotProfileUpdateRequest} from '@fluxer/schema/src/domains/oauth/OAu
|
||||
|
||||
interface ApplicationServiceDeps {
|
||||
discriminatorService: DiscriminatorService;
|
||||
channelRepository: ChannelRepository;
|
||||
applicationRepository: ApplicationRepository;
|
||||
botAuthService: BotAuthService;
|
||||
entityAssetService: EntityAssetService;
|
||||
@@ -307,12 +304,6 @@ export class ApplicationService {
|
||||
const application = await this.verifyOwnership(userId, applicationId);
|
||||
if (application.hasBotUser()) {
|
||||
const botUserId = application.getBotUserId()!;
|
||||
const replacementAuthorId = await remapAuthorMessagesToDeletedUser({
|
||||
originalAuthorId: botUserId,
|
||||
channelRepository: this.deps.channelRepository,
|
||||
userRepository: this.apiContext.services.users,
|
||||
snowflakeService: this.apiContext.services.snowflake,
|
||||
});
|
||||
const guildIds = await this.apiContext.services.users.getUserGuildIds(botUserId);
|
||||
await this.apiContext.services.users.deleteUserSecondaryIndices(botUserId);
|
||||
await this.apiContext.services.users.removeFromAllGuilds(botUserId);
|
||||
@@ -372,14 +363,10 @@ export class ApplicationService {
|
||||
botUser.toRow(),
|
||||
);
|
||||
await this.deps.userCacheService.invalidateUserCache(botUserId);
|
||||
if (replacementAuthorId) {
|
||||
await this.deps.userCacheService.invalidateUserCache(replacementAuthorId);
|
||||
}
|
||||
Logger.info(
|
||||
{
|
||||
applicationId: applicationId.toString(),
|
||||
botUserId: botUserId.toString(),
|
||||
replacementAuthorId: replacementAuthorId?.toString() ?? null,
|
||||
},
|
||||
'Anonymized bot user associated with application',
|
||||
);
|
||||
|
||||
@@ -94,25 +94,17 @@ describe('OAuth2 Application Delete', () => {
|
||||
.execute();
|
||||
const foundMessage = messages.find((message) => message.id === botMessageId);
|
||||
expect(foundMessage).toBeDefined();
|
||||
expect(foundMessage?.author.id).not.toBe(createResult.botUserId);
|
||||
expect(foundMessage?.author.id).toBe(createResult.botUserId);
|
||||
expect(foundMessage?.author.bot).toBe(true);
|
||||
expect(foundMessage?.author.username).toBe('DeletedUser');
|
||||
expect(foundMessage?.author.discriminator).toBe('0000');
|
||||
const previousAuthorCount = await createBuilderWithoutAuth<{
|
||||
const authorCount = await createBuilderWithoutAuth<{
|
||||
count: number;
|
||||
}>(harness)
|
||||
.get(`/test/users/${createResult.botUserId}/messages/count`)
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
expect(previousAuthorCount.count).toBe(0);
|
||||
const replacementAuthorId = foundMessage?.author.id;
|
||||
expect(replacementAuthorId).toBeTruthy();
|
||||
const replacementAuthorCount = await createBuilderWithoutAuth<{
|
||||
count: number;
|
||||
}>(harness)
|
||||
.get(`/test/users/${replacementAuthorId}/messages/count`)
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
expect(replacementAuthorCount.count).toBe(1);
|
||||
expect(authorCount.count).toBe(1);
|
||||
});
|
||||
test('enforces access control', async () => {
|
||||
const owner = await createTestAccount(harness);
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {createTestAccount, setUserACLs} from '@app/api/auth/tests/AuthTestUtils';
|
||||
import type {ApiTestHarness} from '@app/api/test/ApiTestHarness';
|
||||
import {createBuilder} from '@app/api/test/TestRequestBuilder';
|
||||
import {AdminACLs} from '@fluxer/constants/src/AdminACLs';
|
||||
import type {LimitKey} from '@fluxer/constants/src/LimitConfigMetadata';
|
||||
import type {LimitConfigSnapshot} from '@fluxer/limits/src/LimitTypes';
|
||||
|
||||
export async function setLimitOverride(
|
||||
harness: ApiTestHarness,
|
||||
limits: Partial<Record<LimitKey, number>>,
|
||||
): Promise<() => Promise<void>> {
|
||||
const admin = await setUserACLs(harness, await createTestAccount(harness), [
|
||||
AdminACLs.AUTHENTICATE,
|
||||
AdminACLs.INSTANCE_LIMIT_CONFIG_VIEW,
|
||||
AdminACLs.INSTANCE_LIMIT_CONFIG_UPDATE,
|
||||
]);
|
||||
const current = await createBuilder<{limit_config: LimitConfigSnapshot}>(harness, admin.token)
|
||||
.get('/admin/limit-config')
|
||||
.execute();
|
||||
const writeConfig = async (rules: LimitConfigSnapshot['rules']) => {
|
||||
await createBuilder(harness, admin.token)
|
||||
.put('/admin/limit-config')
|
||||
.body({limit_config: {traitDefinitions: current.limit_config.traitDefinitions, rules}})
|
||||
.execute();
|
||||
};
|
||||
await writeConfig([...current.limit_config.rules, {id: 'test_limit_override', limits}]);
|
||||
return async () => {
|
||||
await writeConfig(current.limit_config.rules);
|
||||
};
|
||||
}
|
||||
@@ -81,7 +81,7 @@ function setDefaultTestEnv(): void {
|
||||
FLUXER_EMAIL_SMTP_PORT: '1025',
|
||||
FLUXER_EMAIL_SMTP_USERNAME: 'test',
|
||||
FLUXER_EMAIL_SMTP_PASSWORD: 'test',
|
||||
FLUXER_EMAIL_SMTP_SECURE: 'false',
|
||||
FLUXER_EMAIL_SMTP_TLS_MODE: 'none',
|
||||
FLUXER_LIVEKIT_ENABLED: 'false',
|
||||
FLUXER_STRIPE_ENABLED: 'true',
|
||||
FLUXER_SEARCH_ENGINE: 'elasticsearch',
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {AttachmentDecayRepository} from '@app/api/attachment/AttachmentDecayRepository';
|
||||
import {AttachmentDecayService} from '@app/api/attachment/AttachmentDecayService';
|
||||
import {AttachmentDecayService, resolveDefaultAttachmentDecayConfig} from '@app/api/attachment/AttachmentDecayService';
|
||||
import {makeSignedAttachmentCdnUrl, signAttachmentUrl} from '@app/api/attachment/AttachmentUrls';
|
||||
import type {ChannelID, GuildID, MessageID, UserID} from '@app/api/BrandedTypes';
|
||||
import {createUserID} from '@app/api/BrandedTypes';
|
||||
@@ -378,9 +378,10 @@ export class RepositoryBackedMessageResponseDataService extends MessageResponseD
|
||||
uploadedAt: snowflakeToDate(ownerMessageId),
|
||||
})),
|
||||
);
|
||||
const decayEnabled = (await resolveDefaultAttachmentDecayConfig()).enabled;
|
||||
return Promise.all(
|
||||
attachments.map(async (attachment) => {
|
||||
const decay = await this.attachmentDecayRepository.fetchById(attachment.id);
|
||||
const decay = decayEnabled ? await this.attachmentDecayRepository.fetchById(attachment.id) : null;
|
||||
const url = this.mapAttachmentUrl(message, attachment);
|
||||
return {
|
||||
id: attachment.id.toString(),
|
||||
|
||||
@@ -11,13 +11,13 @@ import {
|
||||
follow,
|
||||
followRequest,
|
||||
setGuildFeatures,
|
||||
setLimitOverride,
|
||||
} from '@app/api/channel/tests/AnnouncementTestUtils';
|
||||
import {createGuild, createPermissionOverwrite, updateGuild} from '@app/api/channel/tests/ChannelTestUtils';
|
||||
import {getPngDataUrl} from '@app/api/emoji/tests/EmojiTestUtils';
|
||||
import {ensureSessionStarted} from '@app/api/message/tests/MessageTestUtils';
|
||||
import {phraseBlocklistCache} from '@app/api/middleware/PhraseBlocklistCache';
|
||||
import {type ApiTestHarness, createApiTestHarness} from '@app/api/test/ApiTestHarness';
|
||||
import {setLimitOverride} from '@app/api/test/LimitTestUtils';
|
||||
import {NoopGatewayService} from '@app/api/test/NoopGatewayService';
|
||||
import {HTTP_STATUS} from '@app/api/test/TestConstants';
|
||||
import {createBuilder} from '@app/api/test/TestRequestBuilder';
|
||||
|
||||
@@ -69,6 +69,7 @@ const LANE_CONFIG = {
|
||||
consumerName: 'workers_batch',
|
||||
tasks: [
|
||||
'expireAttachments',
|
||||
'reconcileAttachmentDecay',
|
||||
'expireReportSnapshots',
|
||||
'clearAuthenticatedReporterEmails',
|
||||
'expireStaleJobs',
|
||||
|
||||
@@ -40,6 +40,7 @@ import processPremiumStateReconciliationQueue from '@app/api/worker/tasks/Proces
|
||||
import processStorePurchaseRefreshQueue from '@app/api/worker/tasks/ProcessStorePurchaseRefreshQueue';
|
||||
import processStripeWebhook from '@app/api/worker/tasks/ProcessStripeWebhook';
|
||||
import prunePostgresKvTtl from '@app/api/worker/tasks/PrunePostgresKvTtl';
|
||||
import reconcileAttachmentDecay from '@app/api/worker/tasks/ReconcileAttachmentDecay';
|
||||
import reconcileUserPayments from '@app/api/worker/tasks/ReconcileUserPayments';
|
||||
import refreshSearchIndex from '@app/api/worker/tasks/RefreshSearchIndex';
|
||||
import refreshStorePurchase from '@app/api/worker/tasks/RefreshStorePurchase';
|
||||
@@ -104,6 +105,7 @@ export const workerTasks: Record<WorkerTaskName, WorkerTaskHandler> = {
|
||||
processInactivityDeletions,
|
||||
processPendingBulkMessageDeletions,
|
||||
processPremiumStateReconciliationQueue,
|
||||
reconcileAttachmentDecay,
|
||||
reconcileUserPayments,
|
||||
processAppStoreNotification,
|
||||
processGooglePlayNotification,
|
||||
|
||||
@@ -4,7 +4,6 @@ import {applicationIdToUserId, createApplicationID, type GuildID} from '@app/api
|
||||
import {mapGuildMemberToResponse} from '@app/api/guild/GuildModel';
|
||||
import {Logger} from '@app/api/Logger';
|
||||
import {createRequestCache} from '@app/api/middleware/RequestCacheMiddleware';
|
||||
import {remapAuthorMessagesToDeletedUser} from '@app/api/oauth/ApplicationMessageAuthorAnonymization';
|
||||
import {chunkArray} from '@app/api/utils/ArrayUtils';
|
||||
import {getWorkerDependencies} from '@app/api/worker/WorkerContext';
|
||||
import {
|
||||
@@ -25,15 +24,8 @@ const applicationProcessDeletion: WorkerTaskHandler = async (payload, helpers) =
|
||||
helpers.logger.debug({payload: validated}, 'Processing applicationProcessDeletion task');
|
||||
const applicationId = createApplicationID(BigInt(validated.applicationId));
|
||||
const botUserId = applicationIdToUserId(applicationId);
|
||||
const {
|
||||
userRepository,
|
||||
guildRepository,
|
||||
channelRepository,
|
||||
applicationRepository,
|
||||
userCacheService,
|
||||
gatewayService,
|
||||
snowflakeService,
|
||||
} = getWorkerDependencies();
|
||||
const {userRepository, guildRepository, applicationRepository, userCacheService, gatewayService} =
|
||||
getWorkerDependencies();
|
||||
Logger.debug({applicationId, botUserId}, 'Starting application deletion');
|
||||
try {
|
||||
const application = await applicationRepository.getApplication(applicationId);
|
||||
@@ -42,21 +34,8 @@ const applicationProcessDeletion: WorkerTaskHandler = async (payload, helpers) =
|
||||
return;
|
||||
}
|
||||
const botUser = await userRepository.findUniqueAssert(botUserId);
|
||||
const replacementAuthorId = await remapAuthorMessagesToDeletedUser({
|
||||
originalAuthorId: botUserId,
|
||||
channelRepository,
|
||||
userRepository,
|
||||
snowflakeService,
|
||||
});
|
||||
if (botUser.flags & UserFlags.DELETED) {
|
||||
Logger.info(
|
||||
{
|
||||
applicationId,
|
||||
botUserId,
|
||||
replacementAuthorId: replacementAuthorId?.toString() ?? null,
|
||||
},
|
||||
'Bot user already marked as deleted, skipping profile update',
|
||||
);
|
||||
Logger.info({applicationId, botUserId}, 'Bot user already marked as deleted, skipping profile update');
|
||||
await applicationRepository.deleteApplication(applicationId);
|
||||
return;
|
||||
}
|
||||
@@ -66,6 +45,11 @@ const applicationProcessDeletion: WorkerTaskHandler = async (payload, helpers) =
|
||||
username: DELETED_USER_USERNAME,
|
||||
global_name: DELETED_USER_GLOBAL_NAME,
|
||||
discriminator: DELETED_USER_DISCRIMINATOR,
|
||||
avatar_hash: null,
|
||||
banner_hash: null,
|
||||
bio: null,
|
||||
pronouns: null,
|
||||
accent_color: null,
|
||||
},
|
||||
botUser.toRow(),
|
||||
);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {isAttachmentDecayReconcileRunning} from '@app/api/attachment/AttachmentDecayReconcileRun';
|
||||
import {AttachmentDecayRepository} from '@app/api/attachment/AttachmentDecayRepository';
|
||||
import {makeAttachmentCdnKey, makeAttachmentCdnUrl} from '@app/api/channel/services/message/MessageHelpers';
|
||||
import {Logger} from '@app/api/Logger';
|
||||
@@ -11,12 +12,16 @@ const BUCKET_LOOKBACK_DAYS = 3;
|
||||
const FETCH_LIMIT = 200;
|
||||
|
||||
export async function processExpiredAttachments(now = new Date()): Promise<void> {
|
||||
const {assetDeletionQueue, instanceConfigRepository} = getWorkerDependencies();
|
||||
const {assetDeletionQueue, instanceConfigRepository, kvClient} = getWorkerDependencies();
|
||||
const attachmentDecay = await instanceConfigRepository.getEffectiveAttachmentDecayConfig();
|
||||
if (!attachmentDecay.enabled) {
|
||||
Logger.info('Attachment decay disabled; skipping expireAttachments task');
|
||||
return;
|
||||
}
|
||||
if (await isAttachmentDecayReconcileRunning(kvClient)) {
|
||||
Logger.info('Attachment decay reconcile in progress; skipping expireAttachments task');
|
||||
return;
|
||||
}
|
||||
const repo = new AttachmentDecayRepository();
|
||||
let totalQueued = 0;
|
||||
let totalDeletedRows = 0;
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {
|
||||
attachmentDecayReconcileJobKey,
|
||||
finishAttachmentDecayReconcileRun,
|
||||
holdAttachmentDecayReconcileRun,
|
||||
} from '@app/api/attachment/AttachmentDecayReconcileRun';
|
||||
import {AttachmentDecayRepository} from '@app/api/attachment/AttachmentDecayRepository';
|
||||
import {type AttachmentDecayPayload, AttachmentDecayService} from '@app/api/attachment/AttachmentDecayService';
|
||||
import type {ChannelID, MessageID} from '@app/api/BrandedTypes';
|
||||
import {createChannelID, createMessageID} from '@app/api/BrandedTypes';
|
||||
import {Config} from '@app/api/Config';
|
||||
import {
|
||||
attachmentDecayPayloadsForMessage,
|
||||
makeAttachmentCdnKey,
|
||||
} from '@app/api/channel/services/message/MessageHelpers';
|
||||
import {Logger} from '@app/api/Logger';
|
||||
import {getWorkerDependencies} from '@app/api/worker/WorkerContext';
|
||||
import type {WorkerTaskHandler} from '@pkgs/worker/src/contracts/WorkerTask';
|
||||
import {z} from 'zod';
|
||||
|
||||
const CHANNEL_PAGE_SIZE = 1000;
|
||||
|
||||
const CursorSchema = z.object({
|
||||
pageState: z.string().nullable(),
|
||||
channelId: z.string().nullable(),
|
||||
beforeMessageId: z.string().nullable(),
|
||||
});
|
||||
|
||||
const PayloadSchema = z.object({
|
||||
runId: z.string(),
|
||||
chunk: z.number().int().nonnegative(),
|
||||
cursor: CursorSchema.optional(),
|
||||
});
|
||||
|
||||
type ReconcileCursor = z.infer<typeof CursorSchema>;
|
||||
export type ReconcileAttachmentDecayPayload = z.infer<typeof PayloadSchema>;
|
||||
|
||||
interface ReconcileChunkLimits {
|
||||
messagePageSize: number;
|
||||
messagePages: number;
|
||||
}
|
||||
|
||||
const DEFAULT_CHUNK_LIMITS: ReconcileChunkLimits = {messagePageSize: 500, messagePages: 20};
|
||||
|
||||
interface ChunkProgress {
|
||||
messagePageSize: number;
|
||||
messagePagesLeft: number;
|
||||
channels: number;
|
||||
changed: number;
|
||||
}
|
||||
|
||||
async function attachmentFileExists(attachment: AttachmentDecayPayload): Promise<boolean> {
|
||||
const {storageService} = getWorkerDependencies();
|
||||
const key = makeAttachmentCdnKey(attachment.channelId, attachment.attachmentId, attachment.filename);
|
||||
return (await storageService.getObjectMetadata(Config.s3.buckets.cdn, key)) !== null;
|
||||
}
|
||||
|
||||
async function reconcileChannel(
|
||||
service: AttachmentDecayService,
|
||||
channelId: ChannelID,
|
||||
beforeMessageId: MessageID | undefined,
|
||||
progress: ChunkProgress,
|
||||
): Promise<MessageID | null> {
|
||||
const {channelRepository} = getWorkerDependencies();
|
||||
let cursor = beforeMessageId;
|
||||
while (true) {
|
||||
if (progress.messagePagesLeft <= 0 && cursor !== undefined) return cursor;
|
||||
const messages = await channelRepository.messages.listMessages(channelId, cursor, progress.messagePageSize);
|
||||
progress.messagePagesLeft--;
|
||||
if (messages.length === 0) break;
|
||||
progress.changed += await service.reconcile(
|
||||
messages.flatMap(attachmentDecayPayloadsForMessage),
|
||||
attachmentFileExists,
|
||||
);
|
||||
if (messages.length < progress.messagePageSize) break;
|
||||
cursor = messages[messages.length - 1]!.id;
|
||||
}
|
||||
progress.channels++;
|
||||
return null;
|
||||
}
|
||||
|
||||
async function reconcileFromCursor(
|
||||
service: AttachmentDecayService,
|
||||
repo: AttachmentDecayRepository,
|
||||
cursor: ReconcileCursor,
|
||||
progress: ChunkProgress,
|
||||
): Promise<ReconcileCursor | null> {
|
||||
let {pageState, channelId} = cursor;
|
||||
if (channelId !== null && cursor.beforeMessageId !== null) {
|
||||
const beforeMessageId = await reconcileChannel(
|
||||
service,
|
||||
createChannelID(BigInt(channelId)),
|
||||
createMessageID(BigInt(cursor.beforeMessageId)),
|
||||
progress,
|
||||
);
|
||||
if (beforeMessageId !== null) return {pageState, channelId, beforeMessageId: beforeMessageId.toString()};
|
||||
}
|
||||
while (true) {
|
||||
const page = await repo.scanChannelsWithMessagesPage(CHANNEL_PAGE_SIZE, pageState);
|
||||
const handled = channelId === null ? -1 : page.channelIds.findIndex((id) => id.toString() === channelId);
|
||||
for (const nextChannelId of page.channelIds.slice(handled + 1)) {
|
||||
if (progress.messagePagesLeft <= 0) return {pageState, channelId, beforeMessageId: null};
|
||||
channelId = nextChannelId.toString();
|
||||
const beforeMessageId = await reconcileChannel(service, nextChannelId, undefined, progress);
|
||||
if (beforeMessageId !== null) return {pageState, channelId, beforeMessageId: beforeMessageId.toString()};
|
||||
}
|
||||
if (page.pageState === null) return null;
|
||||
pageState = page.pageState;
|
||||
}
|
||||
}
|
||||
|
||||
export async function reconcileAttachmentDecayChunk(
|
||||
payload: ReconcileAttachmentDecayPayload,
|
||||
limits = DEFAULT_CHUNK_LIMITS,
|
||||
): Promise<ReconcileAttachmentDecayPayload | null> {
|
||||
const {runId, chunk} = payload;
|
||||
if (!Config.instance.selfHosted) {
|
||||
Logger.info({runId, chunk}, 'Attachment decay reconcile only runs on self-hosted instances; stopping');
|
||||
return null;
|
||||
}
|
||||
const {instanceConfigRepository, kvClient} = getWorkerDependencies();
|
||||
if (!(await holdAttachmentDecayReconcileRun(kvClient, runId))) {
|
||||
Logger.info({runId, chunk}, 'Attachment decay reconcile superseded by a newer run; stopping');
|
||||
return null;
|
||||
}
|
||||
const config = await instanceConfigRepository.fetchStoredEffectiveAttachmentDecayConfig();
|
||||
if (!config.enabled) {
|
||||
await finishAttachmentDecayReconcileRun(kvClient, runId);
|
||||
Logger.info({runId, chunk}, 'Attachment decay disabled; stopping reconcileAttachmentDecay task');
|
||||
return null;
|
||||
}
|
||||
const repo = new AttachmentDecayRepository();
|
||||
const service = new AttachmentDecayService(repo, async () => config);
|
||||
const progress: ChunkProgress = {
|
||||
messagePageSize: limits.messagePageSize,
|
||||
messagePagesLeft: limits.messagePages,
|
||||
channels: 0,
|
||||
changed: 0,
|
||||
};
|
||||
const cursor = await reconcileFromCursor(
|
||||
service,
|
||||
repo,
|
||||
payload.cursor ?? {pageState: null, channelId: null, beforeMessageId: null},
|
||||
progress,
|
||||
);
|
||||
const summary = {runId, chunk, channels: progress.channels, changed: progress.changed};
|
||||
if (cursor !== null) {
|
||||
Logger.info(summary, 'Reconciled a chunk of attachment decay records');
|
||||
return {runId, chunk: chunk + 1, cursor};
|
||||
}
|
||||
await finishAttachmentDecayReconcileRun(kvClient, runId);
|
||||
Logger.info(summary, 'Reconciled attachment decay records with the instance settings');
|
||||
return null;
|
||||
}
|
||||
|
||||
const reconcileAttachmentDecay: WorkerTaskHandler = async (payload, helpers) => {
|
||||
const next = await reconcileAttachmentDecayChunk(PayloadSchema.parse(payload));
|
||||
if (next === null) return;
|
||||
await helpers.addJob('reconcileAttachmentDecay', next, {
|
||||
jobKey: attachmentDecayReconcileJobKey(next.runId, next.chunk),
|
||||
skipLedger: true,
|
||||
});
|
||||
};
|
||||
|
||||
export default reconcileAttachmentDecay;
|
||||
@@ -0,0 +1,139 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {createTestAccount} from '@app/api/auth/tests/AuthTestUtils';
|
||||
import {createApplicationID, createUserID} from '@app/api/BrandedTypes';
|
||||
import {getGatewayService} from '@app/api/middleware/ServiceRegistry';
|
||||
import {
|
||||
createUserCacheService,
|
||||
getApplicationRepository,
|
||||
getGuildRepository,
|
||||
getUserRepository,
|
||||
} from '@app/api/middleware/ServiceSingletons';
|
||||
import {createOAuth2Application, createUniqueApplicationName} from '@app/api/oauth/tests/OAuth2TestUtils';
|
||||
import {type ApiTestHarness, createApiTestHarness} from '@app/api/test/ApiTestHarness';
|
||||
import {NoopLogger} from '@app/api/test/mocks/NoopLogger';
|
||||
import {HTTP_STATUS} from '@app/api/test/TestConstants';
|
||||
import {createBuilder, createBuilderWithoutAuth} from '@app/api/test/TestRequestBuilder';
|
||||
import applicationProcessDeletion from '@app/api/worker/tasks/ApplicationProcessDeletion';
|
||||
import {clearWorkerDependencies, setWorkerDependenciesForTest} from '@app/api/worker/WorkerContext';
|
||||
import {UserFlags} from '@fluxer/constants/src/UserConstants';
|
||||
import type {MessageResponse} from '@fluxer/schema/src/domains/message/MessageResponseSchemas';
|
||||
import type {WorkerTaskHelpers} from '@pkgs/worker/src/contracts/WorkerTask';
|
||||
import {afterAll, afterEach, beforeAll, beforeEach, describe, expect, test} from 'vitest';
|
||||
|
||||
function createHelpers(): WorkerTaskHelpers {
|
||||
return {
|
||||
logger: new NoopLogger(),
|
||||
jobId: 7200000000000000000n,
|
||||
addJob: async () => 0n,
|
||||
reportProgress: async () => {},
|
||||
shouldCancel: async () => false,
|
||||
setContextLink: async () => {},
|
||||
};
|
||||
}
|
||||
|
||||
describe('applicationProcessDeletion task', () => {
|
||||
let harness: ApiTestHarness;
|
||||
beforeAll(async () => {
|
||||
harness = await createApiTestHarness();
|
||||
});
|
||||
afterAll(async () => {
|
||||
await harness.shutdown();
|
||||
});
|
||||
beforeEach(async () => {
|
||||
await harness.resetData();
|
||||
setWorkerDependenciesForTest({
|
||||
userRepository: getUserRepository(),
|
||||
guildRepository: getGuildRepository(),
|
||||
applicationRepository: getApplicationRepository(),
|
||||
userCacheService: createUserCacheService(),
|
||||
gatewayService: getGatewayService(),
|
||||
});
|
||||
});
|
||||
afterEach(() => {
|
||||
clearWorkerDependencies();
|
||||
});
|
||||
|
||||
test('keeps the bot as author of its messages and clears its profile', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const createResult = await createOAuth2Application(harness, account.token, {
|
||||
name: createUniqueApplicationName(),
|
||||
});
|
||||
const botUserId = createUserID(BigInt(createResult.botUserId));
|
||||
const userRepository = getUserRepository();
|
||||
const botUser = await userRepository.findUniqueAssert(botUserId);
|
||||
await userRepository.patchUpsert(
|
||||
botUserId,
|
||||
{
|
||||
avatar_hash: 'a'.repeat(32),
|
||||
banner_hash: 'b'.repeat(32),
|
||||
bio: 'bot bio',
|
||||
pronouns: 'it/its',
|
||||
accent_color: 0x4641d9,
|
||||
},
|
||||
botUser.toRow(),
|
||||
);
|
||||
const guild = await createBuilder<{
|
||||
id: string;
|
||||
system_channel_id?: string;
|
||||
}>(harness, account.token)
|
||||
.post('/guilds')
|
||||
.body({name: `Bot Message Retention ${Date.now()}`})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
const channelId = guild.system_channel_id;
|
||||
if (!channelId) {
|
||||
throw new Error('Guild response missing system channel');
|
||||
}
|
||||
const seeded = await createBuilder<{
|
||||
messages: Array<{
|
||||
message_id: string;
|
||||
}>;
|
||||
}>(harness, '')
|
||||
.post('/test/messages/seed')
|
||||
.body({
|
||||
channel_id: channelId,
|
||||
author_id: createResult.botUserId,
|
||||
clear_existing: true,
|
||||
messages: [{content: 'bot message before deletion'}],
|
||||
})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
const botMessageId = seeded.messages[0]?.message_id;
|
||||
if (!botMessageId) {
|
||||
throw new Error('Seeded message response missing message id');
|
||||
}
|
||||
|
||||
await applicationProcessDeletion({applicationId: createResult.application.id}, createHelpers());
|
||||
|
||||
expect(
|
||||
await getApplicationRepository().getApplication(createApplicationID(BigInt(createResult.application.id))),
|
||||
).toBeNull();
|
||||
const deletedBot = await userRepository.findUniqueAssert(botUserId);
|
||||
expect(deletedBot.isBot).toBe(true);
|
||||
expect(deletedBot.flags & UserFlags.DELETED).toBe(UserFlags.DELETED);
|
||||
expect(deletedBot.username).toBe('DeletedUser');
|
||||
expect(deletedBot.avatarHash).toBeNull();
|
||||
expect(deletedBot.bannerHash).toBeNull();
|
||||
expect(deletedBot.bio).toBeNull();
|
||||
expect(deletedBot.pronouns).toBeNull();
|
||||
expect(deletedBot.accentColor).toBeNull();
|
||||
const messages = await createBuilder<Array<MessageResponse>>(harness, account.token)
|
||||
.get(`/channels/${channelId}/messages`)
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
const foundMessage = messages.find((message) => message.id === botMessageId);
|
||||
expect(foundMessage?.author.id).toBe(createResult.botUserId);
|
||||
expect(foundMessage?.author.bot).toBe(true);
|
||||
expect(foundMessage?.author.username).toBe('DeletedUser');
|
||||
expect(foundMessage?.author.discriminator).toBe('0000');
|
||||
expect(foundMessage?.author.avatar).toBeNull();
|
||||
const authorCount = await createBuilderWithoutAuth<{
|
||||
count: number;
|
||||
}>(harness)
|
||||
.get(`/test/users/${createResult.botUserId}/messages/count`)
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
expect(authorCount.count).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -2,8 +2,10 @@
|
||||
|
||||
import {AttachmentDecayRepository} from '@app/api/attachment/AttachmentDecayRepository';
|
||||
import {createAttachmentID, createChannelID, createMessageID} from '@app/api/BrandedTypes';
|
||||
import {getConfig} from '@app/api/Config';
|
||||
import type {IAssetDeletionQueue, QueuedAssetDeletion} from '@app/api/infrastructure/IAssetDeletionQueue';
|
||||
import type {InstanceConfigRepository} from '@app/api/instance/InstanceConfigRepository';
|
||||
import {MockKVProvider} from '@app/api/test/mocks/MockKVProvider';
|
||||
import {getExpiryBucket} from '@app/api/utils/AttachmentDecay';
|
||||
import {processExpiredAttachments} from '@app/api/worker/tasks/ExpireAttachments';
|
||||
import {clearWorkerDependencies, setWorkerDependenciesForTest} from '@app/api/worker/WorkerContext';
|
||||
@@ -28,9 +30,10 @@ function createQueue(): {queue: IAssetDeletionQueue; queued: Array<QueuedAssetDe
|
||||
return {queue, queued};
|
||||
}
|
||||
|
||||
function installDependencies(queue: IAssetDeletionQueue): void {
|
||||
function installDependencies(queue: IAssetDeletionQueue, kvClient = new MockKVProvider()): void {
|
||||
setWorkerDependenciesForTest({
|
||||
assetDeletionQueue: queue,
|
||||
kvClient,
|
||||
instanceConfigRepository: {
|
||||
async getEffectiveAttachmentDecayConfig() {
|
||||
return {enabled: true};
|
||||
@@ -57,10 +60,29 @@ async function writeDecayRecord(repository: AttachmentDecayRepository, expiresAt
|
||||
}
|
||||
|
||||
describe('processExpiredAttachments', () => {
|
||||
const originalSelfHosted = getConfig().instance.selfHosted;
|
||||
afterEach(() => {
|
||||
getConfig().instance.selfHosted = originalSelfHosted;
|
||||
clearWorkerDependencies();
|
||||
});
|
||||
|
||||
it.each([
|
||||
[true, []],
|
||||
[false, [`attachments/${CHANNEL_ID}/${ATTACHMENT_ID}/${FILENAME}`]],
|
||||
])('pauses deletion during a reconcile run only when self-hosted (%s)', async (selfHosted, expectedKeys) => {
|
||||
getConfig().instance.selfHosted = selfHosted;
|
||||
const repository = new AttachmentDecayRepository();
|
||||
const {queue, queued} = createQueue();
|
||||
const kvClient = new MockKVProvider();
|
||||
await kvClient.setex('attachment_decay:reconcile_run', 60, 'run');
|
||||
installDependencies(queue, kvClient);
|
||||
await writeDecayRecord(repository, FIRST_EXPIRY);
|
||||
|
||||
await processExpiredAttachments(new Date(FIRST_EXPIRY.getTime() + ms('1 day')));
|
||||
|
||||
expect(queued.map((item) => item.s3Key)).toEqual(expectedKeys);
|
||||
});
|
||||
|
||||
it('keeps the decay record when it clears a superseded expiry row', async () => {
|
||||
const repository = new AttachmentDecayRepository();
|
||||
const {queue} = createQueue();
|
||||
|
||||
@@ -22,6 +22,7 @@ import {useTabKeyFocusGuard} from '@app/features/app/hooks/useTabKeyFocusGuard';
|
||||
import {type LayoutVariant, LayoutVariantProvider} from '@app/features/app/state/LayoutVariantContext';
|
||||
import RuntimeConfig from '@app/features/app/state/RuntimeConfig';
|
||||
import RuntimeCrash from '@app/features/app/state/RuntimeCrash';
|
||||
import DeveloperOptions from '@app/features/devtools/state/DeveloperOptions';
|
||||
import {showMyselfTypingHelper} from '@app/features/devtools/utils/ShowMyselfTypingHelper';
|
||||
import GatewayConnection from '@app/features/gateway/transport/GatewayConnection';
|
||||
import GatewaySessions from '@app/features/gateway/transport/GatewaySessionPool';
|
||||
@@ -83,6 +84,14 @@ interface AppWrapperProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
function getInstanceThemeColor(): string | null {
|
||||
const snapshot = RuntimeConfig.getSnapshotOrNull();
|
||||
if (snapshot === null || !(DeveloperOptions.selfHostedModeOverride || snapshot.features.self_hosted)) {
|
||||
return null;
|
||||
}
|
||||
return snapshot.appPublic.branding.theme_color ?? null;
|
||||
}
|
||||
|
||||
export const AppWrapper = observer(({children}: AppWrapperProps) => {
|
||||
const {i18n} = useLingui();
|
||||
const reducedMotion = Accessibility.useReducedMotion;
|
||||
@@ -191,9 +200,7 @@ export const AppWrapper = observer(({children}: AppWrapperProps) => {
|
||||
messageGutter: Accessibility.messageGutter,
|
||||
messageGroupSpacing: Accessibility.getMessageGroupSpacingValue(messageDisplayCompact),
|
||||
hdrDisplayMode: Accessibility.hdrDisplayMode,
|
||||
instanceThemeColor: RuntimeConfig.isSelfHosted()
|
||||
? (RuntimeConfig.getSnapshotOrNull()?.appPublic.branding.theme_color ?? null)
|
||||
: null,
|
||||
instanceThemeColor: getInstanceThemeColor(),
|
||||
});
|
||||
useCustomThemeStyle({
|
||||
enabledThemeCss: ThemeLibrary.activeThemeCss,
|
||||
|
||||
@@ -35,8 +35,13 @@
|
||||
padding: 1.25rem;
|
||||
}
|
||||
|
||||
.contentScroller > .contentFrame {
|
||||
flex: 1 0 auto;
|
||||
}
|
||||
|
||||
.content {
|
||||
display: flex;
|
||||
flex: 1 0 auto;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
min-height: min(33rem, calc(100svh - 12.5rem));
|
||||
|
||||
@@ -41,6 +41,7 @@ import {
|
||||
ServicesStep,
|
||||
SignInMethodStep,
|
||||
ThemeStep,
|
||||
toSmtpTlsMode,
|
||||
WelcomeStep,
|
||||
} from '@app/features/app/components/setup/SetupWizardSteps';
|
||||
import {resolveAppShellBranding} from '@app/features/app/state/AppShellBranding';
|
||||
@@ -80,6 +81,7 @@ import {
|
||||
TagStyles,
|
||||
} from '@fluxer/constants/src/AccountIdentityConstants';
|
||||
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
|
||||
import type {SmtpTlsMode} from '@fluxer/constants/src/SmtpConstants';
|
||||
import {type ThemeType, ThemeTypes} from '@fluxer/constants/src/UserConstants';
|
||||
import type {InstanceConfigResponse} from '@fluxer/schema/src/domains/admin/AdminSchemas';
|
||||
import type {MessageDescriptor} from '@lingui/core';
|
||||
@@ -159,10 +161,10 @@ const STEP_NAVIGATION_LOCK_MS = 320;
|
||||
|
||||
const BRANDING_ASSET_KINDS: ReadonlyArray<SetupBrandingAssetKind> = ['icon', 'symbol', 'logo', 'wordmark', 'favicon'];
|
||||
|
||||
function hexToNumber(value: string | null): number {
|
||||
if (!value) return 0;
|
||||
function hexToNumber(value: string | null): number | null {
|
||||
if (!value) return null;
|
||||
const match = /^#?([0-9a-fA-F]{6})$/.exec(value.trim());
|
||||
if (!match) return 0;
|
||||
if (!match) return null;
|
||||
return parseInt(match[1], 16) >>> 0;
|
||||
}
|
||||
|
||||
@@ -199,7 +201,7 @@ const DEFAULT_INTEGRATION_DRAFT: ServiceIntegrationDraft = {
|
||||
smtpPort: '587',
|
||||
smtpUsername: '',
|
||||
smtpPassword: '',
|
||||
smtpSecure: true,
|
||||
smtpTlsMode: 'auto',
|
||||
blueskyMode: 'later',
|
||||
blueskyEnabled: true,
|
||||
blueskyClientName: '',
|
||||
@@ -345,7 +347,7 @@ function buildIntegrationsPatch(draft: ServiceIntegrationDraft, accountIdentity:
|
||||
port: number;
|
||||
username: string;
|
||||
password: string;
|
||||
secure: boolean;
|
||||
tls_mode: SmtpTlsMode;
|
||||
};
|
||||
};
|
||||
bluesky?: {
|
||||
@@ -365,6 +367,7 @@ function buildIntegrationsPatch(draft: ServiceIntegrationDraft, accountIdentity:
|
||||
integrations.youtube = {api_key: draft.youtubeApiKey.trim()};
|
||||
}
|
||||
if (draft.emailMode === 'configure' && accountIdentity === AccountIdentityModes.EMAIL) {
|
||||
const smtpPort = parsePort(draft.smtpPort) ?? 587;
|
||||
integrations.email = {
|
||||
enabled: draft.emailEnabled,
|
||||
provider: 'smtp',
|
||||
@@ -372,10 +375,10 @@ function buildIntegrationsPatch(draft: ServiceIntegrationDraft, accountIdentity:
|
||||
from_name: draft.emailFromName.trim(),
|
||||
smtp: {
|
||||
host: draft.smtpHost.trim(),
|
||||
port: parsePort(draft.smtpPort) ?? 587,
|
||||
port: smtpPort,
|
||||
username: draft.smtpUsername.trim(),
|
||||
password: draft.smtpPassword.trim(),
|
||||
secure: draft.smtpSecure,
|
||||
tls_mode: toSmtpTlsMode(draft.smtpTlsMode, smtpPort),
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -460,7 +463,7 @@ export const SelfHostedSetupWizardGate = observer(() => {
|
||||
const [creatingRecoveryKit, setCreatingRecoveryKit] = useState(false);
|
||||
|
||||
const [productName, setProductName] = useState('');
|
||||
const [themeColor, setThemeColor] = useState(0);
|
||||
const [themeColor, setThemeColor] = useState<number | null>(null);
|
||||
const [registrationMode, setRegistrationMode] = useState<RegistrationMode>('open');
|
||||
const [singleCommunityEnabled, setSingleCommunityEnabled] = useState(false);
|
||||
const [singleCommunityName, setSingleCommunityName] = useState('');
|
||||
@@ -599,7 +602,7 @@ export const SelfHostedSetupWizardGate = observer(() => {
|
||||
smtpHost: next.integrations.email.smtp.host ?? current.smtpHost,
|
||||
smtpPort: next.integrations.email.smtp.port ? String(next.integrations.email.smtp.port) : current.smtpPort,
|
||||
smtpUsername: next.integrations.email.smtp.username ?? current.smtpUsername,
|
||||
smtpSecure: next.integrations.email.smtp.secure ?? current.smtpSecure,
|
||||
smtpTlsMode: next.integrations.email.smtp.tls_mode ?? current.smtpTlsMode,
|
||||
blueskyEnabled: next.integrations.bluesky.effective_enabled || next.integrations.bluesky.enabled !== false,
|
||||
blueskyClientName: next.integrations.bluesky.client_name ?? current.blueskyClientName,
|
||||
blueskyClientUri: next.integrations.bluesky.client_uri ?? current.blueskyClientUri,
|
||||
@@ -864,7 +867,7 @@ export const SelfHostedSetupWizardGate = observer(() => {
|
||||
port,
|
||||
username: integrationDraft.smtpUsername.trim(),
|
||||
password: integrationDraft.smtpPassword,
|
||||
secure: integrationDraft.smtpSecure,
|
||||
tls_mode: toSmtpTlsMode(integrationDraft.smtpTlsMode, port),
|
||||
});
|
||||
setSmtpTestResult(result.ok ? 'ok' : (result.error ?? 'failed'));
|
||||
} catch (error) {
|
||||
@@ -899,7 +902,7 @@ export const SelfHostedSetupWizardGate = observer(() => {
|
||||
app_public: {
|
||||
branding: {
|
||||
product_name: productNameTrimmed,
|
||||
theme_color: numberToHexColor(themeColor),
|
||||
theme_color: themeColor === null ? null : numberToHexColor(themeColor),
|
||||
},
|
||||
setup: {configured: true},
|
||||
},
|
||||
@@ -1004,7 +1007,11 @@ export const SelfHostedSetupWizardGate = observer(() => {
|
||||
hideCloseButton
|
||||
data-flx="app.self-hosted-setup-wizard-gate.header"
|
||||
/>
|
||||
<Modal.Content className={styles.contentScroller} data-flx="app.self-hosted-setup-wizard-gate.content">
|
||||
<Modal.Content
|
||||
className={styles.contentScroller}
|
||||
contentClassName={styles.contentFrame}
|
||||
data-flx="app.self-hosted-setup-wizard-gate.content"
|
||||
>
|
||||
<div className={styles.content} data-flx="app.self-hosted-setup-wizard-gate.content-inner">
|
||||
{loadError ? (
|
||||
<div className={styles.centeredStep} data-flx="app.self-hosted-setup-wizard-gate.load-error-wrap">
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
type TagStyle,
|
||||
TagStyles,
|
||||
} from '@fluxer/constants/src/AccountIdentityConstants';
|
||||
import {resolveSmtpTlsMode, type SmtpTlsMode, SmtpTlsModes} from '@fluxer/constants/src/SmtpConstants';
|
||||
import type {ThemeType} from '@fluxer/constants/src/UserConstants';
|
||||
import type {MessageDescriptor} from '@lingui/core';
|
||||
import {msg} from '@lingui/core/macro';
|
||||
@@ -33,6 +34,7 @@ import type React from 'react';
|
||||
import {useCallback} from 'react';
|
||||
|
||||
const PUSH_RELAY_NOTICE_URL = 'https://fluxer.com/push-relay';
|
||||
const DEFAULT_THEME_COLOR = 0x4641d9;
|
||||
|
||||
export type RegistrationMode = 'open' | 'approval' | 'closed';
|
||||
export type PremiumMode = 'mirror' | 'everyone';
|
||||
@@ -843,11 +845,11 @@ export const BrandingStep = observer(
|
||||
}: {
|
||||
productName: string;
|
||||
productNameError: boolean;
|
||||
themeColor: number;
|
||||
themeColor: number | null;
|
||||
assets: ReadonlyArray<BrandingAssetState>;
|
||||
disabled: boolean;
|
||||
onProductNameChange: (value: string) => void;
|
||||
onThemeColorChange: (value: number) => void;
|
||||
onThemeColorChange: (value: number | null) => void;
|
||||
onUploadAsset: (kind: SetupBrandingAssetKind) => void;
|
||||
onClearAsset: (kind: SetupBrandingAssetKind) => void;
|
||||
}) => {
|
||||
@@ -872,8 +874,11 @@ export const BrandingStep = observer(
|
||||
/>
|
||||
<ColorPickerField
|
||||
label={i18n._(THEME_COLOR_LABEL_DESCRIPTOR)}
|
||||
value={themeColor}
|
||||
value={themeColor ?? DEFAULT_THEME_COLOR}
|
||||
onChange={onThemeColorChange}
|
||||
defaultValue={DEFAULT_THEME_COLOR}
|
||||
isDefaultValue={themeColor === null}
|
||||
onReset={() => onThemeColorChange(null)}
|
||||
disabled={disabled}
|
||||
data-flx="app.self-hosted-setup-wizard-gate.theme-color-field"
|
||||
/>
|
||||
@@ -1046,7 +1051,7 @@ export interface ServiceIntegrationDraft {
|
||||
smtpPort: string;
|
||||
smtpUsername: string;
|
||||
smtpPassword: string;
|
||||
smtpSecure: boolean;
|
||||
smtpTlsMode: SmtpTlsModeChoice;
|
||||
blueskyMode: IntegrationSetupMode;
|
||||
blueskyEnabled: boolean;
|
||||
blueskyClientName: string;
|
||||
@@ -1123,9 +1128,49 @@ const SMTP_HOST_LABEL_DESCRIPTOR = msg({message: 'SMTP host', comment: 'Label fo
|
||||
const SMTP_PORT_LABEL_DESCRIPTOR = msg({message: 'Port', comment: 'Label for SMTP port input.'});
|
||||
const SMTP_USERNAME_LABEL_DESCRIPTOR = msg({message: 'Username', comment: 'Label for SMTP username input.'});
|
||||
const SMTP_PASSWORD_LABEL_DESCRIPTOR = msg({message: 'Password', comment: 'Label for SMTP password input.'});
|
||||
const SMTP_SECURE_LABEL_DESCRIPTOR = msg({
|
||||
message: 'Use TLS',
|
||||
comment: 'Label for SMTP TLS toggle.',
|
||||
const SMTP_TLS_MODE_LABEL_DESCRIPTOR = msg({
|
||||
message: 'Encryption',
|
||||
comment: 'Label for the choice of how the SMTP connection is encrypted.',
|
||||
});
|
||||
const SMTP_TLS_MODE_AUTO_NAME_DESCRIPTOR = msg({
|
||||
message: 'Automatic',
|
||||
comment: 'SMTP encryption option that picks the mode from the port.',
|
||||
});
|
||||
const SMTP_TLS_MODE_AUTO_DESC_DESCRIPTOR = msg({
|
||||
message: 'Implicit TLS on port 465, required STARTTLS on any other port.',
|
||||
comment: 'Description of the automatic SMTP encryption option.',
|
||||
});
|
||||
const SMTP_TLS_MODE_IMPLICIT_NAME_DESCRIPTOR = msg({
|
||||
message: 'Implicit TLS',
|
||||
comment: 'SMTP encryption option where the connection starts encrypted.',
|
||||
});
|
||||
const SMTP_TLS_MODE_IMPLICIT_DESC_DESCRIPTOR = msg({
|
||||
message: 'The connection is encrypted from the start. Usually port 465.',
|
||||
comment: 'Description of the implicit TLS SMTP encryption option.',
|
||||
});
|
||||
const SMTP_TLS_MODE_STARTTLS_NAME_DESCRIPTOR = msg({
|
||||
message: 'STARTTLS',
|
||||
comment: 'SMTP encryption option where a plain connection must upgrade with STARTTLS.',
|
||||
});
|
||||
const SMTP_TLS_MODE_STARTTLS_DESC_DESCRIPTOR = msg({
|
||||
message: 'The connection must upgrade to TLS before signing in. Usually port 587.',
|
||||
comment: 'Description of the required STARTTLS SMTP encryption option.',
|
||||
});
|
||||
const SMTP_TLS_MODE_OPPORTUNISTIC_NAME_DESCRIPTOR = msg({
|
||||
message: 'STARTTLS when offered',
|
||||
comment: 'SMTP encryption option where STARTTLS is used only if the server offers it.',
|
||||
});
|
||||
const SMTP_TLS_MODE_OPPORTUNISTIC_DESC_DESCRIPTOR = msg({
|
||||
message: 'The connection upgrades to TLS only if the server offers it.',
|
||||
comment: 'Description of the opportunistic STARTTLS SMTP encryption option.',
|
||||
});
|
||||
const SMTP_TLS_MODE_NONE_NAME_DESCRIPTOR = msg({
|
||||
message: 'None',
|
||||
comment: 'SMTP encryption option that never uses TLS.',
|
||||
});
|
||||
const SMTP_TLS_MODE_NONE_DESC_DESCRIPTOR = msg({
|
||||
message: 'The connection is never encrypted. Only for a mail server on a network you trust.',
|
||||
comment: 'Description of the SMTP encryption option that never uses TLS.',
|
||||
});
|
||||
const SMTP_TEST_DESCRIPTOR = msg({
|
||||
message: 'Test SMTP',
|
||||
@@ -1172,6 +1217,44 @@ const BLUESKY_PRIVATE_KEY_LABEL_DESCRIPTOR = msg({
|
||||
comment: 'Label for Bluesky OAuth private key input.',
|
||||
});
|
||||
|
||||
export type SmtpTlsModeChoice = SmtpTlsMode | 'auto';
|
||||
|
||||
export function toSmtpTlsMode(choice: SmtpTlsModeChoice, port: number): SmtpTlsMode {
|
||||
return choice === 'auto' ? resolveSmtpTlsMode(null, port) : choice;
|
||||
}
|
||||
|
||||
function buildSmtpTlsModeOptions(
|
||||
i18n: ReturnType<typeof useLingui>['i18n'],
|
||||
): ReadonlyArray<RadioOption<SmtpTlsModeChoice>> {
|
||||
return [
|
||||
{
|
||||
value: 'auto',
|
||||
name: i18n._(SMTP_TLS_MODE_AUTO_NAME_DESCRIPTOR),
|
||||
desc: i18n._(SMTP_TLS_MODE_AUTO_DESC_DESCRIPTOR),
|
||||
},
|
||||
{
|
||||
value: SmtpTlsModes.IMPLICIT,
|
||||
name: i18n._(SMTP_TLS_MODE_IMPLICIT_NAME_DESCRIPTOR),
|
||||
desc: i18n._(SMTP_TLS_MODE_IMPLICIT_DESC_DESCRIPTOR),
|
||||
},
|
||||
{
|
||||
value: SmtpTlsModes.STARTTLS,
|
||||
name: i18n._(SMTP_TLS_MODE_STARTTLS_NAME_DESCRIPTOR),
|
||||
desc: i18n._(SMTP_TLS_MODE_STARTTLS_DESC_DESCRIPTOR),
|
||||
},
|
||||
{
|
||||
value: SmtpTlsModes.OPPORTUNISTIC,
|
||||
name: i18n._(SMTP_TLS_MODE_OPPORTUNISTIC_NAME_DESCRIPTOR),
|
||||
desc: i18n._(SMTP_TLS_MODE_OPPORTUNISTIC_DESC_DESCRIPTOR),
|
||||
},
|
||||
{
|
||||
value: SmtpTlsModes.NONE,
|
||||
name: i18n._(SMTP_TLS_MODE_NONE_NAME_DESCRIPTOR),
|
||||
desc: i18n._(SMTP_TLS_MODE_NONE_DESC_DESCRIPTOR),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function buildIntegrationModeOptions(
|
||||
i18n: ReturnType<typeof useLingui>['i18n'],
|
||||
): ReadonlyArray<RadioOption<IntegrationSetupMode>> {
|
||||
@@ -1490,12 +1573,19 @@ export const IntegrationStep = observer(
|
||||
disabled={disabled}
|
||||
data-flx="app.setup.setup-wizard-steps.integration-step.input.draft-change.password--2"
|
||||
/>
|
||||
<Switch
|
||||
label={i18n._(SMTP_SECURE_LABEL_DESCRIPTOR)}
|
||||
value={draft.smtpSecure}
|
||||
onChange={(value) => onDraftChange({smtpSecure: value})}
|
||||
<div
|
||||
className={styles.fieldLabel}
|
||||
data-flx="app.setup.setup-wizard-steps.integration-step.smtp-tls-mode-label"
|
||||
>
|
||||
{i18n._(SMTP_TLS_MODE_LABEL_DESCRIPTOR)}
|
||||
</div>
|
||||
<RadioGroup
|
||||
options={buildSmtpTlsModeOptions(i18n)}
|
||||
value={draft.smtpTlsMode}
|
||||
onChange={(value) => onDraftChange({smtpTlsMode: value})}
|
||||
disabled={disabled}
|
||||
data-flx="app.setup.setup-wizard-steps.integration-step.switch.draft-change--2"
|
||||
aria-label={i18n._(SMTP_TLS_MODE_LABEL_DESCRIPTOR)}
|
||||
data-flx="app.setup.setup-wizard-steps.integration-step.radio-group.smtp-tls-mode"
|
||||
/>
|
||||
<div
|
||||
className={styles.integrationActionRow}
|
||||
|
||||
@@ -67,7 +67,6 @@ export const STATIC_IMAGE_WITH_AVIF_FORMATS = formatAssetUploadExtensions('splas
|
||||
export const ANIMATED_IMAGE_FORMATS = formatAssetUploadExtensions('avatar');
|
||||
export const ANIMATED_AVATAR_FORMATS = formatKnownAnimatedAssetExtensions('avatar');
|
||||
export const AVIF_FORMAT_LABEL = 'AVIF';
|
||||
export const IMAGE_MAX_SIZE_BYTES = 10 * 1024 * 1024;
|
||||
export const BACKGROUND_MEDIA_MAX_SIZE_BYTES = 10 * 1024 * 1024;
|
||||
export const CUSTOM_SOUND_MAX_SIZE_BYTES = 2 * 1024 * 1024;
|
||||
export const AVATAR_RECOMMENDED_SIZE_LABEL = '512×512px';
|
||||
|
||||
@@ -1,25 +1,14 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {LimitResolver} from '@app/features/app/utils/LimitResolverAdapter';
|
||||
|
||||
const FALLBACKS = {
|
||||
emoji_max_size: 384 * 1024,
|
||||
sticker_max_size: 512 * 1024,
|
||||
avatar_max_size: 10 * 1024 * 1024,
|
||||
} as const;
|
||||
import {AVATAR_MAX_SIZE} from '@fluxer/constants/src/LimitConstants';
|
||||
|
||||
class GlobalLimitsClass {
|
||||
getEmojiMaxSize(): number {
|
||||
getAvatarMaxSize(): number {
|
||||
return LimitResolver.resolve({
|
||||
key: 'emoji_max_size',
|
||||
fallback: FALLBACKS.emoji_max_size,
|
||||
});
|
||||
}
|
||||
|
||||
getStickerMaxSize(): number {
|
||||
return LimitResolver.resolve({
|
||||
key: 'sticker_max_size',
|
||||
fallback: FALLBACKS.sticker_max_size,
|
||||
key: 'avatar_max_size',
|
||||
fallback: AVATAR_MAX_SIZE,
|
||||
context: {traits: [], guildFeatures: []},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {AVATAR_MAX_SIZE, EMOJI_MAX_SIZE, STICKER_MAX_SIZE} from '@fluxer/constants/src/LimitConstants';
|
||||
import type {LimitConfigSnapshot} from '@fluxer/limits/src/LimitTypes';
|
||||
import type {Guild as WireGuild} from '@fluxer/schema/src/domains/guild/GuildResponseSchemas';
|
||||
import {beforeEach, describe, expect, it, vi} from 'vitest';
|
||||
|
||||
const RAISED = 5 * 1024 * 1024;
|
||||
const state: {limits: LimitConfigSnapshot} = {limits: {traitDefinitions: [], rules: []}};
|
||||
|
||||
vi.mock('@app/features/app/state/RuntimeConfig', () => ({
|
||||
default: {
|
||||
localInstanceDomain: 'fluxer.test',
|
||||
get limits() {
|
||||
return state.limits;
|
||||
},
|
||||
getSnapshotOrNull: () => ({limits: state.limits}),
|
||||
},
|
||||
}));
|
||||
vi.mock('@app/features/app/state/InstanceSnapshotStore', () => ({default: {getLimitsForInstance: () => null}}));
|
||||
vi.mock('@app/features/user/state/Users', () => ({default: {getCurrentUser: () => ({traits: ['premium']})}}));
|
||||
vi.mock('@app/features/channel/state/Channels', () => ({default: {getChannel: () => null}}));
|
||||
vi.mock('@app/features/guild/state/Guilds', () => ({default: {getGuild: () => null}}));
|
||||
vi.mock('@app/features/navigation/state/SelectedChannel', () => ({default: {currentChannelId: null}}));
|
||||
vi.mock('@app/features/navigation/state/SelectedGuild', () => ({default: {selectedGuildId: null}}));
|
||||
vi.mock('@app/features/theme/fonts/ScriptFontLoader', () => ({noteText: () => {}}));
|
||||
|
||||
const {Guild} = await import('@app/features/guild/models/Guild');
|
||||
const {GlobalLimits} = await import('@app/features/app/utils/GlobalLimits');
|
||||
|
||||
function guild(features: Array<string> = []) {
|
||||
return new Guild({id: '1', name: 'guild', owner_id: '2', features, roles: []} as unknown as WireGuild);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
state.limits = {traitDefinitions: ['premium'], rules: [{id: 'default', limits: {}}]};
|
||||
});
|
||||
|
||||
describe('configured image size limits', () => {
|
||||
it('falls back to the stock sizes when nothing is configured', () => {
|
||||
expect(guild().maxEmojiSize).toBe(EMOJI_MAX_SIZE);
|
||||
expect(guild().maxStickerSize).toBe(STICKER_MAX_SIZE);
|
||||
expect(GlobalLimits.getAvatarMaxSize()).toBe(AVATAR_MAX_SIZE);
|
||||
});
|
||||
|
||||
it('follows a raised default rule', () => {
|
||||
state.limits.rules[0].limits = {emoji_max_size: RAISED, sticker_max_size: RAISED, avatar_max_size: RAISED * 6};
|
||||
expect(guild().maxEmojiSize).toBe(RAISED);
|
||||
expect(guild().maxStickerSize).toBe(RAISED);
|
||||
expect(GlobalLimits.getAvatarMaxSize()).toBe(RAISED * 6);
|
||||
});
|
||||
|
||||
it('applies guild feature rules to emoji and sticker sizes', () => {
|
||||
state.limits.rules.push({
|
||||
id: 'boosted',
|
||||
filters: {guildFeatures: ['MORE_EMOJI']},
|
||||
limits: {emoji_max_size: RAISED, sticker_max_size: RAISED},
|
||||
});
|
||||
expect(guild().maxEmojiSize).toBe(EMOJI_MAX_SIZE);
|
||||
expect(guild(['MORE_EMOJI']).maxEmojiSize).toBe(RAISED);
|
||||
expect(guild(['MORE_EMOJI']).maxStickerSize).toBe(RAISED);
|
||||
});
|
||||
|
||||
it('ignores trait rules the server does not apply', () => {
|
||||
state.limits.rules.push({
|
||||
id: 'premium',
|
||||
filters: {traits: ['premium']},
|
||||
limits: {emoji_max_size: RAISED, sticker_max_size: RAISED, avatar_max_size: RAISED * 6},
|
||||
});
|
||||
expect(guild().maxEmojiSize).toBe(EMOJI_MAX_SIZE);
|
||||
expect(guild().maxStickerSize).toBe(STICKER_MAX_SIZE);
|
||||
expect(GlobalLimits.getAvatarMaxSize()).toBe(AVATAR_MAX_SIZE);
|
||||
});
|
||||
});
|
||||
@@ -1,11 +1,8 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {
|
||||
AVATAR_RECOMMENDED_SIZE_LABEL,
|
||||
IMAGE_MAX_SIZE_BYTES,
|
||||
STATIC_IMAGE_FORMATS,
|
||||
} from '@app/features/app/config/I18nDisplayConstants';
|
||||
import {AVATAR_RECOMMENDED_SIZE_LABEL, STATIC_IMAGE_FORMATS} from '@app/features/app/config/I18nDisplayConstants';
|
||||
import {useFormSubmit} from '@app/features/app/hooks/useFormSubmit';
|
||||
import {GlobalLimits} from '@app/features/app/utils/GlobalLimits';
|
||||
import Authentication from '@app/features/auth/state/Authentication';
|
||||
import * as ChannelCommands from '@app/features/channel/commands/ChannelCommands';
|
||||
import {showChannelErrorModal} from '@app/features/channel/components/alerts/ChannelErrorModalUtils';
|
||||
@@ -85,11 +82,11 @@ export function useEditGroupForm({
|
||||
async (file: File | null) => {
|
||||
try {
|
||||
if (!file) return;
|
||||
if (file.size > 10 * 1024 * 1024) {
|
||||
if (file.size > GlobalLimits.getAvatarMaxSize()) {
|
||||
showChannelErrorModal({
|
||||
title: i18n._(messages.iconFileTooLargeTitle),
|
||||
message: i18n._(messages.iconFileTooLarge, {
|
||||
imageMaxSizeLabel: formatFileSize(i18n.locale, IMAGE_MAX_SIZE_BYTES),
|
||||
imageMaxSizeLabel: formatFileSize(i18n.locale, GlobalLimits.getAvatarMaxSize()),
|
||||
}),
|
||||
dataFlx: `${dataFlx}.icon-file-too-large.generic-error-modal`,
|
||||
});
|
||||
@@ -166,7 +163,7 @@ export function useEditGroupForm({
|
||||
title: i18n._(messages.changeIcon),
|
||||
uploadHint: formatImageUploadRecommendedHint(i18n, {
|
||||
formats: STATIC_IMAGE_FORMATS,
|
||||
maxSize: formatFileSize(i18n.locale, IMAGE_MAX_SIZE_BYTES),
|
||||
maxSize: formatFileSize(i18n.locale, GlobalLimits.getAvatarMaxSize()),
|
||||
recommendedSize: AVATAR_RECOMMENDED_SIZE_LABEL,
|
||||
}),
|
||||
onPickUpload: handleIconUploadClick,
|
||||
|
||||
@@ -4,12 +4,12 @@ import {GenericErrorModal} from '@app/features/app/components/alerts/GenericErro
|
||||
import {ConfirmModal} from '@app/features/app/components/dialogs/ConfirmModal';
|
||||
import * as Modal from '@app/features/app/components/dialogs/Modal';
|
||||
import {useFormSubmit} from '@app/features/app/hooks/useFormSubmit';
|
||||
import {GlobalLimits} from '@app/features/app/utils/GlobalLimits';
|
||||
import * as GuildStickerCommands from '@app/features/expressions/commands/GuildStickerCommands';
|
||||
import styles from '@app/features/expressions/components/modals/AddGuildStickerModal.module.css';
|
||||
import {StickerFormFields} from '@app/features/expressions/components/modals/sticker_form/StickerFormFields';
|
||||
import {StickerPreview} from '@app/features/expressions/components/modals/sticker_form/StickerPreview';
|
||||
import * as ImageCropUtils from '@app/features/expressions/utils/ImageCropUtils';
|
||||
import Guilds from '@app/features/guild/state/Guilds';
|
||||
import {CREATE_DESCRIPTOR} from '@app/features/i18n/utils/CommonMessageDescriptors';
|
||||
import {formatFileSize} from '@app/features/messaging/utils/FileUtils';
|
||||
import {HttpError} from '@app/features/platform/types/EndpointError';
|
||||
@@ -20,6 +20,7 @@ import * as ModalCommands from '@app/features/ui/commands/ModalCommands';
|
||||
import {Form} from '@app/features/ui/components/form/Form';
|
||||
import * as FormUtils from '@app/lib/forms';
|
||||
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
|
||||
import {STICKER_MAX_SIZE} from '@fluxer/constants/src/LimitConstants';
|
||||
import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes';
|
||||
import type {I18n} from '@lingui/core';
|
||||
import {msg} from '@lingui/core/macro';
|
||||
@@ -143,7 +144,7 @@ export const AddGuildStickerModal = observer(function AddGuildStickerModal({
|
||||
}, [file]);
|
||||
const onSubmit = useCallback(
|
||||
async (data: FormInputs) => {
|
||||
const maxStickerSize = GlobalLimits.getStickerMaxSize();
|
||||
const maxStickerSize = Guilds.getGuild(guildId)?.maxStickerSize ?? STICKER_MAX_SIZE;
|
||||
setIsProcessing(true);
|
||||
try {
|
||||
const base64Image = await ImageCropUtils.optimizeStickerImage(file, maxStickerSize, 320);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {GlobalLimits} from '@app/features/app/utils/GlobalLimits';
|
||||
import {
|
||||
getAspectRatioRange,
|
||||
type ImageDimensions,
|
||||
@@ -134,7 +135,6 @@ interface AssetConfig {
|
||||
maxHeight: number;
|
||||
minWidth: number;
|
||||
minHeight: number;
|
||||
sizeLimitBytes: number;
|
||||
minHeightRatio?: number;
|
||||
maxHeightRatio?: number;
|
||||
}
|
||||
@@ -147,7 +147,6 @@ const ASSET_CONFIGS: Record<AssetType, AssetConfig> = {
|
||||
maxHeight: 1024,
|
||||
minWidth: 256,
|
||||
minHeight: 256,
|
||||
sizeLimitBytes: 10 * 1024 * 1024,
|
||||
},
|
||||
[AssetType.GUILD_ICON]: {
|
||||
aspectRatio: 1,
|
||||
@@ -156,7 +155,6 @@ const ASSET_CONFIGS: Record<AssetType, AssetConfig> = {
|
||||
maxHeight: 1024,
|
||||
minWidth: 256,
|
||||
minHeight: 256,
|
||||
sizeLimitBytes: 10 * 1024 * 1024,
|
||||
},
|
||||
[AssetType.CHANNEL_ICON]: {
|
||||
aspectRatio: 1,
|
||||
@@ -165,7 +163,6 @@ const ASSET_CONFIGS: Record<AssetType, AssetConfig> = {
|
||||
maxHeight: 1024,
|
||||
minWidth: 256,
|
||||
minHeight: 256,
|
||||
sizeLimitBytes: 10 * 1024 * 1024,
|
||||
},
|
||||
[AssetType.GUILD_BANNER]: {
|
||||
aspectRatio: 16 / 9,
|
||||
@@ -174,7 +171,6 @@ const ASSET_CONFIGS: Record<AssetType, AssetConfig> = {
|
||||
maxHeight: 1152,
|
||||
minWidth: 960,
|
||||
minHeight: 540,
|
||||
sizeLimitBytes: 10 * 1024 * 1024,
|
||||
minHeightRatio: 0.5,
|
||||
maxHeightRatio: 1,
|
||||
},
|
||||
@@ -185,7 +181,6 @@ const ASSET_CONFIGS: Record<AssetType, AssetConfig> = {
|
||||
maxHeight: 723,
|
||||
minWidth: 680,
|
||||
minHeight: 240,
|
||||
sizeLimitBytes: 10 * 1024 * 1024,
|
||||
},
|
||||
[AssetType.SPLASH]: {
|
||||
aspectRatio: 16 / 9,
|
||||
@@ -194,7 +189,6 @@ const ASSET_CONFIGS: Record<AssetType, AssetConfig> = {
|
||||
maxHeight: 1152,
|
||||
minWidth: 960,
|
||||
minHeight: 540,
|
||||
sizeLimitBytes: 10 * 1024 * 1024,
|
||||
minHeightRatio: 0.5,
|
||||
maxHeightRatio: 1,
|
||||
},
|
||||
@@ -205,7 +199,6 @@ const ASSET_CONFIGS: Record<AssetType, AssetConfig> = {
|
||||
maxHeight: 1152,
|
||||
minWidth: 960,
|
||||
minHeight: 540,
|
||||
sizeLimitBytes: 10 * 1024 * 1024,
|
||||
minHeightRatio: 0.5,
|
||||
maxHeightRatio: 1,
|
||||
},
|
||||
@@ -317,7 +310,7 @@ export const AssetCropModal: React.FC<AssetCropModalProps> = observer(
|
||||
cropShape={config.cropShape}
|
||||
maxWidth={config.maxWidth}
|
||||
maxHeight={config.maxHeight}
|
||||
sizeLimitBytes={config.sizeLimitBytes}
|
||||
sizeLimitBytes={GlobalLimits.getAvatarMaxSize()}
|
||||
minHeightRatio={config.minHeightRatio}
|
||||
maxHeightRatio={config.maxHeightRatio}
|
||||
canSkipOriginal={(dimensions) => canSkipOriginalAssetImage(assetType, dimensions)}
|
||||
|
||||
+5
-8
@@ -3,12 +3,9 @@
|
||||
import {Routes} from '@app/app/Routes';
|
||||
import {UserSettingsModal} from '@app/features/app/components/dialogs/LoadableSettingsModals';
|
||||
import {ExternalLink} from '@app/features/app/components/shared/ExternalLink';
|
||||
import {
|
||||
AVATAR_RECOMMENDED_SIZE_LABEL,
|
||||
IMAGE_MAX_SIZE_BYTES,
|
||||
STATIC_IMAGE_FORMATS,
|
||||
} from '@app/features/app/config/I18nDisplayConstants';
|
||||
import {AVATAR_RECOMMENDED_SIZE_LABEL, STATIC_IMAGE_FORMATS} from '@app/features/app/config/I18nDisplayConstants';
|
||||
import RuntimeConfig from '@app/features/app/state/RuntimeConfig';
|
||||
import {GlobalLimits} from '@app/features/app/utils/GlobalLimits';
|
||||
import {openClaimAccountModal} from '@app/features/auth/components/modals/ClaimAccountModal';
|
||||
import {AssetCropModal, AssetType} from '@app/features/expressions/components/modals/AssetCropModal';
|
||||
import {openAssetSourceModal} from '@app/features/expressions/components/modals/AssetSourceModal';
|
||||
@@ -95,10 +92,10 @@ export function useGuildCreateIconField({
|
||||
try {
|
||||
const [file] = await openFilePicker({accept: getAcceptStringFiltered('guild_icon', false)});
|
||||
if (!file) return;
|
||||
if (file.size > 10 * 1024 * 1024) {
|
||||
if (file.size > GlobalLimits.getAvatarMaxSize()) {
|
||||
showIconUploadErrorModal(
|
||||
i18n._(ICON_FILE_IS_TOO_LARGE_PLEASE_CHOOSE_A_DESCRIPTOR, {
|
||||
imageMaxSizeLabel: formatFileSize(i18n.locale, IMAGE_MAX_SIZE_BYTES),
|
||||
imageMaxSizeLabel: formatFileSize(i18n.locale, GlobalLimits.getAvatarMaxSize()),
|
||||
}),
|
||||
);
|
||||
return;
|
||||
@@ -150,7 +147,7 @@ export function useGuildCreateIconField({
|
||||
title: i18n._(messages.changeIcon),
|
||||
uploadHint: formatImageUploadRecommendedHint(i18n, {
|
||||
formats: STATIC_IMAGE_FORMATS,
|
||||
maxSize: formatFileSize(i18n.locale, IMAGE_MAX_SIZE_BYTES),
|
||||
maxSize: formatFileSize(i18n.locale, GlobalLimits.getAvatarMaxSize()),
|
||||
recommendedSize: AVATAR_RECOMMENDED_SIZE_LABEL,
|
||||
}),
|
||||
onPickUpload: handleIconUpload,
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
import {GenericErrorModal} from '@app/features/app/components/alerts/GenericErrorModal';
|
||||
import {ConfirmModal} from '@app/features/app/components/dialogs/ConfirmModal';
|
||||
import {GlobalLimits} from '@app/features/app/utils/GlobalLimits';
|
||||
import {EmojiListHeader, EmojiListItem} from '@app/features/emoji/components/emojis/EmojiListItem';
|
||||
import {EmojiUploadModal} from '@app/features/emoji/components/modals/EmojiUploadModal';
|
||||
import EmojiStickerLayout from '@app/features/emoji/state/EmojiStickerLayout';
|
||||
@@ -33,7 +32,7 @@ import {Spinner} from '@app/features/ui/components/Spinner';
|
||||
import Users from '@app/features/user/state/Users';
|
||||
import {canCropFormat} from '@app/features/voice/utils/MediaCapabilities';
|
||||
import {Permissions} from '@fluxer/constants/src/ChannelConstants';
|
||||
import {MAX_GUILD_EMOJIS} from '@fluxer/constants/src/LimitConstants';
|
||||
import {EMOJI_MAX_SIZE, MAX_GUILD_EMOJIS} from '@fluxer/constants/src/LimitConstants';
|
||||
import type {GuildEmojiWithUser} from '@fluxer/schema/src/domains/guild/GuildEmojiSchemas';
|
||||
import {sortBySnowflakeDesc} from '@fluxer/snowflake/src/SnowflakeUtils';
|
||||
import {msg} from '@lingui/core/macro';
|
||||
@@ -179,7 +178,8 @@ const GuildEmojiTab: React.FC<{guildId: string}> = observer(function GuildEmojiT
|
||||
}, [filteredEmojis]);
|
||||
const maxEmojis = guild?.maxEmojis ?? MAX_GUILD_EMOJIS;
|
||||
const currentEmojiCount = emojis.length;
|
||||
const emojiMaxSizeLabel = formatFileSize(i18n.locale, GlobalLimits.getEmojiMaxSize());
|
||||
const maxEmojiSize = guild?.maxEmojiSize ?? EMOJI_MAX_SIZE;
|
||||
const emojiMaxSizeLabel = formatFileSize(i18n.locale, maxEmojiSize);
|
||||
const canModifyEmoji = useCallback(
|
||||
(emoji: GuildEmojiWithUser): boolean => {
|
||||
if (canManageExpressions) return true;
|
||||
@@ -291,7 +291,6 @@ const GuildEmojiTab: React.FC<{guildId: string}> = observer(function GuildEmojiT
|
||||
);
|
||||
const preparedEmojis: Array<{name: string; image: string; file: File}> = [];
|
||||
const preparationFailures: Array<EmojiUploadFailure> = [];
|
||||
const maxEmojiSize = GlobalLimits.getEmojiMaxSize();
|
||||
const maxEmojiSizeLabel = formatFileSize(i18n.locale, maxEmojiSize);
|
||||
for (const file of filesWithinSlots) {
|
||||
try {
|
||||
@@ -399,7 +398,7 @@ const GuildEmojiTab: React.FC<{guildId: string}> = observer(function GuildEmojiT
|
||||
);
|
||||
}
|
||||
},
|
||||
[guildId, fetchEmojis, maxEmojis, currentEmojiCount, i18n, renderEmojiFailureDescription],
|
||||
[guildId, fetchEmojis, maxEmojis, maxEmojiSize, currentEmojiCount, i18n, renderEmojiFailureDescription],
|
||||
);
|
||||
const handleDrop = useCallback(
|
||||
async (files: Array<File>) => {
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
import {GenericErrorModal} from '@app/features/app/components/alerts/GenericErrorModal';
|
||||
import {ConfirmModal} from '@app/features/app/components/dialogs/ConfirmModal';
|
||||
import {StatusSlate} from '@app/features/app/components/dialogs/shared/StatusSlate';
|
||||
import {GlobalLimits} from '@app/features/app/utils/GlobalLimits';
|
||||
import {StickerGridItem} from '@app/features/emoji/components/stickers/StickerGridItem';
|
||||
import EmojiStickerLayout from '@app/features/emoji/state/EmojiStickerLayout';
|
||||
import * as GuildStickerCommands from '@app/features/expressions/commands/GuildStickerCommands';
|
||||
@@ -34,7 +33,7 @@ import {Spinner} from '@app/features/ui/components/Spinner';
|
||||
import Users from '@app/features/user/state/Users';
|
||||
import {canCropFormat} from '@app/features/voice/utils/MediaCapabilities';
|
||||
import {Permissions} from '@fluxer/constants/src/ChannelConstants';
|
||||
import {MAX_GUILD_STICKERS} from '@fluxer/constants/src/LimitConstants';
|
||||
import {MAX_GUILD_STICKERS, STICKER_MAX_SIZE} from '@fluxer/constants/src/LimitConstants';
|
||||
import type {GuildStickerWithUser} from '@fluxer/schema/src/domains/guild/GuildEmojiSchemas';
|
||||
import {sortBySnowflakeDesc} from '@fluxer/snowflake/src/SnowflakeUtils';
|
||||
import {msg} from '@lingui/core/macro';
|
||||
@@ -227,7 +226,7 @@ const GuildStickersTab: React.FC<{guildId: string}> = observer(function GuildSti
|
||||
},
|
||||
[canManageExpressions, canCreateExpressions, currentUserId],
|
||||
);
|
||||
const stickerMaxSizeLabel = formatFileSize(i18n.locale, GlobalLimits.getStickerMaxSize());
|
||||
const stickerMaxSizeLabel = formatFileSize(i18n.locale, guild?.maxStickerSize ?? STICKER_MAX_SIZE);
|
||||
return (
|
||||
<div className={styles.container} data-flx="guild.guild-tabs.guild-stickers-tab.container">
|
||||
<CloneAllowedToggle
|
||||
|
||||
+2
-2
@@ -4,10 +4,10 @@ import {ImagePreviewField} from '@app/features/app/components/shared/ImagePrevie
|
||||
import {
|
||||
ANIMATED_BANNER_FEATURE,
|
||||
ANIMATED_IMAGE_FORMATS,
|
||||
IMAGE_MAX_SIZE_BYTES,
|
||||
STATIC_IMAGE_FORMATS,
|
||||
WIDE_IMAGE_ASPECT_RATIO_LABEL,
|
||||
} from '@app/features/app/config/I18nDisplayConstants';
|
||||
import {GlobalLimits} from '@app/features/app/utils/GlobalLimits';
|
||||
import {AssetType, getAssetConfig} from '@app/features/expressions/components/modals/AssetCropModal';
|
||||
import {getAcceptStringFiltered} from '@app/features/expressions/utils/AssetFormatCopy';
|
||||
import {getAspectRatioFromDimensions} from '@app/features/expressions/utils/AssetImageGeometry';
|
||||
@@ -97,7 +97,7 @@ export const GuildBannerUploadField: React.FC<{
|
||||
sourceModalTitle: i18n._(CHANGE_BANNER_DESCRIPTOR),
|
||||
uploadHint: formatImageUploadMinimumHint(i18n, {
|
||||
formats: bannerFormats,
|
||||
maxSize: formatFileSize(i18n.locale, IMAGE_MAX_SIZE_BYTES),
|
||||
maxSize: formatFileSize(i18n.locale, GlobalLimits.getAvatarMaxSize()),
|
||||
minimumSize: bannerMinimumSize,
|
||||
aspectRatio: WIDE_IMAGE_ASPECT_RATIO_LABEL,
|
||||
}),
|
||||
|
||||
+2
-2
@@ -1,10 +1,10 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {
|
||||
IMAGE_MAX_SIZE_BYTES,
|
||||
STATIC_IMAGE_WITH_AVIF_FORMATS,
|
||||
WIDE_IMAGE_ASPECT_RATIO_LABEL,
|
||||
} from '@app/features/app/config/I18nDisplayConstants';
|
||||
import {GlobalLimits} from '@app/features/app/utils/GlobalLimits';
|
||||
import {GuildInviteEmbedPreview} from '@app/features/channel/components/InviteEmbed';
|
||||
import {AssetType, getAssetConfig} from '@app/features/expressions/components/modals/AssetCropModal';
|
||||
import {getAcceptString} from '@app/features/expressions/utils/AssetFormatCopy';
|
||||
@@ -76,7 +76,7 @@ export const GuildEmbedSplashUploadField: React.FC<{
|
||||
sourceModalTitle: i18n._(CHANGE_CHAT_EMBED_BACKGROUND_DESCRIPTOR),
|
||||
uploadHint: formatImageUploadMinimumHintWithNote(i18n, {
|
||||
formats: STATIC_IMAGE_WITH_AVIF_FORMATS,
|
||||
maxSize: formatFileSize(i18n.locale, IMAGE_MAX_SIZE_BYTES),
|
||||
maxSize: formatFileSize(i18n.locale, GlobalLimits.getAvatarMaxSize()),
|
||||
minimumSize: embedSplashMinimumSize,
|
||||
aspectRatio: WIDE_IMAGE_ASPECT_RATIO_LABEL,
|
||||
note: i18n._(SHOWN_IN_INVITE_EMBEDS_IN_CHAT_DESCRIPTOR),
|
||||
|
||||
+2
-2
@@ -4,9 +4,9 @@ import {
|
||||
ANIMATED_ICON_FEATURE,
|
||||
ANIMATED_IMAGE_FORMATS,
|
||||
AVATAR_RECOMMENDED_SIZE_LABEL,
|
||||
IMAGE_MAX_SIZE_BYTES,
|
||||
STATIC_IMAGE_FORMATS,
|
||||
} from '@app/features/app/config/I18nDisplayConstants';
|
||||
import {GlobalLimits} from '@app/features/app/utils/GlobalLimits';
|
||||
import {AssetType} from '@app/features/expressions/components/modals/AssetCropModal';
|
||||
import {getAcceptStringFiltered} from '@app/features/expressions/utils/AssetFormatCopy';
|
||||
import {formatImageUploadRecommendedHint} from '@app/features/expressions/utils/AssetUploadHintCopy';
|
||||
@@ -75,7 +75,7 @@ export const GuildIconUploadField: React.FC<{
|
||||
sourceModalTitle: i18n._(CHANGE_ICON_DESCRIPTOR),
|
||||
uploadHint: formatImageUploadRecommendedHint(i18n, {
|
||||
formats: iconFormats,
|
||||
maxSize: formatFileSize(i18n.locale, IMAGE_MAX_SIZE_BYTES),
|
||||
maxSize: formatFileSize(i18n.locale, GlobalLimits.getAvatarMaxSize()),
|
||||
recommendedSize: AVATAR_RECOMMENDED_SIZE_LABEL,
|
||||
}),
|
||||
});
|
||||
|
||||
+2
-2
@@ -2,10 +2,10 @@
|
||||
|
||||
import {ImagePreviewField} from '@app/features/app/components/shared/ImagePreviewField';
|
||||
import {
|
||||
IMAGE_MAX_SIZE_BYTES,
|
||||
STATIC_IMAGE_WITH_AVIF_FORMATS,
|
||||
WIDE_IMAGE_ASPECT_RATIO_LABEL,
|
||||
} from '@app/features/app/config/I18nDisplayConstants';
|
||||
import {GlobalLimits} from '@app/features/app/utils/GlobalLimits';
|
||||
import {AssetType, getAssetConfig} from '@app/features/expressions/components/modals/AssetCropModal';
|
||||
import {getAcceptString} from '@app/features/expressions/utils/AssetFormatCopy';
|
||||
import {getAspectRatioFromDimensions} from '@app/features/expressions/utils/AssetImageGeometry';
|
||||
@@ -82,7 +82,7 @@ export const GuildInviteSplashUploadField: React.FC<{
|
||||
sourceModalTitle: i18n._(CHANGE_INVITE_BACKGROUND_DESCRIPTOR),
|
||||
uploadHint: formatImageUploadMinimumHint(i18n, {
|
||||
formats: STATIC_IMAGE_WITH_AVIF_FORMATS,
|
||||
maxSize: formatFileSize(i18n.locale, IMAGE_MAX_SIZE_BYTES),
|
||||
maxSize: formatFileSize(i18n.locale, GlobalLimits.getAvatarMaxSize()),
|
||||
minimumSize: splashMinimumSize,
|
||||
aspectRatio: WIDE_IMAGE_ASPECT_RATIO_LABEL,
|
||||
}),
|
||||
|
||||
+2
-3
@@ -1,5 +1,6 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {GlobalLimits} from '@app/features/app/utils/GlobalLimits';
|
||||
import type {Gif} from '@app/features/expressions/commands/GifCommands';
|
||||
import {AssetCropModal, type AssetType} from '@app/features/expressions/components/modals/AssetCropModal';
|
||||
import {openAssetSourceModal} from '@app/features/expressions/components/modals/AssetSourceModal';
|
||||
@@ -10,7 +11,6 @@ import {isSvgFile, readImageFileAsUploadDataUrl} from '@app/features/expressions
|
||||
import {showGuildErrorModal} from '@app/features/guild/components/alerts/GuildErrorModalUtils';
|
||||
import {
|
||||
blobToDataUrl,
|
||||
MAX_IMAGE_BYTES,
|
||||
revokeObjectUrl,
|
||||
} from '@app/features/guild/components/modals/guild_tabs/guild_overview_tab/utils/ImageAsset';
|
||||
import type {FormInputs} from '@app/features/guild/utils/guild_tabs/GuildOverviewTabUtils';
|
||||
@@ -78,7 +78,6 @@ export interface UseGuildImageAssetFieldArgs {
|
||||
previewUrl: string | null;
|
||||
setPreviewUrl: React.Dispatch<React.SetStateAction<string | null>>;
|
||||
setHasCleared: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
maxBytes?: number;
|
||||
labelForMessages: string;
|
||||
gif?: GifPolicy;
|
||||
aspectRatio?: AspectRatioConfig;
|
||||
@@ -107,7 +106,6 @@ export function useGuildImageAssetField({
|
||||
previewUrl,
|
||||
setPreviewUrl,
|
||||
setHasCleared,
|
||||
maxBytes = MAX_IMAGE_BYTES,
|
||||
labelForMessages,
|
||||
gif,
|
||||
aspectRatio,
|
||||
@@ -115,6 +113,7 @@ export function useGuildImageAssetField({
|
||||
uploadHint,
|
||||
}: UseGuildImageAssetFieldArgs): ImageAssetFieldController {
|
||||
const {i18n} = useLingui();
|
||||
const maxBytes = GlobalLimits.getAvatarMaxSize();
|
||||
const label = labelForMessages;
|
||||
const requestIdRef = useRef(0);
|
||||
const [isProcessing, setIsProcessing] = useState(false);
|
||||
|
||||
-2
@@ -1,7 +1,5 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
export const MAX_IMAGE_BYTES = 10 * 1024 * 1024;
|
||||
|
||||
export function revokeObjectUrl(url: string | null | undefined): void {
|
||||
if (!url) return;
|
||||
if (!url.startsWith('blob:')) return;
|
||||
|
||||
@@ -8,7 +8,12 @@ import {LARGE_GUILD_THRESHOLD} from '@fluxer/constants/src/GatewayConstants';
|
||||
import type {GuildSplashCardAlignmentValue} from '@fluxer/constants/src/GuildConstants';
|
||||
import {GuildFeatures, GuildNSFWLevel, GuildSplashCardAlignment} from '@fluxer/constants/src/GuildConstants';
|
||||
import type {LimitKey} from '@fluxer/constants/src/LimitConfigMetadata';
|
||||
import {MAX_GUILD_EMOJIS, MAX_GUILD_STICKERS} from '@fluxer/constants/src/LimitConstants';
|
||||
import {
|
||||
EMOJI_MAX_SIZE,
|
||||
MAX_GUILD_EMOJIS,
|
||||
MAX_GUILD_STICKERS,
|
||||
STICKER_MAX_SIZE,
|
||||
} from '@fluxer/constants/src/LimitConstants';
|
||||
import {MessageNotifications} from '@fluxer/constants/src/NotificationConstants';
|
||||
import {resolveLimit} from '@fluxer/limits/src/LimitResolver';
|
||||
import type {Guild as WireGuild} from '@fluxer/schema/src/domains/guild/GuildResponseSchemas';
|
||||
@@ -348,6 +353,14 @@ export class Guild {
|
||||
return this.resolveGuildLimit('max_guild_stickers', MAX_GUILD_STICKERS);
|
||||
}
|
||||
|
||||
get maxEmojiSize(): number {
|
||||
return this.resolveGuildLimit('emoji_max_size', EMOJI_MAX_SIZE);
|
||||
}
|
||||
|
||||
get maxStickerSize(): number {
|
||||
return this.resolveGuildLimit('sticker_max_size', STICKER_MAX_SIZE);
|
||||
}
|
||||
|
||||
private resolveGuildLimit(key: LimitKey, fallback: number): number {
|
||||
const resolved = resolveLimit(
|
||||
RuntimeConfig.limits,
|
||||
|
||||
+49
-5
@@ -5414,8 +5414,10 @@ msgstr "اقتراح الإكمال التلقائي: {label}، {selectedOptionP
|
||||
#. Option label for a WebRTC content hint select.
|
||||
#. Option label for an SVC mode select.
|
||||
#. Option label for an encoder path select.
|
||||
#. SMTP encryption option that picks the mode from the port.
|
||||
#. Settings search synonym. Used to match this term when the user types it in the settings search bar.
|
||||
#. Voice region option label in the voice settings menu meaning let Fluxer pick the best region automatically.
|
||||
#: src/features/app/components/setup/SetupWizardSteps.tsx
|
||||
#: src/features/channel/components/modals/channel_tabs/channel_overview_tab/RtcRegionSelect.tsx
|
||||
#: src/features/user/components/modals/tabs/advanced_settings_tab/AdvancedVideoControls.tsx
|
||||
#: src/features/user/components/settings_utils/section_registry/NotificationsSections.ts
|
||||
@@ -14556,6 +14558,11 @@ msgstr "تفضيل المُرمِّز لعمليات مشاركة الشاشة
|
||||
msgid "Encoder, SVC, and bitrate presets"
|
||||
msgstr "المُرمّز، SVC، وإعدادات البت المسبقة"
|
||||
|
||||
#. Label for the choice of how the SMTP connection is encrypted.
|
||||
#: src/features/app/components/setup/SetupWizardSteps.tsx
|
||||
msgid "Encryption"
|
||||
msgstr "التشفير"
|
||||
|
||||
#: src/features/input/utils/KeybindUtils.ts
|
||||
msgctxt "keyboard-key-name"
|
||||
msgid "End"
|
||||
@@ -18420,6 +18427,16 @@ msgstr "الصور"
|
||||
msgid "Images pasted from your clipboard are sent as attachments, and you don't have permission to attach files in this channel."
|
||||
msgstr "يتم إرسال الصور الملصقة من حافظتك كمرفقات، وليس لديك إذن بإرفاق ملفات في هذه القناة."
|
||||
|
||||
#. SMTP encryption option where the connection starts encrypted.
|
||||
#: src/features/app/components/setup/SetupWizardSteps.tsx
|
||||
msgid "Implicit TLS"
|
||||
msgstr "TLS ضمني"
|
||||
|
||||
#. Description of the automatic SMTP encryption option.
|
||||
#: src/features/app/components/setup/SetupWizardSteps.tsx
|
||||
msgid "Implicit TLS on port 465, required STARTTLS on any other port."
|
||||
msgstr "TLS ضمني على المنفذ 465، مطلوب STARTTLS على أي منفذ آخر."
|
||||
|
||||
#. Primary button in the favorite GIF import modal. Starts importing pasted GIF URLs.
|
||||
#: src/features/channel/components/pickers/gif/ImportFavoriteGifsModal.tsx
|
||||
msgid "Import"
|
||||
@@ -23948,8 +23965,10 @@ msgstr "عدم التحويل"
|
||||
#. Developer tools debug menu label. Internal-only surface for developers; translators may keep this terse.
|
||||
#. Library picker entry meaning no sound is selected for this scope.
|
||||
#. Member verification level option in the community moderation settings tab. Short standalone label for the lowest level (no verification).
|
||||
#. SMTP encryption option that never uses TLS.
|
||||
#. Short label in the visual tab. Keep it concise.
|
||||
#. Summary row value on the confirm step when no items in the category were selected.
|
||||
#: src/features/app/components/setup/SetupWizardSteps.tsx
|
||||
#: src/features/channel/components/channel_header_components/developer_tools/DeveloperToolsShared.ts
|
||||
#: src/features/guild/components/modals/guild_tabs/GuildModerationTab.tsx
|
||||
#: src/features/guild/utils/guild_tabs/audit_log/AuditLogSharedMessages.ts
|
||||
@@ -33153,6 +33172,16 @@ msgstr "بدأ بواسطة {name}"
|
||||
msgid "Started displaying members with this role separately"
|
||||
msgstr "بدأ عرض الأعضاء الذين لديهم هذا الدور بشكل منفصل"
|
||||
|
||||
#. SMTP encryption option where a plain connection must upgrade with STARTTLS.
|
||||
#: src/features/app/components/setup/SetupWizardSteps.tsx
|
||||
msgid "STARTTLS"
|
||||
msgstr "STARTTLS"
|
||||
|
||||
#. SMTP encryption option where STARTTLS is used only if the server offers it.
|
||||
#: src/features/app/components/setup/SetupWizardSteps.tsx
|
||||
msgid "STARTTLS when offered"
|
||||
msgstr "STARTTLS عند توفيرها"
|
||||
|
||||
#. Settings search synonym. Used to match this term when the user types it in the settings search bar.
|
||||
#: src/features/user/components/settings_utils/search_index/DesktopSettingsIndex.ts
|
||||
msgid "Startup"
|
||||
@@ -34625,6 +34654,26 @@ msgstr "عطّل المجتمع الذي جاء منه هذا العنصر ال
|
||||
msgid "The community you're looking for may have been deleted or you may not have access to it."
|
||||
msgstr "ربما حُذف المجتمع الذي تبحث عنه أو ليس لديك صلاحية الوصول إليه."
|
||||
|
||||
#. Description of the implicit TLS SMTP encryption option.
|
||||
#: src/features/app/components/setup/SetupWizardSteps.tsx
|
||||
msgid "The connection is encrypted from the start. Usually port 465."
|
||||
msgstr "يتم تشفير الاتصال من البداية. عادةً المنفذ 465."
|
||||
|
||||
#. Description of the SMTP encryption option that never uses TLS.
|
||||
#: src/features/app/components/setup/SetupWizardSteps.tsx
|
||||
msgid "The connection is never encrypted. Only for a mail server on a network you trust."
|
||||
msgstr "لا يتم تشفير الاتصال أبدًا. فقط لخادم بريد على شبكة تثق بها."
|
||||
|
||||
#. Description of the required STARTTLS SMTP encryption option.
|
||||
#: src/features/app/components/setup/SetupWizardSteps.tsx
|
||||
msgid "The connection must upgrade to TLS before signing in. Usually port 587."
|
||||
msgstr "يجب ترقية الاتصال إلى TLS قبل تسجيل الدخول. عادةً المنفذ 587."
|
||||
|
||||
#. Description of the opportunistic STARTTLS SMTP encryption option.
|
||||
#: src/features/app/components/setup/SetupWizardSteps.tsx
|
||||
msgid "The connection upgrades to TLS only if the server offers it."
|
||||
msgstr "يتم ترقية الاتصال إلى TLS فقط إذا كان الخادم يقدمه."
|
||||
|
||||
#. Modal body shown when a desktop update download fails.
|
||||
#: src/features/updater/commands/UpdaterModalCommands.tsx
|
||||
msgid "The desktop update could not be downloaded. Try again from the update button when your connection is stable."
|
||||
@@ -38364,11 +38413,6 @@ msgstr "استخدام إعداد تقليل الحركة في نظام هذا
|
||||
msgid "Use this device's system reduced motion preference, or customize it below."
|
||||
msgstr "استخدم تفضيل تقليل الحركة في نظام هذا الجهاز، أو خصصه أدناه."
|
||||
|
||||
#. Label for SMTP TLS toggle.
|
||||
#: src/features/app/components/setup/SetupWizardSteps.tsx
|
||||
msgid "Use TLS"
|
||||
msgstr "استخدام TLS"
|
||||
|
||||
#. Permission name: allows voice activation instead of requiring push-to-talk.
|
||||
#: src/features/permissions/utils/PermissionLabelDescriptors.ts
|
||||
msgid "Use voice activity"
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
"P2P",
|
||||
"PgDn",
|
||||
"PgUp",
|
||||
"STARTTLS",
|
||||
"Searching...",
|
||||
"Segoe UI",
|
||||
"Shift",
|
||||
@@ -45,6 +46,7 @@
|
||||
"P2P",
|
||||
"PgDn",
|
||||
"PgUp",
|
||||
"STARTTLS",
|
||||
"Searching...",
|
||||
"Segoe UI",
|
||||
"Shift",
|
||||
@@ -104,6 +106,7 @@
|
||||
"Port",
|
||||
"Premium",
|
||||
"Role",
|
||||
"STARTTLS",
|
||||
"Searching...",
|
||||
"Segoe UI",
|
||||
"Shift",
|
||||
@@ -232,6 +235,7 @@
|
||||
"Hardware",
|
||||
"Hindi",
|
||||
"Hyperlink",
|
||||
"Implicit TLS",
|
||||
"Jitter",
|
||||
"Layout",
|
||||
"Link",
|
||||
@@ -261,6 +265,7 @@
|
||||
"Print",
|
||||
"Prompt",
|
||||
"Relevant",
|
||||
"STARTTLS",
|
||||
"Searching...",
|
||||
"Segoe UI",
|
||||
"Send",
|
||||
@@ -461,6 +466,7 @@
|
||||
"Position",
|
||||
"Premium",
|
||||
"Reposts: <0>{countLabel}</0>",
|
||||
"STARTTLS",
|
||||
"Searching...",
|
||||
"Segoe UI",
|
||||
"Smileys",
|
||||
@@ -599,6 +605,7 @@
|
||||
"Ping: {currentLatency}ms",
|
||||
"Ping: {latency}ms",
|
||||
"Premium",
|
||||
"STARTTLS",
|
||||
"Searching...",
|
||||
"Segoe UI",
|
||||
"Shift",
|
||||
@@ -928,6 +935,7 @@
|
||||
"Enable emoji and sticker cloning shortcuts",
|
||||
"Enable previews",
|
||||
"Enable the integrations whose credentials you have configured. Only available services are shown.",
|
||||
"Encryption",
|
||||
"Ensures file messages appear in the order you sent them",
|
||||
"Enter a community name between 1 and 100 characters.",
|
||||
"Enter a message",
|
||||
@@ -1031,6 +1039,8 @@
|
||||
"If you forget your password, this kit is the only way back into your account. Store it somewhere safe, like a password manager or a printed copy.",
|
||||
"Ignore this channel's slowmode cooldown.",
|
||||
"Image assets",
|
||||
"Implicit TLS",
|
||||
"Implicit TLS on port 465, required STARTTLS on any other port.",
|
||||
"Include my two-factor backup codes in the PDF and printout",
|
||||
"Incorrect PIN. Try again.",
|
||||
"Incorrect PIN. {retriesRemaining, plural, one {# attempt} other {# attempts}} left before the security key locks.",
|
||||
@@ -1336,6 +1346,8 @@
|
||||
"Review who can message you",
|
||||
"Right now",
|
||||
"Run the instance as one community instead of many. This can only be enabled now, during setup.",
|
||||
"STARTTLS",
|
||||
"STARTTLS when offered",
|
||||
"Save a recovery kit",
|
||||
"Save your recovery kit",
|
||||
"Save {percent}%",
|
||||
@@ -1492,6 +1504,10 @@
|
||||
"That doesn't look like an instance address. Try a domain such as {exampleInstanceDomain}.",
|
||||
"That image could not be used. Try a different file.",
|
||||
"The camera preview is still stopping. Try streaming again in a moment.",
|
||||
"The connection is encrypted from the start. Usually port 465.",
|
||||
"The connection is never encrypted. Only for a mail server on a network you trust.",
|
||||
"The connection must upgrade to TLS before signing in. Usually port 587.",
|
||||
"The connection upgrades to TLS only if the server offers it.",
|
||||
"The member to target.",
|
||||
"The message to send.",
|
||||
"The new price is already scheduled for {effectiveDate}.",
|
||||
@@ -1848,6 +1864,7 @@
|
||||
"Portugal",
|
||||
"Premium",
|
||||
"Roles",
|
||||
"STARTTLS",
|
||||
"Searching...",
|
||||
"Segoe UI",
|
||||
"Social",
|
||||
@@ -1967,6 +1984,7 @@
|
||||
"Portugal",
|
||||
"Premium",
|
||||
"Roles",
|
||||
"STARTTLS",
|
||||
"Searching...",
|
||||
"Segoe UI",
|
||||
"Social",
|
||||
@@ -2068,6 +2086,7 @@
|
||||
"Ping",
|
||||
"Premium",
|
||||
"Romania",
|
||||
"STARTTLS",
|
||||
"Searching...",
|
||||
"Segoe UI",
|
||||
"Slovakia",
|
||||
@@ -2221,6 +2240,7 @@
|
||||
"Portugal",
|
||||
"Position",
|
||||
"Premium",
|
||||
"STARTTLS",
|
||||
"Saturation",
|
||||
"Searching...",
|
||||
"Secret",
|
||||
@@ -2337,6 +2357,7 @@
|
||||
"P2P",
|
||||
"PgDn",
|
||||
"PgUp",
|
||||
"STARTTLS",
|
||||
"Searching...",
|
||||
"Segoe UI",
|
||||
"Shift",
|
||||
@@ -2372,10 +2393,12 @@
|
||||
"Fluxer",
|
||||
"Google Lens",
|
||||
"HEVC",
|
||||
"Implicit TLS",
|
||||
"OP",
|
||||
"P2P",
|
||||
"PgDn",
|
||||
"PgUp",
|
||||
"STARTTLS",
|
||||
"Searching...",
|
||||
"Shift",
|
||||
"WebAuthn",
|
||||
@@ -2426,6 +2449,7 @@
|
||||
"Portugal",
|
||||
"Premium",
|
||||
"Premium model",
|
||||
"STARTTLS",
|
||||
"Searching...",
|
||||
"Segoe UI",
|
||||
"Shift",
|
||||
@@ -2517,6 +2541,7 @@
|
||||
"Fluxer",
|
||||
"Google Lens",
|
||||
"HEVC",
|
||||
"Implicit TLS",
|
||||
"Jitter",
|
||||
"Link",
|
||||
"Markdown",
|
||||
@@ -2531,6 +2556,7 @@
|
||||
"Ping",
|
||||
"Platform",
|
||||
"Port",
|
||||
"STARTTLS",
|
||||
"Searching...",
|
||||
"Segoe UI",
|
||||
"Shift",
|
||||
@@ -2687,6 +2713,7 @@
|
||||
"Portugal",
|
||||
"Premium",
|
||||
"Prompt",
|
||||
"STARTTLS",
|
||||
"Searching...",
|
||||
"Segoe UI",
|
||||
"Shift",
|
||||
@@ -2857,6 +2884,7 @@
|
||||
"Premium",
|
||||
"Privacy",
|
||||
"Romania",
|
||||
"STARTTLS",
|
||||
"Searching...",
|
||||
"Segoe UI",
|
||||
"Slovenia",
|
||||
@@ -2971,6 +2999,7 @@
|
||||
"Ping",
|
||||
"Ping: {currentLatency}ms",
|
||||
"Ping: {latency}ms",
|
||||
"STARTTLS",
|
||||
"Searching...",
|
||||
"Segoe UI",
|
||||
"Shift",
|
||||
@@ -3009,6 +3038,7 @@
|
||||
"P2P",
|
||||
"PgDn",
|
||||
"PgUp",
|
||||
"STARTTLS",
|
||||
"Searching...",
|
||||
"Segoe UI",
|
||||
"Shift",
|
||||
@@ -3055,6 +3085,7 @@
|
||||
"PiP",
|
||||
"Ping",
|
||||
"Premium",
|
||||
"STARTTLS",
|
||||
"Searching...",
|
||||
"Segoe UI",
|
||||
"Shift",
|
||||
@@ -3189,6 +3220,7 @@
|
||||
"Privacy",
|
||||
"Relevant",
|
||||
"Reposts: <0>{countLabel}</0>",
|
||||
"STARTTLS",
|
||||
"Searching...",
|
||||
"Segoe UI",
|
||||
"Shift",
|
||||
@@ -3361,6 +3393,7 @@
|
||||
"Premium",
|
||||
"Ring",
|
||||
"Romania",
|
||||
"STARTTLS",
|
||||
"Searching...",
|
||||
"Segoe UI",
|
||||
"Send",
|
||||
@@ -3489,6 +3522,7 @@
|
||||
"Plan",
|
||||
"Port",
|
||||
"Premium",
|
||||
"STARTTLS",
|
||||
"Searching...",
|
||||
"Segoe UI",
|
||||
"Shift",
|
||||
@@ -3604,6 +3638,7 @@
|
||||
"Portugal",
|
||||
"Premium",
|
||||
"Prompt",
|
||||
"STARTTLS",
|
||||
"Searching...",
|
||||
"Segoe UI",
|
||||
"Shift",
|
||||
@@ -3758,6 +3793,7 @@
|
||||
"Ping",
|
||||
"Port",
|
||||
"Premium",
|
||||
"STARTTLS",
|
||||
"Searching...",
|
||||
"Secret",
|
||||
"Segoe UI",
|
||||
@@ -3876,6 +3912,7 @@
|
||||
"P2P",
|
||||
"PgDn",
|
||||
"PgUp",
|
||||
"STARTTLS",
|
||||
"Searching...",
|
||||
"Segoe UI",
|
||||
"Shift",
|
||||
@@ -3945,6 +3982,7 @@
|
||||
"Premium",
|
||||
"Relevant",
|
||||
"Ring",
|
||||
"STARTTLS",
|
||||
"Searching...",
|
||||
"Segoe UI",
|
||||
"Session",
|
||||
@@ -4061,6 +4099,7 @@
|
||||
"PgDn",
|
||||
"PgUp",
|
||||
"PiP",
|
||||
"STARTTLS",
|
||||
"Searching...",
|
||||
"Segoe UI",
|
||||
"Shift",
|
||||
@@ -4123,6 +4162,7 @@
|
||||
"Port",
|
||||
"Premium",
|
||||
"Premium model",
|
||||
"STARTTLS",
|
||||
"Searching...",
|
||||
"Segoe UI",
|
||||
"Shift",
|
||||
@@ -4213,6 +4253,7 @@
|
||||
"P2P",
|
||||
"PgDn",
|
||||
"PgUp",
|
||||
"STARTTLS",
|
||||
"Searching...",
|
||||
"Segoe UI",
|
||||
"Shift",
|
||||
@@ -4272,6 +4313,7 @@
|
||||
"PgUp",
|
||||
"Ping",
|
||||
"Romania",
|
||||
"STARTTLS",
|
||||
"Searching...",
|
||||
"Segoe UI",
|
||||
"Shift",
|
||||
@@ -4364,6 +4406,7 @@
|
||||
"Markdown",
|
||||
"OP",
|
||||
"P2P",
|
||||
"STARTTLS",
|
||||
"Searching...",
|
||||
"Segoe UI",
|
||||
"Shift",
|
||||
@@ -4406,6 +4449,7 @@
|
||||
"P2P",
|
||||
"PgDn",
|
||||
"PgUp",
|
||||
"STARTTLS",
|
||||
"Searching...",
|
||||
"Segoe UI",
|
||||
"Shift",
|
||||
|
||||
+49
-5
@@ -5414,8 +5414,10 @@ msgstr "Предложение за автоматично довършване:
|
||||
#. Option label for a WebRTC content hint select.
|
||||
#. Option label for an SVC mode select.
|
||||
#. Option label for an encoder path select.
|
||||
#. SMTP encryption option that picks the mode from the port.
|
||||
#. Settings search synonym. Used to match this term when the user types it in the settings search bar.
|
||||
#. Voice region option label in the voice settings menu meaning let Fluxer pick the best region automatically.
|
||||
#: src/features/app/components/setup/SetupWizardSteps.tsx
|
||||
#: src/features/channel/components/modals/channel_tabs/channel_overview_tab/RtcRegionSelect.tsx
|
||||
#: src/features/user/components/modals/tabs/advanced_settings_tab/AdvancedVideoControls.tsx
|
||||
#: src/features/user/components/settings_utils/section_registry/NotificationsSections.ts
|
||||
@@ -14556,6 +14558,11 @@ msgstr "Предпочитан енкодер за нови споделяния
|
||||
msgid "Encoder, SVC, and bitrate presets"
|
||||
msgstr "Кодер, SVC и битрейт предварителни настройки"
|
||||
|
||||
#. Label for the choice of how the SMTP connection is encrypted.
|
||||
#: src/features/app/components/setup/SetupWizardSteps.tsx
|
||||
msgid "Encryption"
|
||||
msgstr "Криптиране"
|
||||
|
||||
#: src/features/input/utils/KeybindUtils.ts
|
||||
msgctxt "keyboard-key-name"
|
||||
msgid "End"
|
||||
@@ -18420,6 +18427,16 @@ msgstr "Изображения"
|
||||
msgid "Images pasted from your clipboard are sent as attachments, and you don't have permission to attach files in this channel."
|
||||
msgstr "Изображенията, поставени от клипборда ти, се изпращат като прикачени файлове, а ти нямаш разрешение да прикачваш файлове в този канал."
|
||||
|
||||
#. SMTP encryption option where the connection starts encrypted.
|
||||
#: src/features/app/components/setup/SetupWizardSteps.tsx
|
||||
msgid "Implicit TLS"
|
||||
msgstr "Имплицитно TLS"
|
||||
|
||||
#. Description of the automatic SMTP encryption option.
|
||||
#: src/features/app/components/setup/SetupWizardSteps.tsx
|
||||
msgid "Implicit TLS on port 465, required STARTTLS on any other port."
|
||||
msgstr "Неявен TLS на порт 465, задължителен STARTTLS на всеки друг порт."
|
||||
|
||||
#. Primary button in the favorite GIF import modal. Starts importing pasted GIF URLs.
|
||||
#: src/features/channel/components/pickers/gif/ImportFavoriteGifsModal.tsx
|
||||
msgid "Import"
|
||||
@@ -23948,8 +23965,10 @@ msgstr "Без преобразуване"
|
||||
#. Developer tools debug menu label. Internal-only surface for developers; translators may keep this terse.
|
||||
#. Library picker entry meaning no sound is selected for this scope.
|
||||
#. Member verification level option in the community moderation settings tab. Short standalone label for the lowest level (no verification).
|
||||
#. SMTP encryption option that never uses TLS.
|
||||
#. Short label in the visual tab. Keep it concise.
|
||||
#. Summary row value on the confirm step when no items in the category were selected.
|
||||
#: src/features/app/components/setup/SetupWizardSteps.tsx
|
||||
#: src/features/channel/components/channel_header_components/developer_tools/DeveloperToolsShared.ts
|
||||
#: src/features/guild/components/modals/guild_tabs/GuildModerationTab.tsx
|
||||
#: src/features/guild/utils/guild_tabs/audit_log/AuditLogSharedMessages.ts
|
||||
@@ -33153,6 +33172,16 @@ msgstr "Започнато от {name}"
|
||||
msgid "Started displaying members with this role separately"
|
||||
msgstr "Започна показването на членовете с тази роля отделно"
|
||||
|
||||
#. SMTP encryption option where a plain connection must upgrade with STARTTLS.
|
||||
#: src/features/app/components/setup/SetupWizardSteps.tsx
|
||||
msgid "STARTTLS"
|
||||
msgstr "STARTTLS"
|
||||
|
||||
#. SMTP encryption option where STARTTLS is used only if the server offers it.
|
||||
#: src/features/app/components/setup/SetupWizardSteps.tsx
|
||||
msgid "STARTTLS when offered"
|
||||
msgstr "STARTTLS, ако се предлага"
|
||||
|
||||
#. Settings search synonym. Used to match this term when the user types it in the settings search bar.
|
||||
#: src/features/user/components/settings_utils/search_index/DesktopSettingsIndex.ts
|
||||
msgid "Startup"
|
||||
@@ -34625,6 +34654,26 @@ msgstr "Общността, от която произлиза това, е из
|
||||
msgid "The community you're looking for may have been deleted or you may not have access to it."
|
||||
msgstr "Общността, която търсиш, може да е изтрита или да нямаш достъп до нея."
|
||||
|
||||
#. Description of the implicit TLS SMTP encryption option.
|
||||
#: src/features/app/components/setup/SetupWizardSteps.tsx
|
||||
msgid "The connection is encrypted from the start. Usually port 465."
|
||||
msgstr "Връзката е криптирана от самото начало. Обикновено порт 465."
|
||||
|
||||
#. Description of the SMTP encryption option that never uses TLS.
|
||||
#: src/features/app/components/setup/SetupWizardSteps.tsx
|
||||
msgid "The connection is never encrypted. Only for a mail server on a network you trust."
|
||||
msgstr "Връзката никога не е криптирана. Само за имейл сървър в мрежа, на която имате доверие."
|
||||
|
||||
#. Description of the required STARTTLS SMTP encryption option.
|
||||
#: src/features/app/components/setup/SetupWizardSteps.tsx
|
||||
msgid "The connection must upgrade to TLS before signing in. Usually port 587."
|
||||
msgstr "Връзката трябва да бъде надградена до TLS преди влизане. Обикновено порт 587."
|
||||
|
||||
#. Description of the opportunistic STARTTLS SMTP encryption option.
|
||||
#: src/features/app/components/setup/SetupWizardSteps.tsx
|
||||
msgid "The connection upgrades to TLS only if the server offers it."
|
||||
msgstr "Връзката се надгражда до TLS само ако сървърът го предлага."
|
||||
|
||||
#. Modal body shown when a desktop update download fails.
|
||||
#: src/features/updater/commands/UpdaterModalCommands.tsx
|
||||
msgid "The desktop update could not be downloaded. Try again from the update button when your connection is stable."
|
||||
@@ -38364,11 +38413,6 @@ msgstr "Използване на системната настройка за
|
||||
msgid "Use this device's system reduced motion preference, or customize it below."
|
||||
msgstr "Използвай системната настройка за намалено движение на това устройство или я персонализирай по-долу."
|
||||
|
||||
#. Label for SMTP TLS toggle.
|
||||
#: src/features/app/components/setup/SetupWizardSteps.tsx
|
||||
msgid "Use TLS"
|
||||
msgstr "Използване на TLS"
|
||||
|
||||
#. Permission name: allows voice activation instead of requiring push-to-talk.
|
||||
#: src/features/permissions/utils/PermissionLabelDescriptors.ts
|
||||
msgid "Use voice activity"
|
||||
|
||||
+49
-5
@@ -5414,8 +5414,10 @@ msgstr "Návrh automatického doplňování: {label}, {selectedOptionPosition} z
|
||||
#. Option label for a WebRTC content hint select.
|
||||
#. Option label for an SVC mode select.
|
||||
#. Option label for an encoder path select.
|
||||
#. SMTP encryption option that picks the mode from the port.
|
||||
#. Settings search synonym. Used to match this term when the user types it in the settings search bar.
|
||||
#. Voice region option label in the voice settings menu meaning let Fluxer pick the best region automatically.
|
||||
#: src/features/app/components/setup/SetupWizardSteps.tsx
|
||||
#: src/features/channel/components/modals/channel_tabs/channel_overview_tab/RtcRegionSelect.tsx
|
||||
#: src/features/user/components/modals/tabs/advanced_settings_tab/AdvancedVideoControls.tsx
|
||||
#: src/features/user/components/settings_utils/section_registry/NotificationsSections.ts
|
||||
@@ -14556,6 +14558,11 @@ msgstr "Preferovaný enkodér pro nová sdílení obrazovky."
|
||||
msgid "Encoder, SVC, and bitrate presets"
|
||||
msgstr "Kodér, SVC a předvolby přenosové rychlosti"
|
||||
|
||||
#. Label for the choice of how the SMTP connection is encrypted.
|
||||
#: src/features/app/components/setup/SetupWizardSteps.tsx
|
||||
msgid "Encryption"
|
||||
msgstr "Šifrování"
|
||||
|
||||
#: src/features/input/utils/KeybindUtils.ts
|
||||
msgctxt "keyboard-key-name"
|
||||
msgid "End"
|
||||
@@ -18420,6 +18427,16 @@ msgstr "Obrázky"
|
||||
msgid "Images pasted from your clipboard are sent as attachments, and you don't have permission to attach files in this channel."
|
||||
msgstr "Obrázky vložené ze schránky se odesílají jako přílohy a vy nemáte oprávnění připojovat soubory v tomto kanálu."
|
||||
|
||||
#. SMTP encryption option where the connection starts encrypted.
|
||||
#: src/features/app/components/setup/SetupWizardSteps.tsx
|
||||
msgid "Implicit TLS"
|
||||
msgstr "Implicitní TLS"
|
||||
|
||||
#. Description of the automatic SMTP encryption option.
|
||||
#: src/features/app/components/setup/SetupWizardSteps.tsx
|
||||
msgid "Implicit TLS on port 465, required STARTTLS on any other port."
|
||||
msgstr "Implicitní TLS na portu 465, vyžaduje STARTTLS na jakémkoli jiném portu."
|
||||
|
||||
#. Primary button in the favorite GIF import modal. Starts importing pasted GIF URLs.
|
||||
#: src/features/channel/components/pickers/gif/ImportFavoriteGifsModal.tsx
|
||||
msgid "Import"
|
||||
@@ -23948,8 +23965,10 @@ msgstr "Bez převodu"
|
||||
#. Developer tools debug menu label. Internal-only surface for developers; translators may keep this terse.
|
||||
#. Library picker entry meaning no sound is selected for this scope.
|
||||
#. Member verification level option in the community moderation settings tab. Short standalone label for the lowest level (no verification).
|
||||
#. SMTP encryption option that never uses TLS.
|
||||
#. Short label in the visual tab. Keep it concise.
|
||||
#. Summary row value on the confirm step when no items in the category were selected.
|
||||
#: src/features/app/components/setup/SetupWizardSteps.tsx
|
||||
#: src/features/channel/components/channel_header_components/developer_tools/DeveloperToolsShared.ts
|
||||
#: src/features/guild/components/modals/guild_tabs/GuildModerationTab.tsx
|
||||
#: src/features/guild/utils/guild_tabs/audit_log/AuditLogSharedMessages.ts
|
||||
@@ -33153,6 +33172,16 @@ msgstr "Zahájeno uživatelem {name}"
|
||||
msgid "Started displaying members with this role separately"
|
||||
msgstr "Zapnuto oddělené zobrazení členů s touto rolí"
|
||||
|
||||
#. SMTP encryption option where a plain connection must upgrade with STARTTLS.
|
||||
#: src/features/app/components/setup/SetupWizardSteps.tsx
|
||||
msgid "STARTTLS"
|
||||
msgstr "STARTTLS"
|
||||
|
||||
#. SMTP encryption option where STARTTLS is used only if the server offers it.
|
||||
#: src/features/app/components/setup/SetupWizardSteps.tsx
|
||||
msgid "STARTTLS when offered"
|
||||
msgstr "STARTTLS, pokud je nabízeno"
|
||||
|
||||
#. Settings search synonym. Used to match this term when the user types it in the settings search bar.
|
||||
#: src/features/user/components/settings_utils/search_index/DesktopSettingsIndex.ts
|
||||
msgid "Startup"
|
||||
@@ -34625,6 +34654,26 @@ msgstr "Komunita, ze které tato položka pochází, zakázala klonování, tak
|
||||
msgid "The community you're looking for may have been deleted or you may not have access to it."
|
||||
msgstr "Komunita, kterou hledáte, mohla být smazána nebo k ní nemáte přístup."
|
||||
|
||||
#. Description of the implicit TLS SMTP encryption option.
|
||||
#: src/features/app/components/setup/SetupWizardSteps.tsx
|
||||
msgid "The connection is encrypted from the start. Usually port 465."
|
||||
msgstr "Spojení je šifrované od začátku. Obvykle port 465."
|
||||
|
||||
#. Description of the SMTP encryption option that never uses TLS.
|
||||
#: src/features/app/components/setup/SetupWizardSteps.tsx
|
||||
msgid "The connection is never encrypted. Only for a mail server on a network you trust."
|
||||
msgstr "Připojení není nikdy šifrované. Pouze pro poštovní server v síti, které důvěřujete."
|
||||
|
||||
#. Description of the required STARTTLS SMTP encryption option.
|
||||
#: src/features/app/components/setup/SetupWizardSteps.tsx
|
||||
msgid "The connection must upgrade to TLS before signing in. Usually port 587."
|
||||
msgstr "Připojení musí před přihlášením přejít na TLS. Obvykle port 587."
|
||||
|
||||
#. Description of the opportunistic STARTTLS SMTP encryption option.
|
||||
#: src/features/app/components/setup/SetupWizardSteps.tsx
|
||||
msgid "The connection upgrades to TLS only if the server offers it."
|
||||
msgstr "Připojení se upgraduje na TLS pouze v případě, že jej server nabídne."
|
||||
|
||||
#. Modal body shown when a desktop update download fails.
|
||||
#: src/features/updater/commands/UpdaterModalCommands.tsx
|
||||
msgid "The desktop update could not be downloaded. Try again from the update button when your connection is stable."
|
||||
@@ -38364,11 +38413,6 @@ msgstr "Použít systémové nastavení omezeného pohybu tohoto zařízení"
|
||||
msgid "Use this device's system reduced motion preference, or customize it below."
|
||||
msgstr "Použít předvolbu omezeného pohybu systému tohoto zařízení, nebo si ji přizpůsobit níže."
|
||||
|
||||
#. Label for SMTP TLS toggle.
|
||||
#: src/features/app/components/setup/SetupWizardSteps.tsx
|
||||
msgid "Use TLS"
|
||||
msgstr "Použít TLS"
|
||||
|
||||
#. Permission name: allows voice activation instead of requiring push-to-talk.
|
||||
#: src/features/permissions/utils/PermissionLabelDescriptors.ts
|
||||
msgid "Use voice activity"
|
||||
|
||||
+49
-5
@@ -5414,8 +5414,10 @@ msgstr "Forslag til autoudfyldelse: {label}, {selectedOptionPosition} af {result
|
||||
#. Option label for a WebRTC content hint select.
|
||||
#. Option label for an SVC mode select.
|
||||
#. Option label for an encoder path select.
|
||||
#. SMTP encryption option that picks the mode from the port.
|
||||
#. Settings search synonym. Used to match this term when the user types it in the settings search bar.
|
||||
#. Voice region option label in the voice settings menu meaning let Fluxer pick the best region automatically.
|
||||
#: src/features/app/components/setup/SetupWizardSteps.tsx
|
||||
#: src/features/channel/components/modals/channel_tabs/channel_overview_tab/RtcRegionSelect.tsx
|
||||
#: src/features/user/components/modals/tabs/advanced_settings_tab/AdvancedVideoControls.tsx
|
||||
#: src/features/user/components/settings_utils/section_registry/NotificationsSections.ts
|
||||
@@ -14556,6 +14558,11 @@ msgstr "Encoderpræference for nye skærmdelinger."
|
||||
msgid "Encoder, SVC, and bitrate presets"
|
||||
msgstr "Encoder, SVC og bitrate-forudindstillinger"
|
||||
|
||||
#. Label for the choice of how the SMTP connection is encrypted.
|
||||
#: src/features/app/components/setup/SetupWizardSteps.tsx
|
||||
msgid "Encryption"
|
||||
msgstr "Kryptering"
|
||||
|
||||
#: src/features/input/utils/KeybindUtils.ts
|
||||
msgctxt "keyboard-key-name"
|
||||
msgid "End"
|
||||
@@ -18420,6 +18427,16 @@ msgstr "Billeder"
|
||||
msgid "Images pasted from your clipboard are sent as attachments, and you don't have permission to attach files in this channel."
|
||||
msgstr "Billeder, der er indsat fra din udklipsholder, sendes som vedhæftede filer, og du har ikke tilladelse til at vedhæfte filer i denne kanal."
|
||||
|
||||
#. SMTP encryption option where the connection starts encrypted.
|
||||
#: src/features/app/components/setup/SetupWizardSteps.tsx
|
||||
msgid "Implicit TLS"
|
||||
msgstr "Implicit TLS"
|
||||
|
||||
#. Description of the automatic SMTP encryption option.
|
||||
#: src/features/app/components/setup/SetupWizardSteps.tsx
|
||||
msgid "Implicit TLS on port 465, required STARTTLS on any other port."
|
||||
msgstr "Implicit TLS på port 465, påkrævet STARTTLS på enhver anden port."
|
||||
|
||||
#. Primary button in the favorite GIF import modal. Starts importing pasted GIF URLs.
|
||||
#: src/features/channel/components/pickers/gif/ImportFavoriteGifsModal.tsx
|
||||
msgid "Import"
|
||||
@@ -23948,8 +23965,10 @@ msgstr "Ingen konvertering"
|
||||
#. Developer tools debug menu label. Internal-only surface for developers; translators may keep this terse.
|
||||
#. Library picker entry meaning no sound is selected for this scope.
|
||||
#. Member verification level option in the community moderation settings tab. Short standalone label for the lowest level (no verification).
|
||||
#. SMTP encryption option that never uses TLS.
|
||||
#. Short label in the visual tab. Keep it concise.
|
||||
#. Summary row value on the confirm step when no items in the category were selected.
|
||||
#: src/features/app/components/setup/SetupWizardSteps.tsx
|
||||
#: src/features/channel/components/channel_header_components/developer_tools/DeveloperToolsShared.ts
|
||||
#: src/features/guild/components/modals/guild_tabs/GuildModerationTab.tsx
|
||||
#: src/features/guild/utils/guild_tabs/audit_log/AuditLogSharedMessages.ts
|
||||
@@ -33153,6 +33172,16 @@ msgstr "Startet af {name}"
|
||||
msgid "Started displaying members with this role separately"
|
||||
msgstr "Begyndte at vise medlemmer med denne rolle separat"
|
||||
|
||||
#. SMTP encryption option where a plain connection must upgrade with STARTTLS.
|
||||
#: src/features/app/components/setup/SetupWizardSteps.tsx
|
||||
msgid "STARTTLS"
|
||||
msgstr "STARTTLS"
|
||||
|
||||
#. SMTP encryption option where STARTTLS is used only if the server offers it.
|
||||
#: src/features/app/components/setup/SetupWizardSteps.tsx
|
||||
msgid "STARTTLS when offered"
|
||||
msgstr "STARTTLS, når det tilbydes"
|
||||
|
||||
#. Settings search synonym. Used to match this term when the user types it in the settings search bar.
|
||||
#: src/features/user/components/settings_utils/search_index/DesktopSettingsIndex.ts
|
||||
msgid "Startup"
|
||||
@@ -34625,6 +34654,26 @@ msgstr "Fællesskabet, som dette stammer fra, har slået kloning fra, så det ka
|
||||
msgid "The community you're looking for may have been deleted or you may not have access to it."
|
||||
msgstr "Det fællesskab, du leder efter, er muligvis blevet slettet, eller også har du ikke adgang til det."
|
||||
|
||||
#. Description of the implicit TLS SMTP encryption option.
|
||||
#: src/features/app/components/setup/SetupWizardSteps.tsx
|
||||
msgid "The connection is encrypted from the start. Usually port 465."
|
||||
msgstr "Forbindelsen er krypteret fra start. Normalt port 465."
|
||||
|
||||
#. Description of the SMTP encryption option that never uses TLS.
|
||||
#: src/features/app/components/setup/SetupWizardSteps.tsx
|
||||
msgid "The connection is never encrypted. Only for a mail server on a network you trust."
|
||||
msgstr "Forbindelsen krypteres aldrig. Kun til en mailserver på et netværk, du stoler på."
|
||||
|
||||
#. Description of the required STARTTLS SMTP encryption option.
|
||||
#: src/features/app/components/setup/SetupWizardSteps.tsx
|
||||
msgid "The connection must upgrade to TLS before signing in. Usually port 587."
|
||||
msgstr "Forbindelsen skal opgraderes til TLS, før du logger ind. Normalt port 587."
|
||||
|
||||
#. Description of the opportunistic STARTTLS SMTP encryption option.
|
||||
#: src/features/app/components/setup/SetupWizardSteps.tsx
|
||||
msgid "The connection upgrades to TLS only if the server offers it."
|
||||
msgstr "Forbindelsen opgraderes kun til TLS, hvis serveren tilbyder det."
|
||||
|
||||
#. Modal body shown when a desktop update download fails.
|
||||
#: src/features/updater/commands/UpdaterModalCommands.tsx
|
||||
msgid "The desktop update could not be downloaded. Try again from the update button when your connection is stable."
|
||||
@@ -38364,11 +38413,6 @@ msgstr "Brug enhedens systemindstilling for reduceret bevægelse"
|
||||
msgid "Use this device's system reduced motion preference, or customize it below."
|
||||
msgstr "Brug enhedens systemindstilling for reduceret bevægelse, eller tilpas den nedenfor."
|
||||
|
||||
#. Label for SMTP TLS toggle.
|
||||
#: src/features/app/components/setup/SetupWizardSteps.tsx
|
||||
msgid "Use TLS"
|
||||
msgstr "Brug TLS"
|
||||
|
||||
#. Permission name: allows voice activation instead of requiring push-to-talk.
|
||||
#: src/features/permissions/utils/PermissionLabelDescriptors.ts
|
||||
msgid "Use voice activity"
|
||||
|
||||
+49
-5
@@ -5414,8 +5414,10 @@ msgstr "Vervollständigungsvorschlag: {label}, {selectedOptionPosition} von {res
|
||||
#. Option label for a WebRTC content hint select.
|
||||
#. Option label for an SVC mode select.
|
||||
#. Option label for an encoder path select.
|
||||
#. SMTP encryption option that picks the mode from the port.
|
||||
#. Settings search synonym. Used to match this term when the user types it in the settings search bar.
|
||||
#. Voice region option label in the voice settings menu meaning let Fluxer pick the best region automatically.
|
||||
#: src/features/app/components/setup/SetupWizardSteps.tsx
|
||||
#: src/features/channel/components/modals/channel_tabs/channel_overview_tab/RtcRegionSelect.tsx
|
||||
#: src/features/user/components/modals/tabs/advanced_settings_tab/AdvancedVideoControls.tsx
|
||||
#: src/features/user/components/settings_utils/section_registry/NotificationsSections.ts
|
||||
@@ -14556,6 +14558,11 @@ msgstr "Encoder-Einstellung für neue Bildschirmfreigaben."
|
||||
msgid "Encoder, SVC, and bitrate presets"
|
||||
msgstr "Encoder, SVC und Bitraten-Voreinstellungen"
|
||||
|
||||
#. Label for the choice of how the SMTP connection is encrypted.
|
||||
#: src/features/app/components/setup/SetupWizardSteps.tsx
|
||||
msgid "Encryption"
|
||||
msgstr "Verschlüsselung"
|
||||
|
||||
#: src/features/input/utils/KeybindUtils.ts
|
||||
msgctxt "keyboard-key-name"
|
||||
msgid "End"
|
||||
@@ -18420,6 +18427,16 @@ msgstr "Bilder"
|
||||
msgid "Images pasted from your clipboard are sent as attachments, and you don't have permission to attach files in this channel."
|
||||
msgstr "Aus deiner Zwischenablage eingefügte Bilder werden als Anhänge gesendet. Du hast in diesem Kanal keine Berechtigung, Dateien anzuhängen."
|
||||
|
||||
#. SMTP encryption option where the connection starts encrypted.
|
||||
#: src/features/app/components/setup/SetupWizardSteps.tsx
|
||||
msgid "Implicit TLS"
|
||||
msgstr "Implizites TLS"
|
||||
|
||||
#. Description of the automatic SMTP encryption option.
|
||||
#: src/features/app/components/setup/SetupWizardSteps.tsx
|
||||
msgid "Implicit TLS on port 465, required STARTTLS on any other port."
|
||||
msgstr "Implizites TLS über Port 465, erforderliches STARTTLS über jeden anderen Port."
|
||||
|
||||
#. Primary button in the favorite GIF import modal. Starts importing pasted GIF URLs.
|
||||
#: src/features/channel/components/pickers/gif/ImportFavoriteGifsModal.tsx
|
||||
msgid "Import"
|
||||
@@ -23948,8 +23965,10 @@ msgstr "Nicht konvertieren"
|
||||
#. Developer tools debug menu label. Internal-only surface for developers; translators may keep this terse.
|
||||
#. Library picker entry meaning no sound is selected for this scope.
|
||||
#. Member verification level option in the community moderation settings tab. Short standalone label for the lowest level (no verification).
|
||||
#. SMTP encryption option that never uses TLS.
|
||||
#. Short label in the visual tab. Keep it concise.
|
||||
#. Summary row value on the confirm step when no items in the category were selected.
|
||||
#: src/features/app/components/setup/SetupWizardSteps.tsx
|
||||
#: src/features/channel/components/channel_header_components/developer_tools/DeveloperToolsShared.ts
|
||||
#: src/features/guild/components/modals/guild_tabs/GuildModerationTab.tsx
|
||||
#: src/features/guild/utils/guild_tabs/audit_log/AuditLogSharedMessages.ts
|
||||
@@ -33153,6 +33172,16 @@ msgstr "Gestartet von {name}"
|
||||
msgid "Started displaying members with this role separately"
|
||||
msgstr "Mitglieder mit dieser Rolle werden jetzt separat angezeigt"
|
||||
|
||||
#. SMTP encryption option where a plain connection must upgrade with STARTTLS.
|
||||
#: src/features/app/components/setup/SetupWizardSteps.tsx
|
||||
msgid "STARTTLS"
|
||||
msgstr "STARTTLS"
|
||||
|
||||
#. SMTP encryption option where STARTTLS is used only if the server offers it.
|
||||
#: src/features/app/components/setup/SetupWizardSteps.tsx
|
||||
msgid "STARTTLS when offered"
|
||||
msgstr "STARTTLS, wenn angeboten"
|
||||
|
||||
#. Settings search synonym. Used to match this term when the user types it in the settings search bar.
|
||||
#: src/features/user/components/settings_utils/search_index/DesktopSettingsIndex.ts
|
||||
msgid "Startup"
|
||||
@@ -34625,6 +34654,26 @@ msgstr "Die Community, aus der dies stammt, hat das Klonen deaktiviert, daher ka
|
||||
msgid "The community you're looking for may have been deleted or you may not have access to it."
|
||||
msgstr "Die gesuchte Community wurde möglicherweise gelöscht oder du hast keinen Zugriff darauf."
|
||||
|
||||
#. Description of the implicit TLS SMTP encryption option.
|
||||
#: src/features/app/components/setup/SetupWizardSteps.tsx
|
||||
msgid "The connection is encrypted from the start. Usually port 465."
|
||||
msgstr "Die Verbindung ist von Anfang an verschlüsselt. Normalerweise Port 465."
|
||||
|
||||
#. Description of the SMTP encryption option that never uses TLS.
|
||||
#: src/features/app/components/setup/SetupWizardSteps.tsx
|
||||
msgid "The connection is never encrypted. Only for a mail server on a network you trust."
|
||||
msgstr "Die Verbindung wird nie verschlüsselt. Nur für einen Mailserver in einem Netzwerk, dem du vertraust."
|
||||
|
||||
#. Description of the required STARTTLS SMTP encryption option.
|
||||
#: src/features/app/components/setup/SetupWizardSteps.tsx
|
||||
msgid "The connection must upgrade to TLS before signing in. Usually port 587."
|
||||
msgstr "Die Verbindung muss vor der Anmeldung auf TLS aktualisiert werden. Normalerweise Port 587."
|
||||
|
||||
#. Description of the opportunistic STARTTLS SMTP encryption option.
|
||||
#: src/features/app/components/setup/SetupWizardSteps.tsx
|
||||
msgid "The connection upgrades to TLS only if the server offers it."
|
||||
msgstr "Die Verbindung wird nur dann auf TLS aktualisiert, wenn der Server dies anbietet."
|
||||
|
||||
#. Modal body shown when a desktop update download fails.
|
||||
#: src/features/updater/commands/UpdaterModalCommands.tsx
|
||||
msgid "The desktop update could not be downloaded. Try again from the update button when your connection is stable."
|
||||
@@ -38364,11 +38413,6 @@ msgstr "Die Einstellung „Bewegung reduzieren“ des Systems dieses Geräts ver
|
||||
msgid "Use this device's system reduced motion preference, or customize it below."
|
||||
msgstr "Verwende die Einstellung für reduzierte Bewegung dieses Geräts oder passe sie unten an."
|
||||
|
||||
#. Label for SMTP TLS toggle.
|
||||
#: src/features/app/components/setup/SetupWizardSteps.tsx
|
||||
msgid "Use TLS"
|
||||
msgstr "TLS verwenden"
|
||||
|
||||
#. Permission name: allows voice activation instead of requiring push-to-talk.
|
||||
#: src/features/permissions/utils/PermissionLabelDescriptors.ts
|
||||
msgid "Use voice activity"
|
||||
|
||||
Loaded 100 of 213 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user