Compare commits

..
Author SHA1 Message Date
Hampus 74c6fad260 fix(billing): stack gifts from paid period end after trial ends (#3406) 2026-10-11 20:08:19 +02:00
Hampus bcb30eea88 fix(voice): one-way audio and stream loss in p2p calls (#3405) 2026-10-11 19:27:24 +02:00
Hampus d59ddc2214 fix(app): wait for the session before redirecting from /app (#3404) 2026-10-11 18:47:50 +02:00
Hampus e87b593f46 fix(media-proxy): fail slow external fetches sooner (#3403) 2026-10-11 18:47:11 +02:00
Hampus 5a2d81ebbb fix(media-proxy): bound animated transforms by output pixels (#3402) 2026-10-11 18:46:34 +02:00
Hampus 50c28aa8fa fix(admin): use instance branding on self-hosted (#3401) 2026-10-11 15:57:12 +02:00
Hampus af7724dbf6 fix(push): drop Fluxer badge on self-hosted instances (#3400) 2026-10-11 15:56:53 +02:00
Hampus be314788b3 fix(app): pixel-align mobile status indicator notch (#3399) 2026-10-11 15:56:15 +02:00
Hampus 5b3f69bf27 fix(app): only remember a language the user picked (#3398) 2026-10-11 15:55:55 +02:00
Hampus d7aa6ec2aa fix(app): use instance branding in self-hosted copy (#3397) 2026-10-11 15:55:15 +02:00
Hampus 20e3474393 fix(app-proxy): serve instance branding before boot (#3396) 2026-10-11 15:54:40 +02:00
Hampus 3694300d86 fix(api): show premium badge for premium override (#3395) 2026-10-11 14:55:43 +02:00
Hampus 54ec74e4f9 fix(app): load quick css from the account scope after sign-in (#3394) 2026-10-11 14:49:15 +02:00
Hampus aa3b058ce6 fix(media-proxy): serve originals and speed up slow transforms (#3393) 2026-10-11 14:41:01 +02:00
Hampus 44bc29b101 fix(media-proxy): let video work fit small memory budgets (#3392) 2026-10-11 14:14:59 +02:00
Hampus b07dcc368d fix(app): read instance theme color without an active runtime (#3391) 2026-10-11 13:37:05 +02:00
Hampus 0967bf3136 fix(api): SSO discovery, channel create and deleted bots (#3390) 2026-10-11 13:31:18 +02:00
Hampus edc913096a fix(selfhost): branding image origins and theme colour (#3389) 2026-10-11 13:31:05 +02:00
Hampus cca8227ca6 fix(email): test saved SMTP config and add TLS modes (#3388) 2026-10-11 13:30:00 +02:00
Hampus 45b78a13c2 fix(media): stream large files and honour size settings (#3387) 2026-10-11 13:28:24 +02:00
Hampus 6acd7f3d22 fix(admin): save premium limits and model changes (#3386) 2026-10-11 13:26:39 +02:00
Hampus 7d2c51a57f fix(media-proxy): budget native image work by pod memory (#3385) 2026-10-11 02:07:19 +02:00
267 changed files with 10625 additions and 1719 deletions

No files matched your search

Generated
+10
View File
@@ -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"
+1 -1
View File
@@ -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
+9 -2
View File
@@ -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=
+10
View File
@@ -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,15 @@ 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_PRODUCT_NAME: ${FLUXER_APP_PRODUCT_NAME:-}
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:-}
FLUXER_APP_THEME_COLOR: ${FLUXER_APP_THEME_COLOR:-}
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:-}
+66 -13
View File
@@ -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"}
+9
View File
@@ -49,6 +49,15 @@ impl AdminApiClient {
Self { generated }
}
pub fn anonymous(http_client: &reqwest::Client, config: &AdminConfig) -> Self {
let generated = GeneratedClient::new_with_client(
&config.api_endpoint,
http_client.clone(),
build_proxy_client_ip_headers(config),
);
Self { generated }
}
fn build_url(&self, path: &str, query_params: Option<&[(&str, &str)]>) -> String {
let mut url = format!("{}{}", self.generated.baseurl(), path);
let query = query_params
+3 -4
View File
@@ -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
}
+76 -17
View File
@@ -119,6 +119,9 @@ pub struct InstancePremiumDiscoveryAppPublic {
#[derive(Clone, Debug, Default, Deserialize)]
pub struct InstancePremiumDiscoveryBranding {
pub product_name: Option<String>,
pub icon_url: Option<String>,
pub favicon_url: Option<String>,
pub premium_product_name: Option<String>,
}
@@ -130,46 +133,67 @@ pub struct InstancePremiumDiscoveryFeatures {
impl InstancePremiumDiscovery {
pub fn premium_product_name(&self) -> Option<&str> {
self.app_public
.branding
.premium_product_name
.as_deref()
.map(str::trim)
.filter(|name| !name.is_empty())
non_blank(self.app_public.branding.premium_product_name.as_deref())
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
#[derive(Clone, Debug, Default, Eq, PartialEq)]
pub struct PremiumBranding {
pub name: Option<String>,
pub premium_enabled: bool,
pub product_name: Option<String>,
pub favicon_url: Option<String>,
}
impl PremiumBranding {
pub fn from_discovery(discovery: &InstancePremiumDiscovery) -> Self {
let branding = &discovery.app_public.branding;
Self {
name: discovery.premium_product_name().map(str::to_owned),
premium_enabled: discovery.features.premium_enabled,
product_name: non_blank(branding.product_name.as_deref()).map(str::to_owned),
favicon_url: favicon_url(
branding.favicon_url.as_deref(),
branding.icon_url.as_deref(),
),
}
}
pub fn from_instance_config(config: &InstanceConfigResponse) -> Self {
Self::from_config_parts(
config.self_hosted,
&config.app_public.branding.premium_product_name,
config.policy.premium_mode,
)
let branding = &config.app_public.branding;
Self {
product_name: non_blank(Some(&branding.product_name)).map(str::to_owned),
favicon_url: favicon_url(
branding.favicon_url.as_deref(),
branding.icon_url.as_deref(),
),
..Self::from_config_parts(
config.self_hosted,
&branding.premium_product_name,
config.policy.premium_mode,
)
}
}
fn from_config_parts(self_hosted: bool, name: &str, premium_mode: PremiumMode) -> Self {
let name = name.trim();
Self {
name: (!name.is_empty()).then(|| name.to_owned()),
name: non_blank(Some(name)).map(str::to_owned),
premium_enabled: !self_hosted || matches!(premium_mode, PremiumMode::Mirror),
..Self::default()
}
}
}
fn non_blank(value: Option<&str>) -> Option<&str> {
value.map(str::trim).filter(|value| !value.is_empty())
}
fn favicon_url(favicon_url: Option<&str>, icon_url: Option<&str>) -> Option<String> {
non_blank(favicon_url)
.or_else(|| non_blank(icon_url))
.map(str::to_owned)
}
#[cfg(test)]
mod tests {
use super::*;
@@ -304,18 +328,52 @@ mod tests {
PremiumBranding::from_discovery(&discovery),
PremiumBranding {
name: Some("Gold".to_owned()),
premium_enabled: true
premium_enabled: true,
product_name: Some("Example".to_owned()),
favicon_url: None,
}
);
}
#[test]
fn instance_branding_prefers_the_favicon_and_falls_back_to_the_icon() {
let discovery = |branding| -> InstancePremiumDiscovery {
serde_json::from_value(json!({"app_public": {"branding": branding}}))
.expect("discovery")
};
let both = discovery(json!({
"favicon_url": "https://media.example/favicon.png",
"icon_url": "https://media.example/icon.png"
}));
assert_eq!(
PremiumBranding::from_discovery(&both)
.favicon_url
.as_deref(),
Some("https://media.example/favicon.png")
);
let icon_only =
discovery(json!({"favicon_url": " ", "icon_url": "https://media.example/icon.png"}));
assert_eq!(
PremiumBranding::from_discovery(&icon_only)
.favicon_url
.as_deref(),
Some("https://media.example/icon.png")
);
let neither = discovery(json!({"product_name": " "}));
assert_eq!(
PremiumBranding::from_discovery(&neither),
PremiumBranding::default()
);
}
#[test]
fn premium_branding_from_instance_config_matches_discovery_rules() {
assert_eq!(
PremiumBranding::from_config_parts(true, " Gold ", PremiumMode::Everyone),
PremiumBranding {
name: Some("Gold".to_owned()),
premium_enabled: false
premium_enabled: false,
..PremiumBranding::default()
}
);
assert!(
@@ -325,7 +383,8 @@ mod tests {
PremiumBranding::from_config_parts(false, " ", PremiumMode::Everyone),
PremiumBranding {
name: None,
premium_enabled: true
premium_enabled: true,
..PremiumBranding::default()
}
);
}
+3 -11
View File
@@ -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)]
+11 -18
View File
@@ -1,7 +1,7 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use crate::{
api::types::AdminUser,
api::types::{AdminUser, PremiumBranding},
middleware::flash,
session::{self, Session},
state::AppState,
@@ -16,6 +16,7 @@ use axum::{
pub struct AuthContext {
pub session: Session,
pub admin_user: Option<AdminUser>,
pub branding: Option<PremiumBranding>,
}
pub async fn require_auth(
@@ -28,27 +29,19 @@ pub async fn require_auth(
let Some(session) = session else {
return Redirect::to(&format!("{}/auth/start", config.base_path)).into_response();
};
let admin_result = fetch_admin_user(state.http_client(), config, &session).await;
match admin_result {
let admin_user = match fetch_admin_user(state.http_client(), config, &session).await {
AdminFetchResult::Unauthorized => {
let login_url = format!("{}/login", config.base_path);
return clear_session_and_redirect(&login_url, config);
}
AdminFetchResult::Ok(admin_user) => {
let auth_context = AuthContext {
session,
admin_user: Some(*admin_user),
};
request.extensions_mut().insert(auth_context);
}
AdminFetchResult::None => {
let auth_context = AuthContext {
session,
admin_user: None,
};
request.extensions_mut().insert(auth_context);
}
}
AdminFetchResult::Ok(admin_user) => Some(*admin_user),
AdminFetchResult::None => None,
};
request.extensions_mut().insert(AuthContext {
session,
admin_user,
branding: state.self_hosted_branding().await,
});
let had_flash = flash::extract_flash(&request);
if let Some(ref fd) = had_flash {
+2 -1
View File
@@ -87,7 +87,8 @@ async fn login_page(
Some(_) => Some("Login error. Please try again."),
None => None,
};
Html(login::login_page(config, error_msg).into_string()).into_response()
let branding = state.self_hosted_branding().await;
Html(login::login_page(config, branding.as_ref(), error_msg).into_string()).into_response()
}
async fn auth_start(State(state): State<AppState>) -> Response {
+1 -1
View File
@@ -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,
+248 -48
View File
@@ -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, &current.limit_keys, fallback);
update_limit_rule_values(
rule,
&form,
&current.limit_keys,
&current.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,
+3
View File
@@ -480,11 +480,13 @@ mod tests {
let gold = PremiumBranding {
name: Some("Gold".to_owned()),
premium_enabled: true,
..PremiumBranding::default()
};
assert_eq!(premium_badge_name(Some(&gold)).as_deref(), Some("Gold"));
let unnamed = PremiumBranding {
name: None,
premium_enabled: true,
..PremiumBranding::default()
};
assert_eq!(
premium_badge_name(Some(&unnamed)).as_deref(),
@@ -493,6 +495,7 @@ mod tests {
let everyone = PremiumBranding {
name: Some("Gold".to_owned()),
premium_enabled: false,
..PremiumBranding::default()
};
assert_eq!(premium_badge_name(Some(&everyone)), None);
assert_eq!(premium_badge_name(None).as_deref(), Some("Premium"));
+11
View File
@@ -88,6 +88,17 @@ impl AppState {
Some(branding)
}
pub async fn self_hosted_branding(&self) -> Option<PremiumBranding> {
if !self.config().self_hosted {
return None;
}
self.premium_branding(&AdminApiClient::anonymous(
self.http_client(),
self.config(),
))
.await
}
pub async fn account_identity(&self, client: &AdminApiClient) -> AccountIdentityMode {
self.account_identity_settings(client).await.mode
}
@@ -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,
+78 -5
View File
@@ -1,6 +1,10 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use crate::{api::types::FlashLevel, config::AdminConfig, middleware::auth::AuthContext};
use crate::{
api::types::{FlashLevel, PremiumBranding},
config::AdminConfig,
middleware::auth::AuthContext,
};
use maud::{DOCTYPE, Markup, PreEscaped, html};
use super::components::drawer::drawer_controller_script;
@@ -11,6 +15,28 @@ use super::layout_scripts::{
};
use super::layout_sidebar::render_sidebar;
const DEFAULT_PRODUCT_NAME: &str = "Fluxer";
pub fn product_name(branding: Option<&PremiumBranding>) -> &str {
branding
.and_then(|branding| branding.product_name.as_deref())
.unwrap_or(DEFAULT_PRODUCT_NAME)
}
pub fn favicon_links(config: &AdminConfig, branding: Option<&PremiumBranding>) -> Markup {
let cdn = &config.static_cdn_endpoint;
html! {
@if let Some(favicon_url) = branding.and_then(|branding| branding.favicon_url.as_deref()) {
link rel="icon" href=(favicon_url);
} @else {
link rel="icon" type="image/x-icon" href={(cdn) "/web/favicon.ico"};
link rel="apple-touch-icon" href={(cdn) "/web/apple-touch-icon.png"};
link rel="icon" type="image/png" sizes="32x32" href={(cdn) "/web/favicon-32x32.png"};
link rel="icon" type="image/png" sizes="16x16" href={(cdn) "/web/favicon-16x16.png"};
}
}
}
fn cache_busted_asset(base_path: &str, asset_version: &str, path: &str) -> String {
format!("{base_path}{path}?t={asset_version}")
}
@@ -72,10 +98,7 @@ pub fn admin_layout_ext(
title { (title) " ~ Fluxer Admin" }
link rel="stylesheet" href={(base) "/static/fonts/" (crate::fonts::STYLESHEET_FILE_NAME)};
link rel="stylesheet" href=(cache_busted_asset(base, asset_version, "/static/app.css"));
link rel="icon" type="image/x-icon" href={(config.static_cdn_endpoint) "/web/favicon.ico"};
link rel="apple-touch-icon" href={(config.static_cdn_endpoint) "/web/apple-touch-icon.png"};
link rel="icon" type="image/png" sizes="32x32" href={(config.static_cdn_endpoint) "/web/favicon-32x32.png"};
link rel="icon" type="image/png" sizes="16x16" href={(config.static_cdn_endpoint) "/web/favicon-16x16.png"};
(favicon_links(config, auth.branding.as_ref()))
script src=(cache_busted_asset(base, asset_version, "/static/htmx.min.js")) defer {}
}
body class="min-h-screen overflow-hidden bg-neutral-50" hx-boost="true" {
@@ -126,3 +149,53 @@ fn render_flash(flash: &crate::api::types::FlashMessage) -> Markup {
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::ProxyConfig;
fn config() -> AdminConfig {
AdminConfig {
host: String::new(),
port: 3020,
secret_key_base: "test-secret".to_owned(),
base_path: String::new(),
api_endpoint: String::new(),
media_endpoint: String::new(),
static_cdn_endpoint: "https://static.example".to_owned(),
reports_bucket_origin: String::new(),
admin_endpoint: String::new(),
web_app_endpoint: String::new(),
oauth_client_id: String::new(),
oauth_client_secret: String::new(),
oauth_redirect_uri: String::new(),
build_version: "test".to_owned(),
self_hosted: true,
proxy: ProxyConfig {
trust_client_ip_header: false,
client_ip_header_name: String::new(),
},
}
}
#[test]
fn instance_branding_replaces_the_fluxer_name_and_favicons() {
let branding = PremiumBranding {
product_name: Some("Example".to_owned()),
favicon_url: Some("https://media.example/favicon.png".to_owned()),
..PremiumBranding::default()
};
let links = favicon_links(&config(), Some(&branding)).into_string();
assert!(links.contains("https://media.example/favicon.png"));
assert!(!links.contains("static.example"));
assert_eq!(product_name(Some(&branding)), "Example");
}
#[test]
fn missing_branding_keeps_the_fluxer_name_and_favicons() {
let links = favicon_links(&config(), Some(&PremiumBranding::default())).into_string();
assert!(links.contains("https://static.example/web/favicon.ico"));
assert_eq!(product_name(None), "Fluxer");
}
}
@@ -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"))
}
}
}
}
+12 -4
View File
@@ -1,9 +1,17 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use crate::config::AdminConfig;
use crate::{
api::types::PremiumBranding,
config::AdminConfig,
templates::layout::{favicon_links, product_name},
};
use maud::{DOCTYPE, Markup, html};
pub fn login_page(config: &AdminConfig, error_message: Option<&str>) -> Markup {
pub fn login_page(
config: &AdminConfig,
branding: Option<&PremiumBranding>,
error_message: Option<&str>,
) -> Markup {
let base = &config.base_path;
html! {
(DOCTYPE)
@@ -14,7 +22,7 @@ pub fn login_page(config: &AdminConfig, error_message: Option<&str>) -> Markup {
title { "Login ~ Fluxer Admin" }
link rel="stylesheet" href={(base) "/static/fonts/" (crate::fonts::STYLESHEET_FILE_NAME)};
link rel="stylesheet" href={(base) "/static/app.css"};
link rel="icon" type="image/x-icon" href={(config.static_cdn_endpoint) "/web/favicon.ico"};
(favicon_links(config, branding))
}
body class="flex min-h-[100dvh] items-center justify-center bg-neutral-50 p-4" {
main class="w-full max-w-sm" {
@@ -38,7 +46,7 @@ pub fn login_page(config: &AdminConfig, error_message: Option<&str>) -> Markup {
bg-neutral-900 text-white hover:bg-neutral-800 \
px-4 py-2 text-base w-full sm:w-fit \
focus:ring-offset-white" {
span { "Sign in with Fluxer" }
span { "Sign in with " (product_name(branding)) }
}
}
}
@@ -11,7 +11,7 @@ use crate::{
},
page_container::{card, page_header},
},
layout::admin_layout,
layout::{admin_layout, product_name},
},
};
use maud::{Markup, html};
@@ -37,9 +37,9 @@ pub fn system_dm_page(
"Send a system DM"
}
p class="text-sm text-neutral-500" {
"Sent from the official Fluxer system account. Each recipient \
will receive the same content as a DM. Progress is observable \
on the "
"Sent from the official " (product_name(auth.branding.as_ref()))
" system account. Each recipient will receive the same content \
as a DM. Progress is observable on the "
a href=(jobs_url) class="font-medium text-neutral-900 hover:underline" {
"Jobs page"
}
@@ -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"))
+2 -1
View File
@@ -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
+54
View File
@@ -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&amp;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()));
+1
View File
@@ -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));
});
});
+12 -3
View File
@@ -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;
}
}
+3 -1
View File
@@ -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);
});
});
+11 -7
View File
@@ -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};
+2 -1
View File
@@ -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: {
+140 -15
View File
@@ -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,
+32 -14
View File
@@ -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);
@@ -9,7 +9,7 @@ export function getGiftTrialMetadataKey(giftCode: string): string {
return `${GIFT_TRIAL_KEY_PREFIX}${giftCode}`;
}
function isGiftTrialRunning(subscription: Stripe.Subscription, nowMs: number): boolean {
export function isGiftTrialRunning(subscription: Stripe.Subscription, nowMs: number): boolean {
return subscription.trial_end != null && subscription.trial_end * 1000 > nowMs;
}
@@ -10,7 +10,11 @@ import {addGiftCodeDuration} from '@app/api/models/GiftCode';
import type {User} from '@app/api/models/User';
import type {StoreEntitlementService} from '@app/api/store_billing/StoreEntitlementService';
import type {ProductInfo, RecurringBillingCycle} from '@app/api/stripe/ProductRegistry';
import {buildGiftTrialMetadata, getGiftTrialPaidUntil} from '@app/api/stripe/StripeGiftTrialMetadata';
import {
buildGiftTrialMetadata,
getGiftTrialPaidUntil,
isGiftTrialRunning,
} from '@app/api/stripe/StripeGiftTrialMetadata';
import {
getPrimarySubscriptionItem,
getSubscriptionEntitlementPeriodEndUnix,
@@ -1293,7 +1297,8 @@ export class StripeSubscriptionService {
const currentTrialEnd = subscription.trial_end;
const item = getPrimarySubscriptionItem(subscription);
const currentPeriodEnd = getSubscriptionItemPeriodEndUnix(item);
const baseUnix = currentTrialEnd ?? currentPeriodEnd;
const nowMs = Date.now();
const baseUnix = isGiftTrialRunning(subscription, nowMs) ? currentTrialEnd : currentPeriodEnd;
Logger.debug(
{
userId: user.id,
@@ -1331,7 +1336,7 @@ export class StripeSubscriptionService {
{
trial_end: newTrialEndUnix,
proration_behavior: 'none',
metadata: buildGiftTrialMetadata(subscription, idempotencyKey, baseUnix, newTrialEndUnix, Date.now()),
metadata: buildGiftTrialMetadata(subscription, idempotencyKey, baseUnix, newTrialEndUnix, nowMs),
},
{idempotencyKey: stripeIdempotencyKey},
);
@@ -749,5 +749,45 @@ describe('gift time around a Stripe subscription', () => {
const afterWebhook = await findUser(account.userId);
expect(afterWebhook.premiumGiftExtensionEndsAt?.getTime()).toBe(trialEnd.getTime());
});
test('stacks from the paid period end when an earlier gift trial has ended', async () => {
const subscriptionId = 'sub_shift_stale_trial';
const periodEnd = Math.floor((Date.now() + ms('20 days')) / 1000);
const staleTrialEnd = periodEnd - 60 * 24 * 60 * 60;
useStripe({
subscriptions: {
[subscriptionId]: {
current_period_start: periodEnd - 30 * 24 * 60 * 60,
current_period_end: periodEnd,
trial_end: staleTrialEnd,
metadata: giftTrialMetadata('OLDGIFT', 30, staleTrialEnd - 30 * 24 * 60 * 60),
},
},
});
const {account} = await createUser({
premium_type: UserPremiumTypes.SUBSCRIPTION,
stripe_subscription_id: subscriptionId,
stripe_customer_id: 'cus_test_1',
premium_billing_cycle: 'monthly',
premium_until: new Date(periodEnd * 1000),
});
const gifter = await createTestAccount(harness);
await createBuilder(harness, gifter.token)
.post('/test/gifts/SHIFTSTALEGIFT')
.body({duration_type: 'days', duration_quantity: 30, created_by_user_id: gifter.userId})
.execute();
await createBuilder(harness, account.token).post('/gifts/SHIFTSTALEGIFT/redeem').expect(204).execute();
const update = stripeHandlers.spies.updatedSubscriptions[0]?.params;
expect(Number(update?.trial_end)).toBe(periodEnd + 30 * 24 * 60 * 60);
expect(update?.metadata).toEqual({
gtrial_OLDGIFT: '',
gtrial_paid_until: String(periodEnd),
gtrial_SHIFTSTALEGIFT: String(30 * 24 * 60 * 60),
});
const redeemed = await findUser(account.userId);
expect(redeemed.premiumUntil?.getTime()).toBe((periodEnd + 30 * 24 * 60 * 60) * 1000);
});
});
});
+32
View File
@@ -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);
};
}
+1 -1
View File
@@ -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(),
@@ -3,6 +3,7 @@
import {
canOwnerRunBots,
checkIsPremium,
getBadgePremiumType,
getEffectivePremiumUntil,
getPremiumPaymentRecoveryGraceMs,
isSignInRefused,
@@ -61,6 +62,31 @@ describe('checkIsPremium', () => {
});
});
describe('getBadgePremiumType', () => {
it('shows a subscription badge for a backend premium override', () => {
expect(getBadgePremiumType({premiumType: null, premiumFlags: PremiumFlags.ENABLED_OVERRIDE})).toBe(
UserPremiumTypes.SUBSCRIPTION,
);
expect(getBadgePremiumType({premiumType: UserPremiumTypes.NONE, premiumFlags: PremiumFlags.ENABLED_OVERRIDE})).toBe(
UserPremiumTypes.SUBSCRIPTION,
);
});
it('keeps the stored premium type', () => {
expect(
getBadgePremiumType({premiumType: UserPremiumTypes.LIFETIME, premiumFlags: PremiumFlags.ENABLED_OVERRIDE}),
).toBe(UserPremiumTypes.LIFETIME);
});
it('shows no badge without premium or while perks are disabled', () => {
expect(getBadgePremiumType({premiumType: null, premiumFlags: 0})).toBeUndefined();
expect(
getBadgePremiumType({
premiumType: null,
premiumFlags: PremiumFlags.ENABLED_OVERRIDE | PremiumFlags.PERKS_DISABLED,
}),
).toBeUndefined();
});
});
describe('account standing', () => {
const hour = 3_600_000;
function standing(flags: bigint, tempBannedUntil: Date | null = null, deletionStartedAt: Date | null = null) {
+17
View File
@@ -9,6 +9,8 @@ import {
PREMIUM_PAYMENT_RECOVERY_GRACE_DAYS,
PremiumFlags,
UserFlags,
type UserPremiumType,
UserPremiumTypes,
} from '@fluxer/constants/src/UserConstants';
import {MS_PER_DAY} from '@fluxer/date_utils/src/DateConstants';
import {snowflakeToDate} from '@fluxer/snowflake/src/Snowflake';
@@ -121,6 +123,21 @@ export function checkIsPremium(user: PremiumCheckable): boolean {
return false;
}
export function getBadgePremiumType(
user: Pick<PremiumCheckable, 'premiumType' | 'premiumFlags'>,
): UserPremiumType | undefined {
if (user.premiumType != null && user.premiumType !== UserPremiumTypes.NONE) {
return user.premiumType as UserPremiumType;
}
if (
(user.premiumFlags & (PremiumFlags.ENABLED_OVERRIDE | PremiumFlags.PERKS_DISABLED)) ===
PremiumFlags.ENABLED_OVERRIDE
) {
return UserPremiumTypes.SUBSCRIPTION;
}
return undefined;
}
const PREMIUM_CLEAR_FIELDS = [
'premium_type',
'premium_since',
@@ -11,6 +11,7 @@ import type {GuildMember} from '@app/api/models/GuildMember';
import type {User} from '@app/api/models/User';
import type {UserRepository} from '@app/api/user/repositories/UserRepository';
import {isUsernameTaken} from '@app/api/user/UniqueUsernames';
import {getBadgePremiumType} from '@app/api/user/UserHelpers';
import {hasFixedDiscriminator} from '@app/api/user/UserTag';
import {ChannelTypes} from '@fluxer/constants/src/ChannelConstants';
import {
@@ -100,7 +101,7 @@ export class UserAccountLookupService {
}
}
}
let premiumType = user.premiumType ?? undefined;
let premiumType = getBadgePremiumType(user);
let premiumSince = user.premiumSince ?? undefined;
let premiumLifetimeSequence = user.premiumLifetimeSequence ?? undefined;
if (restrictProfile || user.premiumFlags & PremiumFlags.BADGE_HIDDEN) {
@@ -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();
+2 -1
View File
@@ -145,7 +145,8 @@ function calculateStatusGeometry(avatarSize: number, isMobile: boolean = false):
return baseGeometry;
}
const phoneWidth = statusSize;
const phoneHeight = Math.round(phoneWidth / DESIGN_RULES.mobileAspectRatio) + DESIGN_RULES.mobilePhoneExtraHeight;
const phoneHeight =
Math.round((phoneWidth / DESIGN_RULES.mobileAspectRatio + DESIGN_RULES.mobilePhoneExtraHeight) / 2) * 2;
const phoneRx = Math.round(phoneWidth * DESIGN_RULES.mobileCornerRadius);
const bezelHeight = Math.max(1, Math.round(phoneHeight * 0.05));
const phoneX = cutoutCenter - phoneWidth / 2;
+10 -3
View File
@@ -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,
+4 -4
View File
@@ -1,6 +1,7 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {messages as messagesEnUS} from '@app/features/i18n/locales/en-US/messages.mjs';
import {AppStorageKey} from '@app/features/platform/state/AppStorageKeys';
import AppStorage from '@app/features/platform/state/PersistentStorage';
import {getNativeLocaleIdentifier} from '@app/features/platform/types/Platform';
import {Logger} from '@app/features/platform/utils/AppLogger';
@@ -145,7 +146,7 @@ function detectPreferredLocale(forceLocale?: string): LocaleCode {
if (forceLocale) {
return normalizeLocale(forceLocale);
}
const storedLocale = AppStorage.getItem('locale');
const storedLocale = AppStorage.getItem(AppStorageKey.I18N_CHOSEN_LOCALE);
if (storedLocale) {
return normalizeLocale(storedLocale);
}
@@ -166,7 +167,6 @@ function activateLocale(localeCode: LocaleCode, messages: Messages): void {
document.documentElement.lang = localeCode;
}
noteLocale(localeCode);
AppStorage.setItem('locale', localeCode);
}
export function ensureActiveLocale(): void {
@@ -208,10 +208,10 @@ export function applyLocaleChange(localeCode: string): LocaleCode {
const normalized = normalizeLocale(localeCode);
requestedLocale = normalized;
if (normalized === i18n.locale) {
AppStorage.setItem('locale', normalized);
AppStorage.setItem(AppStorageKey.I18N_CHOSEN_LOCALE, normalized);
return normalized;
}
AppStorage.setItem('locale', normalized);
AppStorage.setItem(AppStorageKey.I18N_CHOSEN_LOCALE, normalized);
void loadLocaleCatalog(normalized).catch((error) => {
logger.error(`Failed to apply locale ${normalized}`, error);
});
@@ -9,6 +9,13 @@ import {getDefaultLandingPath} from '@app/features/navigation/utils/DefaultLandi
import {createRootRoute, createRoute} from '@app/features/platform/components/router/RouterBuilder';
import {Redirect} from '@app/features/platform/components/router/RouterTypes';
const enterDefaultLanding = afterSessionInitialization(() => {
if (readAuthenticatedRuntimeContext() === null) {
return new Redirect(Routes.LOGIN);
}
return new Redirect(getDefaultLandingPath());
});
export const rootRoute = createRootRoute({
layout: ({children}) => (
<RootComponent data-flx="app.router.root-routes.layout.root-component">{children}</RootComponent>
@@ -23,16 +30,11 @@ export const homeRoute = createRoute({
getParentRoute: () => rootRoute,
id: 'home',
path: '/',
onEnter: afterSessionInitialization(() => {
if (readAuthenticatedRuntimeContext() === null) {
return new Redirect(Routes.LOGIN);
}
return new Redirect(getDefaultLandingPath());
}),
onEnter: enterDefaultLanding,
});
export const appRoute = createRoute({
getParentRoute: () => rootRoute,
id: 'app',
path: Routes.APP,
onEnter: () => new Redirect(getDefaultLandingPath()),
onEnter: enterDefaultLanding,
});
@@ -784,9 +784,6 @@ class Accessibility {
persistLocalMotionSettings(this.localMotionSettingsSnapshot);
}
this.customThemeCss = readLocalCustomThemeCss();
if (this.customThemeCss !== null) {
persistLocalCustomThemeCss(this.customThemeCss);
}
this.customThemeCssSyncAcrossDevices = readLocalCustomThemeCssSyncAcrossDevices();
this.showNeko = readAndMigrateLocalShowNeko();
this.keepNekoStill = readAndMigrateLocalKeepNekoStill();
@@ -798,6 +795,7 @@ class Accessibility {
this.initializeShowNekoSessionSync();
this.initializeShowNekoStorageSync();
this.initializeVideoSeekPreviewThumbnailsStorageSync();
this.initializeCustomThemeCssStorageSync();
this.applyStartupPresentationSettings();
initializeStore(this, () => this.initPersistence());
}
@@ -1021,7 +1019,6 @@ class Accessibility {
},
});
await this.applyStoredZoom();
this.applyStoredCustomThemeCss();
}
private initializeMotionDetection() {
@@ -1498,12 +1495,23 @@ class Accessibility {
await this.applyZoom(zoomLevel);
}
applyStoredCustomThemeCss(): void {
const customThemeCss = readLocalCustomThemeCss();
if (customThemeCss !== null) {
this.customThemeCss = customThemeCss;
persistLocalCustomThemeCss(customThemeCss);
}
private initializeCustomThemeCssStorageSync(): void {
AppStorage.subscribe(
(event) => {
if (
event.key !== null &&
event.key !== ACCESSIBILITY_CUSTOM_THEME_STORAGE_KEY &&
event.key !== ACCESSIBILITY_CUSTOM_THEME_SYNC_STORAGE_KEY
) {
return;
}
runInAction(() => {
this.customThemeCss = readLocalCustomThemeCss();
this.customThemeCssSyncAcrossDevices = readLocalCustomThemeCssSyncAcrossDevices();
});
},
{source: 'external'},
);
}
private applyStartupPresentationSettings(): void {
@@ -2,15 +2,13 @@
import {BootstrapErrorKind, classifyBootstrapError} from '@app/features/app/components/BootstrapErrorKind';
import styles from '@app/features/app/components/ErrorFallback.module.css';
import {
BLUESKY_PROVIDER_NAME,
FLUXER_BLUESKY_HANDLE,
PRODUCT_NAME,
} from '@app/features/app/config/I18nDisplayConstants';
import {BLUESKY_PROVIDER_NAME, FLUXER_BLUESKY_HANDLE} from '@app/features/app/config/I18nDisplayConstants';
import {resolveAppShellBranding} from '@app/features/app/state/AppShellBranding';
import RuntimeConfig from '@app/features/app/state/RuntimeConfig';
import {TRY_AGAIN_DESCRIPTOR} from '@app/features/i18n/utils/CommonMessageDescriptors';
import {ResetClientStateReason, resetClientState} from '@app/features/platform/state/ResetClientState';
import {Button} from '@app/features/ui/button/Button';
import {APPLICATION_ICON_DESCRIPTOR, FluxerIconMark} from '@app/features/ui/components/icons/FluxerIconMark';
import {FluxerIcon} from '@app/features/ui/components/icons/FluxerIcon';
import {ExternalUrls} from '@fluxer/constants/src/ExternalUrls';
import {Trans, useLingui} from '@lingui/react/macro';
import type React from 'react';
@@ -62,13 +60,13 @@ export const BootstrapErrorScreen: React.FC<BootstrapErrorScreenProps> = ({error
window.location.reload(),
);
}, []);
const snapshot = RuntimeConfig.getSnapshotOrNull();
const productName = resolveAppShellBranding(snapshot).productName;
const statusPageUrl = RuntimeConfig.statusPageUrl;
const selfHosted = snapshot !== null && RuntimeConfig.isSelfHosted();
return (
<div className={styles.errorFallbackContainer} data-flx="app.bootstrap-error-screen.error-fallback-container">
<FluxerIconMark
aria-label={i18n._(APPLICATION_ICON_DESCRIPTOR, {productName: PRODUCT_NAME})}
className={styles.errorFallbackIcon}
data-flx="app.bootstrap-error-screen.error-fallback-icon"
/>
<FluxerIcon className={styles.errorFallbackIcon} data-flx="app.bootstrap-error-screen.error-fallback-icon" />
<div className={styles.errorFallbackContent} data-flx="app.bootstrap-error-screen.error-fallback-content">
<h1 className={styles.errorFallbackTitle} data-flx="app.bootstrap-error-screen.error-fallback-title">
{unreachable ? <Trans>Can't connect</Trans> : <Trans>Failed to start</Trans>}
@@ -78,7 +76,7 @@ export const BootstrapErrorScreen: React.FC<BootstrapErrorScreenProps> = ({error
<Trans>Check your connection and try again.</Trans>
) : (
<Trans>
{PRODUCT_NAME} failed to start properly. This could be due to corrupted data or a temporary issue.
{productName} failed to start properly. This could be due to corrupted data or a temporary issue.
</Trans>
)}
</p>
@@ -91,23 +89,44 @@ export const BootstrapErrorScreen: React.FC<BootstrapErrorScreenProps> = ({error
{error.message}
</p>
)}
<p
className={styles.errorFallbackDescription}
data-flx="app.bootstrap-error-screen.error-fallback-description--3"
>
<Trans>
Check our{' '}
<a
href={ExternalUrls.BLUESKY}
target="_blank"
rel="noopener noreferrer"
data-flx="app.bootstrap-error-screen.a"
>
{BLUESKY_PROVIDER_NAME} ({FLUXER_BLUESKY_HANDLE})
</a>{' '}
for status updates.
</Trans>
</p>
{statusPageUrl && (
<p
className={styles.errorFallbackDescription}
data-flx="app.bootstrap-error-screen.error-fallback-description--status"
>
<Trans>
Check the{' '}
<a
href={statusPageUrl}
target="_blank"
rel="noopener noreferrer"
data-flx="app.bootstrap-error-screen.status-page-link"
>
status page
</a>{' '}
for updates.
</Trans>
</p>
)}
{!selfHosted && (
<p
className={styles.errorFallbackDescription}
data-flx="app.bootstrap-error-screen.error-fallback-description--3"
>
<Trans>
Check our{' '}
<a
href={ExternalUrls.BLUESKY}
target="_blank"
rel="noopener noreferrer"
data-flx="app.bootstrap-error-screen.a"
>
{BLUESKY_PROVIDER_NAME} ({FLUXER_BLUESKY_HANDLE})
</a>{' '}
for status updates.
</Trans>
</p>
)}
</div>
<div className={styles.errorFallbackActions} data-flx="app.bootstrap-error-screen.error-fallback-actions">
<Button onClick={reloadApp} data-flx="app.bootstrap-error-screen.button.retry">
@@ -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: []},
});
}
}
Loaded 100 of 267 files, more files were not shown because too many files have changed in this diff. Show more