mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-07 19:22:14 +09:00
fix(config)!: validate and derive config, drop the unread keys (#2482)
This commit is contained in:
Vendored
+2
-3
@@ -89,7 +89,6 @@ FLUXER_GATEWAY_PRESENCE_PUSH_BUFFER_MAX_BYTES=1048576
|
||||
FLUXER_ADMIN_PORT=3020
|
||||
FLUXER_ADMIN_BASE_PATH=/admin
|
||||
FLUXER_ADMIN_SECRET_KEY_BASE=dev-admin-secret-key-base
|
||||
FLUXER_ADMIN_OAUTH_CLIENT_ID=1234567890123456789
|
||||
FLUXER_ADMIN_OAUTH_CLIENT_SECRET=dev-admin-oauth-secret
|
||||
FLUXER_ADMIN_OAUTH_REDIRECT_URI=http://localhost:8088/admin/oauth2_callback
|
||||
FLUXER_MARKETING_PORT=3010
|
||||
@@ -99,8 +98,8 @@ FLUXER_MARKETING_SECRET_KEY_BASE=dev-marketing-secret-key-base
|
||||
|
||||
FLUXER_SUDO_MODE_SECRET=dev-sudo-secret
|
||||
FLUXER_CONNECTION_INITIATION_SECRET=dev-connection-initiation-secret
|
||||
FLUXER_VAPID_PUBLIC_KEY=dev-vapid-public-key
|
||||
FLUXER_VAPID_PRIVATE_KEY=dev-vapid-private-key
|
||||
FLUXER_VAPID_PUBLIC_KEY=BHIbdKs24FdPkOQS7hbeg3adceLS0IqlKsn71ywEe6kbeopeFFiG3lkvJac7BVqkuk7mxwEa555O2FXV3HLt56w
|
||||
FLUXER_VAPID_PRIVATE_KEY=cs24JvXSxHiqJQgkJNocJFAdzJpPmpfU9xD-fDpn3tw
|
||||
FLUXER_VAPID_EMAIL=dev@localhost
|
||||
FLUXER_PASSKEY_RP_NAME='Fluxer Dev'
|
||||
FLUXER_PASSKEY_RP_ID=localhost
|
||||
|
||||
@@ -120,6 +120,10 @@ impl AdminConfig {
|
||||
pub fn is_production(&self) -> bool {
|
||||
self.env == RuntimeEnv::Production
|
||||
}
|
||||
|
||||
pub fn secure_cookies(&self) -> bool {
|
||||
self.admin_endpoint.starts_with("https://")
|
||||
}
|
||||
}
|
||||
|
||||
impl RuntimeEnv {
|
||||
|
||||
@@ -28,7 +28,7 @@ pub async fn csrf_protection(
|
||||
let config = state.config();
|
||||
let secret = config.secret_key_base.clone();
|
||||
let admin_endpoint = config.admin_endpoint.clone();
|
||||
let is_production = config.is_production();
|
||||
let secure_cookies = config.secure_cookies();
|
||||
|
||||
let user_id = request
|
||||
.extensions()
|
||||
@@ -76,17 +76,17 @@ pub async fn csrf_protection(
|
||||
|
||||
let mut response = next.run(request).await;
|
||||
|
||||
let cookie_name = if is_production {
|
||||
let cookie_name = if secure_cookies {
|
||||
HOST_CSRF_COOKIE_NAME
|
||||
} else {
|
||||
CSRF_COOKIE_NAME
|
||||
};
|
||||
let secure = if is_production { "; Secure" } else { "" };
|
||||
let secure = if secure_cookies { "; Secure" } else { "" };
|
||||
let cookie_value = format!("{cookie_name}={token}; Path=/; SameSite=Lax; HttpOnly{secure}");
|
||||
if let Ok(value) = HeaderValue::from_str(&cookie_value) {
|
||||
response.headers_mut().append(header::SET_COOKIE, value);
|
||||
}
|
||||
if is_production
|
||||
if secure_cookies
|
||||
&& let Ok(value) = HeaderValue::from_str(&format!(
|
||||
"{CSRF_COOKIE_NAME}=; Path=/; SameSite=Lax; HttpOnly; Max-Age=0"
|
||||
))
|
||||
@@ -199,6 +199,81 @@ pub fn get_csrf_token(request: &Request) -> String {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::config::{AdminConfig, ProxyConfig, RuntimeEnv};
|
||||
use crate::state::AppState;
|
||||
use axum::{Router, middleware::from_fn_with_state, routing::get};
|
||||
use tower::ServiceExt;
|
||||
|
||||
fn state_with_admin_endpoint(admin_endpoint: &str) -> AppState {
|
||||
AppState::new(AdminConfig {
|
||||
env: RuntimeEnv::Production,
|
||||
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: String::new(),
|
||||
admin_endpoint: admin_endpoint.to_owned(),
|
||||
web_app_endpoint: String::new(),
|
||||
kv_url: String::new(),
|
||||
oauth_client_id: String::new(),
|
||||
oauth_client_secret: String::new(),
|
||||
oauth_redirect_uri: String::new(),
|
||||
build_version: "test".to_owned(),
|
||||
release_channel: String::new(),
|
||||
self_hosted: false,
|
||||
proxy: ProxyConfig {
|
||||
trust_client_ip_header: false,
|
||||
client_ip_header_name: String::new(),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
async fn csrf_cookies(admin_endpoint: &str) -> Vec<String> {
|
||||
let state = state_with_admin_endpoint(admin_endpoint);
|
||||
let app = Router::new()
|
||||
.route("/", get(|| async { "ok" }))
|
||||
.layer(from_fn_with_state(state, csrf_protection));
|
||||
let response = app
|
||||
.oneshot(Request::builder().uri("/").body(Body::empty()).unwrap())
|
||||
.await
|
||||
.expect("router responds");
|
||||
response
|
||||
.headers()
|
||||
.get_all(header::SET_COOKIE)
|
||||
.iter()
|
||||
.filter_map(|value| value.to_str().ok())
|
||||
.map(|value| value.to_owned())
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn https_admin_endpoint_sets_a_host_prefixed_secure_cookie() {
|
||||
let cookies = csrf_cookies("https://example.com/admin").await;
|
||||
assert!(
|
||||
cookies
|
||||
.iter()
|
||||
.any(|cookie| cookie.starts_with("__Host-csrf_token=")
|
||||
&& cookie.contains("; Secure")),
|
||||
"expected a secure __Host- cookie, got {cookies:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn http_admin_endpoint_sets_a_plain_cookie_without_secure() {
|
||||
let cookies = csrf_cookies("http://example.com/admin").await;
|
||||
assert!(
|
||||
cookies
|
||||
.iter()
|
||||
.any(|cookie| cookie.starts_with("csrf_token=") && !cookie.contains("Secure")),
|
||||
"expected a plain csrf_token cookie, got {cookies:?}"
|
||||
);
|
||||
assert!(
|
||||
!cookies.iter().any(|cookie| cookie.contains("__Host-")),
|
||||
"expected no __Host- cookie, got {cookies:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oauth2_callback_is_exempt() {
|
||||
|
||||
@@ -92,9 +92,9 @@ pub fn clear_flash_cookie(response: &mut Response) {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn redirect_with_flash(url: &str, flash: FlashData, is_production: bool) -> Response {
|
||||
pub fn redirect_with_flash(url: &str, flash: FlashData, secure: bool) -> Response {
|
||||
let encoded = serialize_flash(&flash);
|
||||
let secure_flag = if is_production { "; Secure" } else { "" };
|
||||
let secure_flag = if secure { "; Secure" } else { "" };
|
||||
let cookie_value = format!(
|
||||
"{FLASH_COOKIE_NAME}={encoded}; Path=/; HttpOnly; SameSite=Lax; Max-Age=60{secure_flag}"
|
||||
);
|
||||
|
||||
@@ -119,7 +119,7 @@ async fn admin_api_keys_post(
|
||||
return flash::redirect_with_flash(
|
||||
&format!("{base}/admin-api-keys"),
|
||||
flash,
|
||||
config.is_production(),
|
||||
config.secure_cookies(),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -128,7 +128,7 @@ async fn admin_api_keys_post(
|
||||
flash::redirect_with_flash(
|
||||
&format!("{base}/admin-api-keys"),
|
||||
FlashData::success(format!("API key action '{action}' completed.")),
|
||||
config.is_production(),
|
||||
config.secure_cookies(),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -143,7 +143,7 @@ fn admin_api_key_flash_response(
|
||||
flash::redirect_with_flash(
|
||||
&format!("{}/admin-api-keys", config.base_path),
|
||||
flash_data,
|
||||
config.is_production(),
|
||||
config.secure_cookies(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -151,7 +151,7 @@ async fn application_detail_post(
|
||||
return flash::redirect_with_flash(
|
||||
&format!("{base}/applications/{application_id}"),
|
||||
FlashData::error("Invalid form data"),
|
||||
config.is_production(),
|
||||
config.secure_cookies(),
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -178,6 +178,6 @@ async fn application_detail_post(
|
||||
flash::redirect_with_flash(
|
||||
&format!("{base}/applications/{application_id}"),
|
||||
flash,
|
||||
config.is_production(),
|
||||
config.secure_cookies(),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -187,7 +187,7 @@ async fn oauth2_callback_finish(
|
||||
|
||||
let session_cookie_value =
|
||||
session::create_session(&user.id, &token.access_token, &config.secret_key_base);
|
||||
let secure = if config.is_production() {
|
||||
let secure = if config.secure_cookies() {
|
||||
"; Secure"
|
||||
} else {
|
||||
""
|
||||
|
||||
@@ -82,7 +82,7 @@ async fn gift_codes_post(
|
||||
return flash::redirect_with_flash(
|
||||
&format!("{base}/gift-codes"),
|
||||
FlashData::error("Invalid form data"),
|
||||
config.is_production(),
|
||||
config.secure_cookies(),
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -99,14 +99,14 @@ async fn gift_codes_post(
|
||||
.and_then(|s| s.parse::<u32>().ok())
|
||||
.unwrap_or(1);
|
||||
let client = AdminApiClient::new(state.http_client(), config, &auth.0.session);
|
||||
let is_prod = config.is_production();
|
||||
let secure_cookies = config.secure_cookies();
|
||||
match client.generate_gift_codes(count, dur_type, dur_qty).await {
|
||||
Ok(result) => {
|
||||
let codes = result.codes.join(",");
|
||||
flash::redirect_with_flash(
|
||||
&format!("{base}/gift-codes?codes={codes}"),
|
||||
FlashData::success(format!("{} gift code(s) generated", result.codes.len())),
|
||||
is_prod,
|
||||
secure_cookies,
|
||||
)
|
||||
}
|
||||
Err(error) => {
|
||||
@@ -114,7 +114,7 @@ async fn gift_codes_post(
|
||||
flash::redirect_with_flash(
|
||||
&format!("{base}/gift-codes"),
|
||||
FlashData::error("Failed to generate gift codes"),
|
||||
is_prod,
|
||||
secure_cookies,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -105,7 +105,7 @@ async fn discovery_approve(
|
||||
return flash::redirect_with_flash(
|
||||
&format!("{base}/discovery"),
|
||||
FlashData::error("Invalid form data"),
|
||||
config.is_production(),
|
||||
config.secure_cookies(),
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -115,7 +115,7 @@ async fn discovery_approve(
|
||||
return flash::redirect_with_flash(
|
||||
&format!("{base}/discovery"),
|
||||
FlashData::error("Guild ID is required"),
|
||||
config.is_production(),
|
||||
config.secure_cookies(),
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -131,7 +131,7 @@ async fn discovery_approve(
|
||||
flash::redirect_with_flash(
|
||||
&format!("{base}/discovery?tab=pending"),
|
||||
flash,
|
||||
config.is_production(),
|
||||
config.secure_cookies(),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -149,7 +149,7 @@ async fn discovery_reject(
|
||||
return flash::redirect_with_flash(
|
||||
&format!("{base}/discovery"),
|
||||
FlashData::error("Invalid form data"),
|
||||
config.is_production(),
|
||||
config.secure_cookies(),
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -159,7 +159,7 @@ async fn discovery_reject(
|
||||
return flash::redirect_with_flash(
|
||||
&format!("{base}/discovery"),
|
||||
FlashData::error("Guild ID is required"),
|
||||
config.is_production(),
|
||||
config.secure_cookies(),
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -173,7 +173,7 @@ async fn discovery_reject(
|
||||
flash::redirect_with_flash(
|
||||
&format!("{base}/discovery?tab=pending"),
|
||||
flash,
|
||||
config.is_production(),
|
||||
config.secure_cookies(),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -191,7 +191,7 @@ async fn discovery_remove(
|
||||
return flash::redirect_with_flash(
|
||||
&format!("{base}/discovery"),
|
||||
FlashData::error("Invalid form data"),
|
||||
config.is_production(),
|
||||
config.secure_cookies(),
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -201,7 +201,7 @@ async fn discovery_remove(
|
||||
return flash::redirect_with_flash(
|
||||
&format!("{base}/discovery"),
|
||||
FlashData::error("Guild ID is required"),
|
||||
config.is_production(),
|
||||
config.secure_cookies(),
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -215,6 +215,6 @@ async fn discovery_remove(
|
||||
flash::redirect_with_flash(
|
||||
&format!("{base}/discovery?tab=listed"),
|
||||
flash,
|
||||
config.is_production(),
|
||||
config.secure_cookies(),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -191,7 +191,7 @@ async fn guild_detail_post(
|
||||
return flash::redirect_with_flash(
|
||||
&format!("{base}/guilds/{guild_id}"),
|
||||
FlashData::error("Invalid form data"),
|
||||
config.is_production(),
|
||||
config.secure_cookies(),
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -203,7 +203,7 @@ async fn guild_detail_post(
|
||||
} else {
|
||||
format!("{base}/guilds/{guild_id}?tab={tab}")
|
||||
};
|
||||
flash::redirect_with_flash(&redirect, flash, config.is_production())
|
||||
flash::redirect_with_flash(&redirect, flash, config.secure_cookies())
|
||||
}
|
||||
|
||||
async fn dispatch_guild_action(
|
||||
|
||||
@@ -187,7 +187,7 @@ async fn job_detail_post(
|
||||
return flash::redirect_with_flash(
|
||||
&format!("{base}/jobs/{job_id}"),
|
||||
FlashData::error("Invalid form data"),
|
||||
config.is_production(),
|
||||
config.secure_cookies(),
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -214,7 +214,7 @@ async fn job_detail_post(
|
||||
flash::redirect_with_flash(
|
||||
&format!("{base}/jobs/{job_id}"),
|
||||
flash,
|
||||
config.is_production(),
|
||||
config.secure_cookies(),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -48,7 +48,7 @@ pub(crate) async fn messages_post(
|
||||
return flash::redirect_with_flash(
|
||||
&format!("{base}/messages"),
|
||||
FlashData::error("Invalid form data"),
|
||||
config.is_production(),
|
||||
config.secure_cookies(),
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -164,7 +164,7 @@ pub(crate) async fn system_dms_post(
|
||||
return flash::redirect_with_flash(
|
||||
&format!("{base}/system-dms"),
|
||||
FlashData::error("Invalid form data"),
|
||||
config.is_production(),
|
||||
config.secure_cookies(),
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -184,7 +184,11 @@ pub(crate) async fn system_dms_post(
|
||||
} else {
|
||||
FlashData::error("Recipients and content are required")
|
||||
};
|
||||
flash::redirect_with_flash(&format!("{base}/system-dms"), flash, config.is_production())
|
||||
flash::redirect_with_flash(
|
||||
&format!("{base}/system-dms"),
|
||||
flash,
|
||||
config.secure_cookies(),
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) async fn bulk_actions_post(
|
||||
@@ -201,7 +205,7 @@ pub(crate) async fn bulk_actions_post(
|
||||
return flash::redirect_with_flash(
|
||||
&format!("{base}/bulk-actions"),
|
||||
FlashData::error("Invalid form data"),
|
||||
config.is_production(),
|
||||
config.secure_cookies(),
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -278,7 +282,7 @@ pub(crate) async fn bulk_actions_post(
|
||||
return flash::redirect_with_flash(
|
||||
&format!("{base}/bulk-actions"),
|
||||
FlashData::error("Unknown bulk action"),
|
||||
config.is_production(),
|
||||
config.secure_cookies(),
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -290,7 +294,7 @@ pub(crate) async fn bulk_actions_post(
|
||||
flash::redirect_with_flash(
|
||||
&format!("{base}/bulk-actions"),
|
||||
FlashData::success("Bulk action submitted"),
|
||||
config.is_production(),
|
||||
config.secure_cookies(),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -299,7 +303,7 @@ pub(crate) async fn bulk_actions_post(
|
||||
flash::redirect_with_flash(
|
||||
&format!("{base}/bulk-actions"),
|
||||
FlashData::error("Failed to submit bulk action"),
|
||||
config.is_production(),
|
||||
config.secure_cookies(),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -405,14 +409,14 @@ pub(crate) async fn archives_download(
|
||||
Ok(_) => flash::redirect_with_flash(
|
||||
&format!("{base}/archives"),
|
||||
FlashData::error("Archive download URL was empty"),
|
||||
config.is_production(),
|
||||
config.secure_cookies(),
|
||||
),
|
||||
Err(error) => {
|
||||
tracing::warn!(%error, "admin API request failed: get archive download URL");
|
||||
flash::redirect_with_flash(
|
||||
&format!("{base}/archives"),
|
||||
FlashData::error("Failed to create archive download URL"),
|
||||
config.is_production(),
|
||||
config.secure_cookies(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -195,7 +195,7 @@ async fn report_resolve(
|
||||
return flash::redirect_with_flash(
|
||||
&format!("{base}/reports/{report_id}"),
|
||||
FlashData::error("Invalid form data"),
|
||||
config.is_production(),
|
||||
config.secure_cookies(),
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -212,7 +212,7 @@ async fn report_resolve(
|
||||
flash::redirect_with_flash(
|
||||
&format!("{base}/reports/{report_id}"),
|
||||
FlashData::success("Report resolved"),
|
||||
config.is_production(),
|
||||
config.secure_cookies(),
|
||||
)
|
||||
}
|
||||
Err(error) => {
|
||||
@@ -223,7 +223,7 @@ async fn report_resolve(
|
||||
flash::redirect_with_flash(
|
||||
&format!("{base}/reports/{report_id}"),
|
||||
FlashData::error("Failed to resolve report"),
|
||||
config.is_production(),
|
||||
config.secure_cookies(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,8 +44,8 @@ pub struct ActionQuery {
|
||||
pub rule: Option<String>,
|
||||
}
|
||||
|
||||
pub fn redirect_back_with_flash(base: &str, path: &str, fd: FlashData, prod: bool) -> Response {
|
||||
flash::redirect_with_flash(&format!("{base}{path}"), fd, prod)
|
||||
pub fn redirect_back_with_flash(base: &str, path: &str, fd: FlashData, secure: bool) -> Response {
|
||||
flash::redirect_with_flash(&format!("{base}{path}"), fd, secure)
|
||||
}
|
||||
|
||||
pub async fn gateway_post(
|
||||
@@ -63,7 +63,7 @@ pub async fn gateway_post(
|
||||
base,
|
||||
"/gateway",
|
||||
FlashData::error("Invalid form data"),
|
||||
config.is_production(),
|
||||
config.secure_cookies(),
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -80,7 +80,7 @@ pub async fn gateway_post(
|
||||
} else {
|
||||
FlashData::error("Unknown gateway action")
|
||||
};
|
||||
redirect_back_with_flash(base, "/gateway", flash, config.is_production())
|
||||
redirect_back_with_flash(base, "/gateway", flash, config.secure_cookies())
|
||||
}
|
||||
|
||||
pub async fn search_index_post(
|
||||
@@ -97,7 +97,7 @@ pub async fn search_index_post(
|
||||
base,
|
||||
"/search-index",
|
||||
FlashData::error("Invalid form data"),
|
||||
config.is_production(),
|
||||
config.secure_cookies(),
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -114,7 +114,7 @@ pub async fn search_index_post(
|
||||
flash::redirect_with_flash(
|
||||
&format!("{base}/search-index?job_id={job_id}"),
|
||||
FlashData::success("Search index refresh started"),
|
||||
config.is_production(),
|
||||
config.secure_cookies(),
|
||||
)
|
||||
}
|
||||
Err(error) => {
|
||||
@@ -123,7 +123,7 @@ pub async fn search_index_post(
|
||||
base,
|
||||
"/search-index",
|
||||
FlashData::error("Failed to start search index refresh"),
|
||||
config.is_production(),
|
||||
config.secure_cookies(),
|
||||
)
|
||||
}
|
||||
};
|
||||
@@ -132,7 +132,7 @@ pub async fn search_index_post(
|
||||
base,
|
||||
"/search-index",
|
||||
FlashData::error("Index type is required"),
|
||||
config.is_production(),
|
||||
config.secure_cookies(),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -157,7 +157,7 @@ pub async fn instance_config_post(
|
||||
base,
|
||||
"/instance-config",
|
||||
flash,
|
||||
config.is_production(),
|
||||
config.secure_cookies(),
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -320,7 +320,7 @@ pub async fn instance_config_post(
|
||||
if htmx::is_htmx_request(&headers) {
|
||||
return htmx::toast_response(&flash);
|
||||
}
|
||||
redirect_back_with_flash(base, "/instance-config", flash, config.is_production())
|
||||
redirect_back_with_flash(base, "/instance-config", flash, config.secure_cookies())
|
||||
}
|
||||
|
||||
fn render_registration_url_list_response(
|
||||
@@ -866,13 +866,13 @@ pub async fn limit_config_post(
|
||||
base,
|
||||
"/limit-config",
|
||||
FlashData::error("Invalid form data"),
|
||||
config.is_production(),
|
||||
config.secure_cookies(),
|
||||
);
|
||||
}
|
||||
};
|
||||
let client = AdminApiClient::new(state.http_client(), config, &auth.0.session);
|
||||
let action = aq.action.as_deref().unwrap_or("");
|
||||
let is_prod = config.is_production();
|
||||
let secure_cookies = config.secure_cookies();
|
||||
let current = match client.get_limit_config().await {
|
||||
Ok(current) => current,
|
||||
Err(error) => {
|
||||
@@ -881,7 +881,7 @@ pub async fn limit_config_post(
|
||||
base,
|
||||
"/limit-config",
|
||||
FlashData::error("Failed to fetch current limit configuration"),
|
||||
is_prod,
|
||||
secure_cookies,
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -895,7 +895,7 @@ pub async fn limit_config_post(
|
||||
base,
|
||||
"/limit-config",
|
||||
FlashData::error("Rule not found"),
|
||||
is_prod,
|
||||
secure_cookies,
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -908,7 +908,7 @@ pub async fn limit_config_post(
|
||||
base,
|
||||
"/limit-config",
|
||||
FlashData::error("Rule not found"),
|
||||
is_prod,
|
||||
secure_cookies,
|
||||
);
|
||||
};
|
||||
let fallback = current
|
||||
@@ -923,7 +923,7 @@ pub async fn limit_config_post(
|
||||
"Limit configuration updated",
|
||||
"Failed to update limit configuration",
|
||||
);
|
||||
return redirect_back_with_flash(base, "/limit-config", flash, is_prod);
|
||||
return redirect_back_with_flash(base, "/limit-config", flash, secure_cookies);
|
||||
}
|
||||
"delete" => {
|
||||
let rule_id = match aq.rule.as_deref().and_then(clean_string) {
|
||||
@@ -933,7 +933,7 @@ pub async fn limit_config_post(
|
||||
base,
|
||||
"/limit-config",
|
||||
FlashData::error("Rule not found"),
|
||||
is_prod,
|
||||
secure_cookies,
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -942,7 +942,7 @@ pub async fn limit_config_post(
|
||||
base,
|
||||
"/limit-config",
|
||||
FlashData::error("The default rule cannot be deleted"),
|
||||
is_prod,
|
||||
secure_cookies,
|
||||
);
|
||||
}
|
||||
let old_len = limit_config.rules.len();
|
||||
@@ -952,14 +952,14 @@ pub async fn limit_config_post(
|
||||
base,
|
||||
"/limit-config",
|
||||
FlashData::error("Rule not found"),
|
||||
is_prod,
|
||||
secure_cookies,
|
||||
);
|
||||
}
|
||||
let request = LimitConfigUpdateRequest { limit_config };
|
||||
let result = client.update_limit_config(&request).await;
|
||||
let flash =
|
||||
limit_config_result(result, "Limit rule deleted", "Failed to delete limit rule");
|
||||
return redirect_back_with_flash(base, "/limit-config", flash, is_prod);
|
||||
return redirect_back_with_flash(base, "/limit-config", flash, secure_cookies);
|
||||
}
|
||||
"create" => {
|
||||
let rule_id = match form.clean("rule_id") {
|
||||
@@ -969,7 +969,7 @@ pub async fn limit_config_post(
|
||||
base,
|
||||
"/limit-config",
|
||||
FlashData::error("Rule ID is required"),
|
||||
is_prod,
|
||||
secure_cookies,
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -978,7 +978,7 @@ pub async fn limit_config_post(
|
||||
base,
|
||||
"/limit-config",
|
||||
FlashData::error("The default rule ID is reserved"),
|
||||
is_prod,
|
||||
secure_cookies,
|
||||
);
|
||||
}
|
||||
if limit_config.rules.iter().any(|rule| rule.id == rule_id) {
|
||||
@@ -986,7 +986,7 @@ pub async fn limit_config_post(
|
||||
base,
|
||||
"/limit-config",
|
||||
FlashData::error("Rule ID already exists"),
|
||||
is_prod,
|
||||
secure_cookies,
|
||||
);
|
||||
}
|
||||
let limits = current.defaults.get("default").cloned().unwrap_or_default();
|
||||
@@ -1000,7 +1000,7 @@ pub async fn limit_config_post(
|
||||
let result = client.update_limit_config(&request).await;
|
||||
let flash =
|
||||
limit_config_result(result, "Limit rule created", "Failed to create limit rule");
|
||||
return redirect_back_with_flash(base, "/limit-config", flash, is_prod);
|
||||
return redirect_back_with_flash(base, "/limit-config", flash, secure_cookies);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
@@ -1008,7 +1008,7 @@ pub async fn limit_config_post(
|
||||
base,
|
||||
"/limit-config",
|
||||
FlashData::success("Limit config updated"),
|
||||
is_prod,
|
||||
secure_cookies,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -189,7 +189,7 @@ async fn user_detail_post(
|
||||
return flash::redirect_with_flash(
|
||||
&format!("{base}/users/{user_id}"),
|
||||
flash,
|
||||
config.is_production(),
|
||||
config.secure_cookies(),
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -208,7 +208,7 @@ async fn user_detail_post(
|
||||
{
|
||||
return htmx::toast_response(&outcome.flash);
|
||||
}
|
||||
flash::redirect_with_flash(&redirect, outcome.flash, config.is_production())
|
||||
flash::redirect_with_flash(&redirect, outcome.flash, config.secure_cookies())
|
||||
}
|
||||
|
||||
async fn user_tab(
|
||||
|
||||
@@ -160,7 +160,7 @@ pub(crate) async fn voice_regions_post(
|
||||
flash::redirect_with_flash(
|
||||
&format!("{base}/voice-regions"),
|
||||
flash_from_level(level, &msg),
|
||||
config.is_production(),
|
||||
config.secure_cookies(),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -232,7 +232,7 @@ pub(crate) async fn voice_servers_post(
|
||||
flash::redirect_with_flash(
|
||||
&redirect_url,
|
||||
flash_from_level(level, &msg),
|
||||
config.is_production(),
|
||||
config.secure_cookies(),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -708,11 +708,11 @@ fn csrf_cookie(headers: &HeaderMap) -> Option<String> {
|
||||
.iter()
|
||||
.filter_map(|value| value.to_str().ok())
|
||||
.find_map(|value| {
|
||||
value
|
||||
.split(';')
|
||||
.next()
|
||||
.and_then(|pair| pair.strip_prefix("csrf_token="))
|
||||
.map(str::to_owned)
|
||||
let pair = value.split(';').next()?;
|
||||
let token = pair
|
||||
.strip_prefix("__Host-csrf_token=")
|
||||
.or_else(|| pair.strip_prefix("csrf_token="))?;
|
||||
(!token.is_empty()).then(|| token.to_owned())
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {loadConfig, resetConfig} from '@fluxer/config/src/ConfigLoader';
|
||||
import type {MasterConfig} from '@fluxer/config/src/MasterConfig';
|
||||
import {createServer} from '@fluxer/hono/src/Server';
|
||||
import {Hono} from 'hono';
|
||||
import {afterAll, afterEach, describe, expect, test, vi} from 'vitest';
|
||||
import {afterAll, afterEach, beforeAll, describe, expect, it, test, vi} from 'vitest';
|
||||
import {buildAPIConfigFromMaster, buildAPIServerOptions} from './Config';
|
||||
|
||||
interface ListeningServer {
|
||||
@@ -63,3 +64,50 @@ describe('buildAPIServerOptions', () => {
|
||||
expect(server.headersTimeout).toBe(45_000);
|
||||
});
|
||||
});
|
||||
|
||||
function withUploadRelaySecret(master: MasterConfig, secretBase64: string): MasterConfig {
|
||||
return {
|
||||
...master,
|
||||
services: {
|
||||
...master.services,
|
||||
media_proxy: {
|
||||
...master.services.media_proxy,
|
||||
upload_relay: {
|
||||
...master.services.media_proxy.upload_relay,
|
||||
secret_base64: secretBase64,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe('buildAPIConfigFromMaster upload relay secret', () => {
|
||||
let master: MasterConfig;
|
||||
beforeAll(async () => {
|
||||
master = await loadConfig();
|
||||
});
|
||||
|
||||
it('refuses to build without FLUXER_MEDIA_PROXY_UPLOAD_RELAY_SECRET_BASE64', () => {
|
||||
expect(() => buildAPIConfigFromMaster(withUploadRelaySecret(master, ''))).toThrow(
|
||||
/FLUXER_MEDIA_PROXY_UPLOAD_RELAY_SECRET_BASE64/,
|
||||
);
|
||||
});
|
||||
|
||||
it('refuses a secret that decodes to fewer than 32 bytes', () => {
|
||||
const secret = Buffer.alloc(16, 7).toString('base64');
|
||||
expect(() => buildAPIConfigFromMaster(withUploadRelaySecret(master, secret))).toThrow(/at least 32 bytes/);
|
||||
});
|
||||
|
||||
it('accepts a secret that decodes to 32 bytes', () => {
|
||||
const secret = Buffer.alloc(32, 7).toString('base64');
|
||||
expect(
|
||||
buildAPIConfigFromMaster(withUploadRelaySecret(master, secret)).mediaProxy.uploadRelay.relaySecretBase64,
|
||||
).toBe(secret);
|
||||
});
|
||||
|
||||
it('reads the relay secret from the loaded config rather than the environment', () => {
|
||||
expect(buildAPIConfigFromMaster(master).mediaProxy.uploadRelay.relaySecretBase64).toBe(
|
||||
master.services.media_proxy.upload_relay.secret_base64,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -113,17 +113,18 @@ function mapPushProviderApps(
|
||||
project_id?: string;
|
||||
}>
|
||||
| undefined,
|
||||
configName: string,
|
||||
): APIConfig['push']['apns']['apps'] {
|
||||
return (apps ?? []).flatMap((app) => {
|
||||
if (!app.app_id) return [];
|
||||
return [
|
||||
{
|
||||
appId: app.app_id,
|
||||
topic: app.topic,
|
||||
environment: app.environment,
|
||||
projectId: app.project_id,
|
||||
},
|
||||
];
|
||||
return (apps ?? []).map((app) => {
|
||||
if (!app.app_id) {
|
||||
throw new Error(`${configName} contains an entry with no app_id`);
|
||||
}
|
||||
return {
|
||||
appId: app.app_id,
|
||||
topic: app.topic,
|
||||
environment: app.environment,
|
||||
projectId: app.project_id,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
@@ -139,7 +140,13 @@ export function buildAPIConfigFromMaster(master: MasterConfig): APIConfig {
|
||||
serviceName: 'api',
|
||||
});
|
||||
const uploadRelayConfig = master.services.media_proxy.upload_relay;
|
||||
const uploadRelaySecretBase64 = process.env.FLUXER_MEDIA_PROXY_UPLOAD_RELAY_SECRET_BASE64 ?? '';
|
||||
const uploadRelaySecretBase64 = uploadRelayConfig.secret_base64;
|
||||
if (uploadRelaySecretBase64.length === 0) {
|
||||
throw new Error('FLUXER_MEDIA_PROXY_UPLOAD_RELAY_SECRET_BASE64 is required for the API');
|
||||
}
|
||||
if (Buffer.from(uploadRelaySecretBase64, 'base64').length < 32) {
|
||||
throw new Error('FLUXER_MEDIA_PROXY_UPLOAD_RELAY_SECRET_BASE64 must decode to at least 32 bytes');
|
||||
}
|
||||
if (!s3Config) {
|
||||
throw new Error('S3 configuration is required for the API');
|
||||
}
|
||||
@@ -149,7 +156,6 @@ export function buildAPIConfigFromMaster(master: MasterConfig): APIConfig {
|
||||
downloads: '',
|
||||
reports: '',
|
||||
harvests: '',
|
||||
static: '',
|
||||
};
|
||||
if (master.database.backend === 'cassandra' && !cassandraSource) {
|
||||
throw new Error('Cassandra configuration is required.');
|
||||
@@ -494,7 +500,7 @@ export function buildAPIConfigFromMaster(master: MasterConfig): APIConfig {
|
||||
privateKey: master.integrations.push.apns.private_key,
|
||||
privateKeyPath: master.integrations.push.apns.private_key_path,
|
||||
defaultEnvironment: master.integrations.push.apns.default_environment ?? 'production',
|
||||
apps: mapPushProviderApps(master.integrations.push.apns.apps),
|
||||
apps: mapPushProviderApps(master.integrations.push.apns.apps, 'FLUXER_PUSH_APNS_APPS'),
|
||||
},
|
||||
fcm: {
|
||||
enabled: master.integrations.push.fcm.enabled,
|
||||
@@ -504,7 +510,7 @@ export function buildAPIConfigFromMaster(master: MasterConfig): APIConfig {
|
||||
privateKeyPath: master.integrations.push.fcm.private_key_path,
|
||||
serviceAccountJsonPath: master.integrations.push.fcm.service_account_json_path,
|
||||
tokenUri: master.integrations.push.fcm.token_uri ?? 'https://oauth2.googleapis.com/token',
|
||||
apps: mapPushProviderApps(master.integrations.push.fcm.apps),
|
||||
apps: mapPushProviderApps(master.integrations.push.fcm.apps, 'FLUXER_PUSH_FCM_APPS'),
|
||||
},
|
||||
},
|
||||
worker: {
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {generateKeyPairSync} from 'node:crypto';
|
||||
import {describe, expect, it} from 'vitest';
|
||||
import type {BlueskyOAuthConfig} from '../config/APIConfig';
|
||||
import {MockKVProvider} from '../test/mocks/MockKVProvider';
|
||||
import {BlueskyOAuthService} from './BlueskyOAuthService';
|
||||
|
||||
const API_PUBLIC_ENDPOINT = 'https://chat.example.com';
|
||||
|
||||
function generateSigningKey(): string {
|
||||
const {privateKey} = generateKeyPairSync('ec', {
|
||||
namedCurve: 'P-256',
|
||||
privateKeyEncoding: {type: 'pkcs8', format: 'pem'},
|
||||
publicKeyEncoding: {type: 'spki', format: 'pem'},
|
||||
});
|
||||
return privateKey;
|
||||
}
|
||||
|
||||
function buildConfig(overrides: Partial<BlueskyOAuthConfig> = {}): BlueskyOAuthConfig {
|
||||
return {
|
||||
enabled: true,
|
||||
client_name: 'Fluxer',
|
||||
client_uri: '',
|
||||
logo_uri: '',
|
||||
tos_uri: '',
|
||||
policy_uri: '',
|
||||
keys: [{kid: 'test-key', private_key: generateSigningKey()}],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
async function serveClientMetadata(config: BlueskyOAuthConfig): Promise<Record<string, unknown>> {
|
||||
const service = await BlueskyOAuthService.create(config, new MockKVProvider(), API_PUBLIC_ENDPOINT);
|
||||
return JSON.parse(JSON.stringify(service.clientMetadata)) as Record<string, unknown>;
|
||||
}
|
||||
|
||||
describe('BlueskyOAuthService', () => {
|
||||
it('omits the legal URLs from the served client document when none are configured', async () => {
|
||||
const metadata = await serveClientMetadata(buildConfig());
|
||||
|
||||
expect(metadata).not.toHaveProperty('tos_uri');
|
||||
expect(metadata).not.toHaveProperty('policy_uri');
|
||||
expect(metadata).not.toHaveProperty('logo_uri');
|
||||
expect(metadata.client_id).toBe(`${API_PUBLIC_ENDPOINT}/connections/bluesky/client-metadata.json`);
|
||||
});
|
||||
|
||||
it('echoes configured legal URLs verbatim', async () => {
|
||||
const metadata = await serveClientMetadata(
|
||||
buildConfig({
|
||||
tos_uri: 'https://chat.example.com/terms',
|
||||
policy_uri: 'https://chat.example.com/privacy',
|
||||
}),
|
||||
);
|
||||
|
||||
expect(metadata.tos_uri).toBe('https://chat.example.com/terms');
|
||||
expect(metadata.policy_uri).toBe('https://chat.example.com/privacy');
|
||||
});
|
||||
});
|
||||
@@ -153,7 +153,6 @@ export interface APIConfig {
|
||||
reports: string;
|
||||
harvests: string;
|
||||
downloads: string;
|
||||
static: string;
|
||||
};
|
||||
};
|
||||
s3Downloads: ResolvedDownloadsProvider;
|
||||
|
||||
@@ -61,7 +61,6 @@ const MAX_DESKTOP_OBJECTS_PER_PREFIX = 10_000;
|
||||
const DESKTOP_BUCKET_PREFIX = 'desktop';
|
||||
const DESKTOP_TEST_BUCKET_PREFIX = 'desktop-test';
|
||||
const DOWNLOAD_KEY_ALLOWED_PREFIXES = [`${DESKTOP_BUCKET_PREFIX}/`, `${DESKTOP_TEST_BUCKET_PREFIX}/`];
|
||||
const DEFAULT_API_CLIENT_BASE_URL = 'https://api.fluxer.app';
|
||||
const GITHUB_RELEASE_DOWNLOAD_BASE_URL = 'https://github.com/fluxerapp/fluxer/releases/download';
|
||||
const GITHUB_RELEASE_MARKER_DIRECTORY = 'github-releases';
|
||||
|
||||
@@ -687,11 +686,7 @@ export class DownloadService {
|
||||
}
|
||||
|
||||
private buildBaseUrl(baseUrl?: string): string {
|
||||
const configuredBaseUrl = (baseUrl ?? Config.endpoints.apiClient).trim();
|
||||
if (configuredBaseUrl.length > 0) {
|
||||
return configuredBaseUrl.replace(/\/+$/u, '');
|
||||
}
|
||||
return DEFAULT_API_CLIENT_BASE_URL;
|
||||
return (baseUrl ?? Config.endpoints.apiClient).trim().replace(/\/+$/u, '');
|
||||
}
|
||||
|
||||
private buildDesktopVersionUrl(params: {
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {Readable} from 'node:stream';
|
||||
import {describe, expect, it} from 'vitest';
|
||||
import {Config} from '../../Config';
|
||||
import type {IStorageService} from '../../infrastructure/IStorageService';
|
||||
import {DownloadService} from '../DownloadService';
|
||||
|
||||
const PREFIX = 'desktop/stable/darwin/x64';
|
||||
const MANIFEST_KEY = `${PREFIX}/manifest.json`;
|
||||
const FILENAME = 'Fluxer-1.3.0-mac-universal.dmg';
|
||||
const SHA256 = 'a'.repeat(64);
|
||||
|
||||
const LATEST_PARAMS = {
|
||||
channel: 'stable',
|
||||
plat: 'darwin',
|
||||
arch: 'x64',
|
||||
} as const;
|
||||
|
||||
const MANIFEST_BODY = JSON.stringify({
|
||||
channel: 'stable',
|
||||
platform: 'darwin',
|
||||
arch: 'x64',
|
||||
version: '1.3.0',
|
||||
pub_date: '2026-08-17T00:00:00Z',
|
||||
files: {dmg: {filename: FILENAME, sha256: SHA256}},
|
||||
});
|
||||
|
||||
function createService() {
|
||||
const objectKeys = [`${PREFIX}/${FILENAME}`];
|
||||
const storageService = {
|
||||
streamObject: async (params: {key: string}) => {
|
||||
if (params.key !== MANIFEST_KEY) {
|
||||
return null;
|
||||
}
|
||||
const buffer = Buffer.from(MANIFEST_BODY, 'utf8');
|
||||
return {body: Readable.from([buffer]), contentLength: buffer.byteLength};
|
||||
},
|
||||
listObjects: async () => objectKeys.map((key) => ({key})),
|
||||
getObjectMetadata: async (_bucket: string, key: string) =>
|
||||
objectKeys.includes(key) ? {contentLength: 1, contentType: 'application/x-apple-diskimage'} : null,
|
||||
} as unknown as IStorageService;
|
||||
return new DownloadService(storageService);
|
||||
}
|
||||
|
||||
describe('desktop download base url', () => {
|
||||
it('builds artifact urls from the configured client API endpoint', async () => {
|
||||
const version = await createService().getLatestDesktopVersion({...LATEST_PARAMS});
|
||||
const base = Config.endpoints.apiClient.replace(/\/+$/u, '');
|
||||
expect(base.length).toBeGreaterThan(0);
|
||||
expect(version?.files.dmg?.url).toBe(`${base}/dl/desktop/stable/darwin/x64/1.3.0/dmg`);
|
||||
expect(version?.files.dmg?.checksum_url).toBe(`${base}/dl/desktop/stable/darwin/x64/1.3.0/dmg.sha256`);
|
||||
});
|
||||
|
||||
it('prefers an explicit base url and strips its trailing slashes', async () => {
|
||||
const version = await createService().getLatestDesktopVersion({
|
||||
...LATEST_PARAMS,
|
||||
baseUrl: 'https://chat.example.com/api//',
|
||||
});
|
||||
expect(version?.files.dmg?.url).toBe('https://chat.example.com/api/dl/desktop/stable/darwin/x64/1.3.0/dmg');
|
||||
});
|
||||
});
|
||||
@@ -44,7 +44,6 @@ interface S3BucketConfigOverrides {
|
||||
downloads?: string;
|
||||
reports?: string;
|
||||
harvests?: string;
|
||||
static?: string;
|
||||
}
|
||||
|
||||
interface S3ConfigOverrides {
|
||||
|
||||
@@ -115,6 +115,38 @@ describe('InstanceConfigRepository', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('reports the effective captcha provider as none while the selected pair is incomplete', async () => {
|
||||
const executor = new CountingInMemoryCassandraQueryExecutor();
|
||||
setCassandraQueryExecutorForTesting(executor);
|
||||
const kvProvider = new MockKVProvider();
|
||||
const repository = createRepository(kvProvider);
|
||||
|
||||
await repository.setInstanceIntegrationsConfig({
|
||||
captcha: {
|
||||
provider: 'turnstile',
|
||||
hcaptcha_site_key: 'hcaptcha-site-key',
|
||||
hcaptcha_secret_key: 'hcaptcha-secret-key',
|
||||
},
|
||||
});
|
||||
|
||||
await expect(repository.getEffectiveCaptchaConfig()).resolves.toMatchObject({
|
||||
enabled: false,
|
||||
provider: 'none',
|
||||
});
|
||||
|
||||
await repository.setInstanceIntegrationsConfig({
|
||||
captcha: {
|
||||
turnstile_site_key: 'turnstile-site-key',
|
||||
turnstile_secret_key: 'turnstile-secret-key',
|
||||
},
|
||||
});
|
||||
|
||||
await expect(repository.getEffectiveCaptchaConfig()).resolves.toMatchObject({
|
||||
enabled: true,
|
||||
provider: 'turnstile',
|
||||
});
|
||||
});
|
||||
|
||||
it('uses the registration URL id as the admin-visible registration code', async () => {
|
||||
const executor = new CountingInMemoryCassandraQueryExecutor();
|
||||
setCassandraQueryExecutorForTesting(executor);
|
||||
|
||||
@@ -1262,8 +1262,8 @@ export class InstanceConfigRepository {
|
||||
? Boolean(turnstileSiteKey && turnstileSecretKey)
|
||||
: false;
|
||||
return {
|
||||
enabled: provider !== 'none' && providerReady,
|
||||
provider,
|
||||
enabled: providerReady,
|
||||
provider: providerReady ? provider : 'none',
|
||||
hcaptcha_site_key: hcaptchaSiteKey,
|
||||
hcaptcha_secret_key: hcaptchaSecretKey,
|
||||
turnstile_site_key: turnstileSiteKey,
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {Hono} from 'hono';
|
||||
import {afterEach, describe, expect, it} from 'vitest';
|
||||
import type {SsoService} from '../auth/services/SsoService';
|
||||
import {setCassandraQueryExecutorForTesting} from '../database/CassandraQueryExecution';
|
||||
import type {LimitConfigService} from '../limits/LimitConfigService';
|
||||
import {InMemoryCassandraQueryExecutor} from '../test/InMemoryCassandraQueryExecutor';
|
||||
import {MockKVProvider} from '../test/mocks/MockKVProvider';
|
||||
import type {HonoEnv} from '../types/HonoEnv';
|
||||
import {InstanceConfigRepository} from './InstanceConfigRepository';
|
||||
import {InstanceController} from './InstanceController';
|
||||
|
||||
interface DiscoveryCaptcha {
|
||||
provider: string;
|
||||
hcaptcha_site_key: string | null;
|
||||
turnstile_site_key: string | null;
|
||||
}
|
||||
|
||||
describe('InstanceController discovery captcha', () => {
|
||||
const repositories: Array<InstanceConfigRepository> = [];
|
||||
|
||||
afterEach(() => {
|
||||
for (const repository of repositories) {
|
||||
repository.shutdown();
|
||||
}
|
||||
repositories.length = 0;
|
||||
});
|
||||
|
||||
function createRepository(): InstanceConfigRepository {
|
||||
setCassandraQueryExecutorForTesting(new InMemoryCassandraQueryExecutor());
|
||||
const repository = new InstanceConfigRepository(new MockKVProvider());
|
||||
repositories.push(repository);
|
||||
return repository;
|
||||
}
|
||||
|
||||
function createApp(repository: InstanceConfigRepository): Hono<HonoEnv> {
|
||||
const app = new Hono<HonoEnv>({strict: true});
|
||||
app.use('*', async (ctx, next) => {
|
||||
ctx.set('instanceConfigRepository', repository);
|
||||
ctx.set('limitConfigService', {
|
||||
getConfigWireFormat: () => ({version: 2, traitDefinitions: [], rules: [], defaultsHash: 'test'}),
|
||||
} as unknown as LimitConfigService);
|
||||
ctx.set('ssoService', {
|
||||
getPublicStatus: async () => ({
|
||||
enabled: false,
|
||||
enforced: false,
|
||||
display_name: null,
|
||||
redirect_uri: '',
|
||||
}),
|
||||
} as unknown as SsoService);
|
||||
await next();
|
||||
});
|
||||
InstanceController(app);
|
||||
return app;
|
||||
}
|
||||
|
||||
async function readCaptcha(repository: InstanceConfigRepository): Promise<DiscoveryCaptcha> {
|
||||
const response = await createApp(repository).request('http://localhost/.well-known/fluxer');
|
||||
expect(response.status).toBe(200);
|
||||
return ((await response.json()) as {captcha: DiscoveryCaptcha}).captcha;
|
||||
}
|
||||
|
||||
it('advertises no provider and no site key while the selected pair is incomplete', async () => {
|
||||
const repository = createRepository();
|
||||
await repository.setInstanceIntegrationsConfig({
|
||||
captcha: {
|
||||
provider: 'turnstile',
|
||||
hcaptcha_site_key: 'hcaptcha-site-key',
|
||||
hcaptcha_secret_key: 'hcaptcha-secret-key',
|
||||
},
|
||||
});
|
||||
|
||||
await expect(readCaptcha(repository)).resolves.toEqual({
|
||||
provider: 'none',
|
||||
hcaptcha_site_key: null,
|
||||
turnstile_site_key: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('advertises only the site key that matches the named provider', async () => {
|
||||
const repository = createRepository();
|
||||
await repository.setInstanceIntegrationsConfig({
|
||||
captcha: {
|
||||
provider: 'turnstile',
|
||||
hcaptcha_site_key: 'hcaptcha-site-key',
|
||||
hcaptcha_secret_key: 'hcaptcha-secret-key',
|
||||
turnstile_site_key: 'turnstile-site-key',
|
||||
turnstile_secret_key: 'turnstile-secret-key',
|
||||
},
|
||||
});
|
||||
|
||||
await expect(readCaptcha(repository)).resolves.toEqual({
|
||||
provider: 'turnstile',
|
||||
hcaptcha_site_key: null,
|
||||
turnstile_site_key: 'turnstile-site-key',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -57,8 +57,8 @@ function buildDiscoveryStaticInput(
|
||||
},
|
||||
captcha: {
|
||||
provider: runtime.captcha.provider,
|
||||
hcaptcha_site_key: runtime.captcha.hcaptcha_site_key,
|
||||
turnstile_site_key: runtime.captcha.turnstile_site_key,
|
||||
hcaptcha_site_key: runtime.captcha.provider === 'hcaptcha' ? runtime.captcha.hcaptcha_site_key : null,
|
||||
turnstile_site_key: runtime.captcha.provider === 'turnstile' ? runtime.captcha.turnstile_site_key : null,
|
||||
},
|
||||
features: {
|
||||
voice_enabled: Config.voice.enabled,
|
||||
|
||||
@@ -45,7 +45,7 @@ function resolveCaptchaProvider(
|
||||
}
|
||||
const secretKey = resolveProviderSecret(config, requestedProvider);
|
||||
if (!secretKey) {
|
||||
throw new Error(`Captcha provider ${requestedProvider} is enabled but has no configured secret key`);
|
||||
throw new InvalidCaptchaError();
|
||||
}
|
||||
return createCaptchaProvider({mode: requestedProvider, secretKey});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {AppErrorHandler} from '@fluxer/errors/src/domains/core/ErrorHandlers';
|
||||
import {Hono} from 'hono';
|
||||
import {afterEach, beforeEach, describe, expect, it} from 'vitest';
|
||||
import {Config} from '../../Config';
|
||||
import type {InstanceCaptchaEffectiveConfig, InstanceConfigRepository} from '../../instance/InstanceConfigRepository';
|
||||
import type {HonoEnv} from '../../types/HonoEnv';
|
||||
import {CaptchaMiddleware} from '../CaptchaMiddleware';
|
||||
|
||||
const HCAPTCHA_ONLY: InstanceCaptchaEffectiveConfig = {
|
||||
enabled: true,
|
||||
provider: 'hcaptcha',
|
||||
hcaptcha_site_key: 'hcaptcha-site-key',
|
||||
hcaptcha_secret_key: 'hcaptcha-secret-key',
|
||||
turnstile_site_key: null,
|
||||
turnstile_secret_key: null,
|
||||
};
|
||||
|
||||
function createHarness(
|
||||
captcha: InstanceCaptchaEffectiveConfig,
|
||||
): (headers: Record<string, string>) => Promise<Response> {
|
||||
const repository = {
|
||||
getEffectiveCaptchaConfig: async () => captcha,
|
||||
} as unknown as InstanceConfigRepository;
|
||||
const app = new Hono<HonoEnv>();
|
||||
app.use(async (ctx, next) => {
|
||||
ctx.set('instanceConfigRepository', repository);
|
||||
await next();
|
||||
});
|
||||
app.use(CaptchaMiddleware);
|
||||
app.post('/auth/register', (ctx) => ctx.text('ok'));
|
||||
app.onError(AppErrorHandler);
|
||||
return async (headers) => app.request('http://localhost/auth/register', {method: 'POST', headers});
|
||||
}
|
||||
|
||||
describe('CaptchaMiddleware provider header', () => {
|
||||
let previousTestModeEnabled: boolean;
|
||||
|
||||
beforeEach(() => {
|
||||
previousTestModeEnabled = Config.dev.testModeEnabled;
|
||||
Config.dev.testModeEnabled = false;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
Config.dev.testModeEnabled = previousTestModeEnabled;
|
||||
});
|
||||
|
||||
it('rejects a header naming a provider the instance holds no secret key for with 400 INVALID_CAPTCHA', async () => {
|
||||
const request = createHarness(HCAPTCHA_ONLY);
|
||||
const response = await request({'x-captcha-token': 'solution', 'x-captcha-type': 'turnstile'});
|
||||
expect(response.status).toBe(400);
|
||||
expect(await response.json()).toMatchObject({code: 'INVALID_CAPTCHA'});
|
||||
});
|
||||
|
||||
it('rejects a request with no proof with 400 CAPTCHA_REQUIRED', async () => {
|
||||
const request = createHarness(HCAPTCHA_ONLY);
|
||||
const response = await request({});
|
||||
expect(response.status).toBe(400);
|
||||
expect(await response.json()).toMatchObject({code: 'CAPTCHA_REQUIRED'});
|
||||
});
|
||||
});
|
||||
@@ -2,18 +2,29 @@
|
||||
|
||||
import * as fs from 'node:fs';
|
||||
import type {Hono} from 'hono';
|
||||
import {Config} from '../Config';
|
||||
import {RateLimitMiddleware} from '../middleware/RateLimitMiddleware';
|
||||
import {RateLimitConfigs} from '../RateLimitConfig';
|
||||
import type {HonoEnv} from '../types/HonoEnv';
|
||||
import {resolveAssetPath} from '../utils/AssetPaths';
|
||||
|
||||
const SPEC_PATH = resolveAssetPath('openapi', 'openapi.json');
|
||||
const SPEC_BODY = fs.readFileSync(SPEC_PATH, 'utf-8');
|
||||
const SPEC_DOCUMENT = JSON.parse(fs.readFileSync(SPEC_PATH, 'utf-8')) as Record<string, unknown>;
|
||||
|
||||
let specBody: string | null = null;
|
||||
|
||||
export function buildOpenAPISpecBody(apiClientEndpoint: string): string {
|
||||
return JSON.stringify({
|
||||
...SPEC_DOCUMENT,
|
||||
servers: [{url: `${apiClientEndpoint.trim().replace(/\/+$/u, '')}/v1`, description: 'This deployment'}],
|
||||
});
|
||||
}
|
||||
|
||||
export function OpenAPIController(app: Hono<HonoEnv>): void {
|
||||
app.get('/openapi.json', RateLimitMiddleware(RateLimitConfigs.INSTANCE_INFO), (ctx) => {
|
||||
specBody ??= buildOpenAPISpecBody(Config.endpoints.apiClient);
|
||||
ctx.header('Access-Control-Allow-Origin', '*');
|
||||
ctx.header('Content-Type', 'application/json; charset=utf-8');
|
||||
return ctx.body(SPEC_BODY);
|
||||
return ctx.body(specBody);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {Hono} from 'hono';
|
||||
import {describe, expect, it} from 'vitest';
|
||||
import {Config} from '../../Config';
|
||||
import type {HonoEnv} from '../../types/HonoEnv';
|
||||
import {buildOpenAPISpecBody, OpenAPIController} from '../OpenAPIController';
|
||||
|
||||
interface ServerEntry {
|
||||
url: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
function parseServers(body: string): Array<ServerEntry> {
|
||||
return (JSON.parse(body) as {servers: Array<ServerEntry>}).servers;
|
||||
}
|
||||
|
||||
describe('OpenAPI server entry', () => {
|
||||
it('declares the deployment client API endpoint', () => {
|
||||
expect(parseServers(buildOpenAPISpecBody('https://chat.example.com/api'))).toEqual([
|
||||
{url: 'https://chat.example.com/api/v1', description: 'This deployment'},
|
||||
]);
|
||||
});
|
||||
|
||||
it('strips trailing slashes from the configured endpoint', () => {
|
||||
expect(parseServers(buildOpenAPISpecBody('https://chat.example.com/api//'))[0].url).toBe(
|
||||
'https://chat.example.com/api/v1',
|
||||
);
|
||||
});
|
||||
|
||||
it('serves the configured endpoint instead of an upstream host', async () => {
|
||||
const app = new Hono<HonoEnv>();
|
||||
OpenAPIController(app);
|
||||
const response = await app.request('/openapi.json');
|
||||
expect(response.status).toBe(200);
|
||||
const spec = (await response.json()) as {servers: Array<ServerEntry>; paths: Record<string, unknown>};
|
||||
expect(spec.servers).toEqual([
|
||||
{url: `${Config.endpoints.apiClient.replace(/\/+$/u, '')}/v1`, description: 'This deployment'},
|
||||
]);
|
||||
expect(spec.servers[0].url).not.toContain('api.fluxer.app');
|
||||
expect(Object.keys(spec.paths).length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
@@ -68,11 +68,10 @@ function setDefaultTestEnv(): void {
|
||||
FLUXER_APP_PROXY_PORT: '8773',
|
||||
FLUXER_GATEWAY_MEDIA_PROXY_ENDPOINT: 'http://127.0.0.1:8088/media',
|
||||
FLUXER_GATEWAY_RPC_AUTH_TOKEN: 'test-gateway-rpc-token',
|
||||
FLUXER_GATEWAY_PUSH_ENABLED: 'false',
|
||||
FLUXER_SUDO_MODE_SECRET: 'test-sudo-secret',
|
||||
FLUXER_CONNECTION_INITIATION_SECRET: 'test-connection-secret',
|
||||
FLUXER_VAPID_PUBLIC_KEY: 'test-vapid-public-key',
|
||||
FLUXER_VAPID_PRIVATE_KEY: 'test-vapid-private-key',
|
||||
FLUXER_VAPID_PUBLIC_KEY: 'BB76bTFIuoqmxJtTfZX0yGTn1f_qu9H03B_nkj8OyExJFkN7Y-HBZZzShnHZoEhXKc5ZRy3jFu7OkBbnaQG-4aw',
|
||||
FLUXER_VAPID_PRIVATE_KEY: 'Xgi-3P8J-I3Q6U1HlCcXMuc_tKLGAM9nIfznX3Hz68o',
|
||||
FLUXER_VAPID_EMAIL: '[email protected]',
|
||||
FLUXER_PASSKEY_RP_NAME: 'Fluxer Test',
|
||||
FLUXER_PASSKEY_RP_ID: 'localhost',
|
||||
|
||||
@@ -398,22 +398,27 @@ characters_to_binary_or_default(Value, Default) ->
|
||||
|
||||
-spec env_int(string(), integer()) -> integer().
|
||||
env_int(Name, Default) ->
|
||||
parse_env_int(os:getenv(Name), Default).
|
||||
parse_env_int(Name, os:getenv(Name), Default).
|
||||
|
||||
-spec parse_env_int(false | string(), integer()) -> integer().
|
||||
parse_env_int(false, Default) ->
|
||||
-spec parse_env_int(string(), false | string(), integer()) -> integer().
|
||||
parse_env_int(_Name, false, Default) ->
|
||||
Default;
|
||||
parse_env_int("", Default) ->
|
||||
parse_env_int(_Name, "", Default) ->
|
||||
Default;
|
||||
parse_env_int(Value, Default) ->
|
||||
parse_int(Value, Default).
|
||||
parse_env_int(Name, Value, Default) ->
|
||||
parse_int(Name, Value, Default).
|
||||
|
||||
-spec parse_int(string(), integer()) -> integer().
|
||||
parse_int(Value, Default) ->
|
||||
try list_to_integer(Value) of
|
||||
Parsed -> Parsed
|
||||
catch
|
||||
error:badarg -> Default
|
||||
-spec parse_int(string(), string(), integer()) -> integer().
|
||||
parse_int(Name, Value, Default) ->
|
||||
case string:trim(Value) of
|
||||
"" ->
|
||||
Default;
|
||||
Trimmed ->
|
||||
try list_to_integer(Trimmed) of
|
||||
Parsed -> Parsed
|
||||
catch
|
||||
error:badarg -> erlang:error({invalid_integer_env, Name, Value})
|
||||
end
|
||||
end.
|
||||
|
||||
-spec env_bool(string(), boolean()) -> boolean().
|
||||
@@ -573,7 +578,7 @@ parse_node_list([], _Remaining, Acc) ->
|
||||
parse_node_list([Peer | Rest], Remaining, Acc) ->
|
||||
case gateway_node_name:from_string(Peer) of
|
||||
{ok, Node} -> parse_node_list(Rest, Remaining - 1, [Node | Acc]);
|
||||
error -> parse_node_list(Rest, Remaining, Acc)
|
||||
error -> erlang:error({invalid_cluster_static_peer, Peer})
|
||||
end.
|
||||
|
||||
-spec normalize_log_level(term()) -> log_level() | undefined.
|
||||
|
||||
@@ -906,13 +906,15 @@ schedule_eviction() ->
|
||||
maybe_warn_vapid_misconfigured(true) ->
|
||||
Public = fluxer_gateway_env:get(vapid_public_key),
|
||||
Private = fluxer_gateway_env:get(vapid_private_key),
|
||||
case
|
||||
is_binary(Public) andalso is_binary(Private) andalso
|
||||
byte_size(Public) > 0 andalso byte_size(Private) > 0
|
||||
of
|
||||
true ->
|
||||
ok;
|
||||
false ->
|
||||
case {Public, Private} of
|
||||
{Public0, Private0} when
|
||||
is_binary(Public0),
|
||||
is_binary(Private0),
|
||||
byte_size(Public0) > 0,
|
||||
byte_size(Private0) > 0
|
||||
->
|
||||
warn_unless_vapid_pair_valid(Public0, Private0);
|
||||
_ ->
|
||||
logger:error(
|
||||
"Push: push_enabled=true but VAPID keys are missing or empty; "
|
||||
"all web push notifications will be silently dropped"
|
||||
@@ -922,6 +924,21 @@ maybe_warn_vapid_misconfigured(true) ->
|
||||
maybe_warn_vapid_misconfigured(_) ->
|
||||
ok.
|
||||
|
||||
-spec warn_unless_vapid_pair_valid(binary(), binary()) -> ok.
|
||||
warn_unless_vapid_pair_valid(Public, Private) ->
|
||||
try
|
||||
push_utils:assert_vapid_pair(Public, Private)
|
||||
catch
|
||||
_:Reason ->
|
||||
logger:error(
|
||||
"Push: FLUXER_VAPID_PUBLIC_KEY and FLUXER_VAPID_PRIVATE_KEY are not a "
|
||||
"valid base64url P-256 pair; expected a 65-byte 0x04-prefixed point and "
|
||||
"a 32-byte scalar; all web push notifications will be silently dropped",
|
||||
#{reason => Reason}
|
||||
),
|
||||
ok
|
||||
end.
|
||||
|
||||
-spec env_boolean(atom()) -> boolean().
|
||||
env_boolean(Key) ->
|
||||
case fluxer_gateway_env:get(Key) of
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
get_default_avatar_url/1,
|
||||
extract_origin/1,
|
||||
generate_vapid_token/3,
|
||||
assert_vapid_pair/2,
|
||||
generate_jwt_from_pem/3,
|
||||
base64url_encode/1,
|
||||
base64url_decode/1,
|
||||
@@ -166,6 +167,31 @@ build_ec_jwk(PrivRaw, PubRaw) ->
|
||||
unwrap_jwk({JW, _Fields}) -> JW;
|
||||
unwrap_jwk(JW) -> JW.
|
||||
|
||||
-spec assert_vapid_pair(binary(), binary()) -> ok.
|
||||
assert_vapid_pair(PublicKeyB64Url, PrivateKeyB64Url) ->
|
||||
ensure_crypto_started(),
|
||||
PubRaw = decode_or_error(PublicKeyB64Url, invalid_public_key),
|
||||
PrivRaw = decode_or_error(PrivateKeyB64Url, invalid_private_key),
|
||||
case {PubRaw, PrivRaw} of
|
||||
{<<4, _:64/binary>>, <<_:32/binary>>} ->
|
||||
assert_vapid_scalar_derives_point(PublicKeyB64Url, PubRaw, PrivRaw);
|
||||
_ ->
|
||||
erlang:error({vapid_keys_malformed, byte_size(PubRaw), byte_size(PrivRaw)})
|
||||
end.
|
||||
|
||||
-spec assert_vapid_scalar_derives_point(binary(), binary(), binary()) -> ok.
|
||||
assert_vapid_scalar_derives_point(PublicKeyB64Url, PubRaw, PrivRaw) ->
|
||||
Derived =
|
||||
try crypto:generate_key(ecdh, prime256v1, PrivRaw) of
|
||||
{Point, _} -> Point
|
||||
catch
|
||||
_:_ -> undefined
|
||||
end,
|
||||
case Derived of
|
||||
PubRaw -> ok;
|
||||
_ -> erlang:error({vapid_keys_mismatched, PublicKeyB64Url})
|
||||
end.
|
||||
|
||||
-spec sign_and_compact(term(), map(), map()) -> binary().
|
||||
sign_and_compact(JWK, Header, Claims) ->
|
||||
JWS = jose_jwt:sign(JWK, Header, Claims),
|
||||
|
||||
@@ -45,7 +45,7 @@ cluster_overrides_test() ->
|
||||
maps:get(cluster_static_peers, Config)
|
||||
).
|
||||
|
||||
cluster_static_peers_filters_invalid_node_names_test() ->
|
||||
cluster_static_peers_rejects_invalid_node_names_test() ->
|
||||
LongPeer = list_to_binary(lists:duplicate(260, $a)),
|
||||
RawConfig = #{
|
||||
<<"services">> => #{
|
||||
@@ -60,6 +60,20 @@ cluster_static_peers_filters_invalid_node_names_test() ->
|
||||
}
|
||||
}
|
||||
},
|
||||
?assertError(
|
||||
{invalid_cluster_static_peer, "invalid [email protected]"},
|
||||
fluxer_gateway_config:build_config(RawConfig)
|
||||
).
|
||||
|
||||
cluster_static_peers_accepts_valid_node_names_test() ->
|
||||
RawConfig = #{
|
||||
<<"services">> => #{
|
||||
<<"gateway">> => #{
|
||||
<<"cluster_static_peers">> =>
|
||||
<<"[email protected],[email protected]">>
|
||||
}
|
||||
}
|
||||
},
|
||||
Config = fluxer_gateway_config:build_config(RawConfig),
|
||||
?assertEqual(
|
||||
[list_to_atom("[email protected]"), list_to_atom("[email protected]")],
|
||||
@@ -140,6 +154,20 @@ rpc_concurrency_keys_are_independent_test() ->
|
||||
end
|
||||
).
|
||||
|
||||
env_int_rejects_a_non_integer_value_test() ->
|
||||
with_env("FLUXER_GATEWAY_HTTP_RPC_MAX_CONCURRENCY", "abc", fun() ->
|
||||
?assertError(
|
||||
{invalid_integer_env, "FLUXER_GATEWAY_HTTP_RPC_MAX_CONCURRENCY", "abc"},
|
||||
fluxer_gateway_config:load()
|
||||
)
|
||||
end).
|
||||
|
||||
env_int_falls_back_to_the_default_for_an_empty_value_test() ->
|
||||
with_env("FLUXER_GATEWAY_HTTP_RPC_MAX_CONCURRENCY", "", fun() ->
|
||||
Config = fluxer_gateway_config:load(),
|
||||
?assertEqual(512, maps:get(gateway_http_rpc_max_concurrency, Config))
|
||||
end).
|
||||
|
||||
rpc_concurrency_key_defaults_test() ->
|
||||
Config = fluxer_gateway_config:build_config(#{}),
|
||||
?assertEqual(512, maps:get(gateway_nats_rpc_max_handlers, Config)),
|
||||
|
||||
@@ -160,6 +160,30 @@ prefetch_user_guild_settings_skips_direct_messages_test() ->
|
||||
end),
|
||||
?assertEqual([], settings_requests()).
|
||||
|
||||
init_logs_a_mismatched_vapid_pair_test() ->
|
||||
with_push_env(
|
||||
fun() ->
|
||||
{Pub, _} = generate_vapid_pair(),
|
||||
{_, OtherPriv} = generate_vapid_pair(),
|
||||
patch_vapid(true, Pub, OtherPriv),
|
||||
{ok, Pid} = with_captured_logs(fun() -> push:start_link() end),
|
||||
?assert(is_process_alive(Pid)),
|
||||
?assert(any_error_log_mentions("FLUXER_VAPID_PUBLIC_KEY")),
|
||||
gen_server:stop(Pid)
|
||||
end
|
||||
).
|
||||
|
||||
init_accepts_a_matching_vapid_pair_test() ->
|
||||
with_push_env(
|
||||
fun() ->
|
||||
{Pub, Priv} = generate_vapid_pair(),
|
||||
patch_vapid(true, Pub, Priv),
|
||||
{ok, Pid} = push:start_link(),
|
||||
?assert(is_process_alive(Pid)),
|
||||
gen_server:stop(Pid)
|
||||
end
|
||||
).
|
||||
|
||||
with_rpc_client_stub(Result, Fun) ->
|
||||
Self = self(),
|
||||
ok = meck:new(rpc_client, [passthrough, no_link]),
|
||||
@@ -184,6 +208,70 @@ settings_requests() ->
|
||||
[]
|
||||
end.
|
||||
|
||||
with_push_env(Fun) ->
|
||||
push_ets_cache:init(),
|
||||
push_worker_pool:init_counter(),
|
||||
OldConfig = fluxer_gateway_env:get_map(),
|
||||
OldTrap = erlang:process_flag(trap_exit, true),
|
||||
try
|
||||
Fun()
|
||||
after
|
||||
_ = erlang:process_flag(trap_exit, OldTrap),
|
||||
flush_exit_signals(),
|
||||
_ = fluxer_gateway_env:update(fun(_) -> OldConfig end)
|
||||
end.
|
||||
|
||||
with_captured_logs(Fun) ->
|
||||
Self = self(),
|
||||
ok = logger:add_primary_filter(
|
||||
capture_logs, {
|
||||
fun(Event, Pid) ->
|
||||
Pid ! {captured_log, Event},
|
||||
stop
|
||||
end,
|
||||
Self
|
||||
}
|
||||
),
|
||||
try
|
||||
Fun()
|
||||
after
|
||||
_ = logger:remove_primary_filter(capture_logs)
|
||||
end.
|
||||
|
||||
any_error_log_mentions(Needle) ->
|
||||
receive
|
||||
{captured_log, #{level := error, msg := {string, Message}}} ->
|
||||
case string:find(Message, Needle) of
|
||||
nomatch -> any_error_log_mentions(Needle);
|
||||
_ -> true
|
||||
end;
|
||||
{captured_log, _} ->
|
||||
any_error_log_mentions(Needle)
|
||||
after 0 ->
|
||||
false
|
||||
end.
|
||||
|
||||
patch_vapid(Enabled, Pub, Priv) ->
|
||||
_ = fluxer_gateway_env:patch(#{
|
||||
push_enabled => Enabled,
|
||||
vapid_public_key => push_utils:base64url_encode(Pub),
|
||||
vapid_private_key => push_utils:base64url_encode(Priv)
|
||||
}),
|
||||
ok.
|
||||
|
||||
generate_vapid_pair() ->
|
||||
case crypto:generate_key(ecdh, prime256v1) of
|
||||
{<<4, _:64/binary>> = Pub, <<_:32/binary>> = Priv} -> {Pub, Priv};
|
||||
_ -> generate_vapid_pair()
|
||||
end.
|
||||
|
||||
flush_exit_signals() ->
|
||||
receive
|
||||
{'EXIT', _, _} -> flush_exit_signals()
|
||||
after 0 ->
|
||||
ok
|
||||
end.
|
||||
|
||||
erase_persistent_term(Key) ->
|
||||
try persistent_term:erase(Key) of
|
||||
_ -> ok
|
||||
|
||||
@@ -56,3 +56,50 @@ hkdf_expand_test() ->
|
||||
Info = <<"test info">>,
|
||||
Result = push_utils:hkdf_expand(IKM, Salt, Info, 32),
|
||||
?assertEqual(32, byte_size(Result)).
|
||||
|
||||
assert_vapid_pair_accepts_generated_pair_test() ->
|
||||
{Pub, Priv} = generate_vapid_pair(),
|
||||
?assertEqual(
|
||||
ok,
|
||||
push_utils:assert_vapid_pair(
|
||||
push_utils:base64url_encode(Pub),
|
||||
push_utils:base64url_encode(Priv)
|
||||
)
|
||||
).
|
||||
|
||||
assert_vapid_pair_rejects_mismatched_scalar_test() ->
|
||||
{Pub, _} = generate_vapid_pair(),
|
||||
{_, OtherPriv} = generate_vapid_pair(),
|
||||
?assertError(
|
||||
{vapid_keys_mismatched, _},
|
||||
push_utils:assert_vapid_pair(
|
||||
push_utils:base64url_encode(Pub),
|
||||
push_utils:base64url_encode(OtherPriv)
|
||||
)
|
||||
).
|
||||
|
||||
assert_vapid_pair_rejects_malformed_public_point_test() ->
|
||||
{Pub, Priv} = generate_vapid_pair(),
|
||||
?assertError(
|
||||
{vapid_keys_malformed, 64, 32},
|
||||
push_utils:assert_vapid_pair(
|
||||
push_utils:base64url_encode(binary:part(Pub, 0, 64)),
|
||||
push_utils:base64url_encode(Priv)
|
||||
)
|
||||
).
|
||||
|
||||
assert_vapid_pair_rejects_short_scalar_test() ->
|
||||
{Pub, Priv} = generate_vapid_pair(),
|
||||
?assertError(
|
||||
{vapid_keys_malformed, 65, 31},
|
||||
push_utils:assert_vapid_pair(
|
||||
push_utils:base64url_encode(Pub),
|
||||
push_utils:base64url_encode(binary:part(Priv, 0, 31))
|
||||
)
|
||||
).
|
||||
|
||||
generate_vapid_pair() ->
|
||||
case crypto:generate_key(ecdh, prime256v1) of
|
||||
{<<4, _:64/binary>> = Pub, <<_:32/binary>> = Priv} -> {Pub, Priv};
|
||||
_ -> generate_vapid_pair()
|
||||
end.
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {createPrivateKey, createPublicKey} from 'node:crypto';
|
||||
import {buildNamedFluxerEnvOverrides} from '@fluxer/config/src/config_loader/EnvironmentOverrides';
|
||||
import {
|
||||
type DerivedEndpoints,
|
||||
@@ -41,6 +42,7 @@ function defaultConfig(): MasterConfig {
|
||||
media: '',
|
||||
static_cdn: '',
|
||||
admin: '',
|
||||
docs: '',
|
||||
marketing: '',
|
||||
invite: '',
|
||||
gift: '',
|
||||
@@ -90,7 +92,6 @@ function defaultConfig(): MasterConfig {
|
||||
downloads: 'fluxer-downloads',
|
||||
reports: 'fluxer-reports',
|
||||
harvests: 'fluxer-harvests',
|
||||
static: 'fluxer-static',
|
||||
},
|
||||
},
|
||||
services: {
|
||||
@@ -130,14 +131,14 @@ function defaultConfig(): MasterConfig {
|
||||
mode: 'upload',
|
||||
upload_relay: {
|
||||
endpoint: 'http://localhost:8088/media',
|
||||
max_body_bytes: 268_435_456,
|
||||
secret_base64: '',
|
||||
max_body_bytes: 524_288_000,
|
||||
token_ttl_secs: 900,
|
||||
keep_direct_countries: [],
|
||||
},
|
||||
},
|
||||
gateway: {
|
||||
port: 8771,
|
||||
push_enabled: false,
|
||||
rpc_auth_token: '',
|
||||
},
|
||||
admin: {
|
||||
@@ -163,7 +164,7 @@ function defaultConfig(): MasterConfig {
|
||||
sso_allow_private_addresses: false,
|
||||
passkeys: {
|
||||
rp_name: 'Fluxer',
|
||||
rp_id: 'fluxer.app',
|
||||
rp_id: '',
|
||||
additional_allowed_origins: DEFAULT_PASSKEY_ORIGINS,
|
||||
},
|
||||
vapid: {
|
||||
@@ -172,12 +173,12 @@ function defaultConfig(): MasterConfig {
|
||||
email: '',
|
||||
},
|
||||
bluesky: {
|
||||
enabled: true,
|
||||
enabled: false,
|
||||
client_name: 'Fluxer',
|
||||
client_uri: '',
|
||||
logo_uri: '',
|
||||
tos_uri: 'https://fluxer.app/terms',
|
||||
policy_uri: 'https://fluxer.app/privacy',
|
||||
tos_uri: '',
|
||||
policy_uri: '',
|
||||
keys: [],
|
||||
},
|
||||
},
|
||||
@@ -335,6 +336,22 @@ function requireString(value: string | undefined, envName: string): void {
|
||||
}
|
||||
}
|
||||
|
||||
function validateUploadRelaySecret(value: string, mode: string): void {
|
||||
const trimmed = value.trim();
|
||||
if (trimmed.length === 0) {
|
||||
if (mode === 'upload') {
|
||||
throw new Error('FLUXER_MEDIA_PROXY_UPLOAD_RELAY_SECRET_BASE64 is required in upload mode');
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!/^[A-Za-z0-9+/]+={0,2}$/u.test(trimmed)) {
|
||||
throw new Error('FLUXER_MEDIA_PROXY_UPLOAD_RELAY_SECRET_BASE64 must be base64');
|
||||
}
|
||||
if (Buffer.from(trimmed, 'base64').length < 32) {
|
||||
throw new Error('FLUXER_MEDIA_PROXY_UPLOAD_RELAY_SECRET_BASE64 must decode to at least 32 bytes');
|
||||
}
|
||||
}
|
||||
|
||||
function assertBoolean(value: unknown, envName: string): asserts value is boolean {
|
||||
if (typeof value !== 'boolean') {
|
||||
throw new Error(`${envName} must be true or false`);
|
||||
@@ -353,6 +370,35 @@ function assertIdentifier(value: string, envName: string): void {
|
||||
}
|
||||
}
|
||||
|
||||
function validateVapidConfig(config: MasterConfig): void {
|
||||
requireString(config.auth.vapid.public_key, 'FLUXER_VAPID_PUBLIC_KEY');
|
||||
requireString(config.auth.vapid.private_key, 'FLUXER_VAPID_PRIVATE_KEY');
|
||||
const pub = Buffer.from(config.auth.vapid.public_key, 'base64url');
|
||||
const priv = Buffer.from(config.auth.vapid.private_key, 'base64url');
|
||||
if (pub.length !== 65 || pub[0] !== 0x04) {
|
||||
throw new Error('FLUXER_VAPID_PUBLIC_KEY must be the base64url 65-byte uncompressed P-256 point');
|
||||
}
|
||||
if (priv.length !== 32) {
|
||||
throw new Error('FLUXER_VAPID_PRIVATE_KEY must be the base64url 32-byte P-256 scalar');
|
||||
}
|
||||
const jwk = {
|
||||
kty: 'EC',
|
||||
crv: 'P-256',
|
||||
x: pub.subarray(1, 33).toString('base64url'),
|
||||
y: pub.subarray(33, 65).toString('base64url'),
|
||||
d: priv.toString('base64url'),
|
||||
};
|
||||
let derived: {x?: string; y?: string};
|
||||
try {
|
||||
derived = createPublicKey(createPrivateKey({key: jwk, format: 'jwk'})).export({format: 'jwk'});
|
||||
} catch {
|
||||
throw new Error('FLUXER_VAPID_PRIVATE_KEY does not match FLUXER_VAPID_PUBLIC_KEY');
|
||||
}
|
||||
if (derived.x !== jwk.x || derived.y !== jwk.y) {
|
||||
throw new Error('FLUXER_VAPID_PRIVATE_KEY does not match FLUXER_VAPID_PUBLIC_KEY');
|
||||
}
|
||||
}
|
||||
|
||||
function validatePostgresConfig(config: MasterConfig): void {
|
||||
const postgres = config.database.postgres;
|
||||
assertIntegerInRange(postgres.port, 'FLUXER_POSTGRES_PORT', 1, 65535);
|
||||
@@ -380,6 +426,24 @@ function validatePostgresConfig(config: MasterConfig): void {
|
||||
}
|
||||
}
|
||||
|
||||
function validateCaptchaConfig(config: MasterConfig): void {
|
||||
const captcha = config.integrations.captcha;
|
||||
if (!captcha.enabled) {
|
||||
return;
|
||||
}
|
||||
if (captcha.provider === 'hcaptcha') {
|
||||
requireString(captcha.hcaptcha?.site_key, 'FLUXER_CAPTCHA_HCAPTCHA_SITE_KEY');
|
||||
requireString(captcha.hcaptcha?.secret_key, 'FLUXER_CAPTCHA_HCAPTCHA_SECRET_KEY');
|
||||
return;
|
||||
}
|
||||
if (captcha.provider === 'turnstile') {
|
||||
requireString(captcha.turnstile?.site_key, 'FLUXER_CAPTCHA_TURNSTILE_SITE_KEY');
|
||||
requireString(captcha.turnstile?.secret_key, 'FLUXER_CAPTCHA_TURNSTILE_SECRET_KEY');
|
||||
return;
|
||||
}
|
||||
throw new Error('FLUXER_CAPTCHA_PROVIDER must be hcaptcha or turnstile when FLUXER_CAPTCHA_ENABLED is true');
|
||||
}
|
||||
|
||||
function validateApiWorkerConfig(config: MasterConfig): void {
|
||||
const worker = config.services.api?.worker;
|
||||
if (!worker) {
|
||||
@@ -412,6 +476,7 @@ function normalizeConfig(config: MasterConfig): MasterConfig {
|
||||
'FLUXER_ABUSE_DIRECT_CONTACT_SPAM_ACTION',
|
||||
);
|
||||
validatePostgresConfig(config);
|
||||
validateCaptchaConfig(config);
|
||||
validateApiWorkerConfig(config);
|
||||
assertIntegerInRange(config.services.api.max_inflight_requests, 'FLUXER_API_MAX_INFLIGHT_REQUESTS', 1, 100_000);
|
||||
assertIntegerInRange(config.services.api.headers_timeout_ms, 'FLUXER_API_HEADERS_TIMEOUT_MS', 1_000, 3_600_000);
|
||||
@@ -419,11 +484,11 @@ function normalizeConfig(config: MasterConfig): MasterConfig {
|
||||
requireString(config.domain.base_domain, 'FLUXER_BASE_DOMAIN');
|
||||
requireString(config.auth.sudo_mode_secret, 'FLUXER_SUDO_MODE_SECRET');
|
||||
requireString(config.auth.connection_initiation_secret, 'FLUXER_CONNECTION_INITIATION_SECRET');
|
||||
requireString(config.auth.vapid.public_key, 'FLUXER_VAPID_PUBLIC_KEY');
|
||||
requireString(config.auth.vapid.private_key, 'FLUXER_VAPID_PRIVATE_KEY');
|
||||
validateVapidConfig(config);
|
||||
requireString(config.s3?.access_key_id, 'FLUXER_S3_ACCESS_KEY_ID');
|
||||
requireString(config.s3?.secret_access_key, 'FLUXER_S3_SECRET_ACCESS_KEY');
|
||||
requireString(config.services.media_proxy.secret_key, 'FLUXER_MEDIA_PROXY_SECRET_KEY');
|
||||
validateUploadRelaySecret(config.services.media_proxy.upload_relay.secret_base64, config.services.media_proxy.mode);
|
||||
requireString(config.services.admin.secret_key_base, 'FLUXER_ADMIN_SECRET_KEY_BASE');
|
||||
requireString(config.services.admin.oauth_client_secret, 'FLUXER_ADMIN_OAUTH_CLIENT_SECRET');
|
||||
if (!config.instance.self_hosted) {
|
||||
@@ -463,15 +528,37 @@ function applyPublicPort(config: MasterConfig, endpoints: DerivedEndpoints): Mas
|
||||
};
|
||||
}
|
||||
|
||||
function resolveAppOrigin(appEndpoint: string): string {
|
||||
try {
|
||||
return new URL(appEndpoint).origin;
|
||||
} catch {
|
||||
throw new Error(`FLUXER_APP_ENDPOINT must be a valid URL: ${appEndpoint}`);
|
||||
}
|
||||
}
|
||||
|
||||
function applyPasskeyDefaults(config: MasterConfig, endpoints: DerivedEndpoints): void {
|
||||
const passkeys = config.auth.passkeys;
|
||||
if (passkeys.rp_id.trim().length === 0) {
|
||||
passkeys.rp_id = config.domain.base_domain;
|
||||
}
|
||||
if (passkeys.additional_allowed_origins.length === 0) {
|
||||
passkeys.additional_allowed_origins = [resolveAppOrigin(endpoints.app)];
|
||||
}
|
||||
}
|
||||
|
||||
export async function loadConfig(): Promise<MasterConfig> {
|
||||
if (cachedConfig) {
|
||||
return cachedConfig;
|
||||
}
|
||||
const merged = mergeConfig(defaultConfig(), buildNamedFluxerEnvOverrides(process.env));
|
||||
const overrides = buildNamedFluxerEnvOverrides(process.env);
|
||||
const merged = mergeConfig(defaultConfig(), overrides);
|
||||
const normalized = normalizeConfig(merged);
|
||||
const derived = deriveEndpointsFromDomain(normalized.domain);
|
||||
const endpoints = {...derived, ...(normalized.endpoint_overrides ?? {})};
|
||||
cachedConfig = applyPublicPort(normalized, endpoints);
|
||||
requireString(endpoints.api_client, 'FLUXER_API_CLIENT_ENDPOINT');
|
||||
const withPublicPort = applyPublicPort(normalized, endpoints);
|
||||
applyPasskeyDefaults(withPublicPort, withPublicPort.endpoints);
|
||||
cachedConfig = withPublicPort;
|
||||
return cachedConfig;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
const DOCS_ENDPOINT = 'https://fluxer.dev';
|
||||
|
||||
export interface DomainConfig {
|
||||
base_domain: string;
|
||||
public_scheme: 'http' | 'https';
|
||||
@@ -19,6 +21,7 @@ export interface DerivedEndpoints {
|
||||
media: string;
|
||||
static_cdn: string;
|
||||
admin: string;
|
||||
docs: string;
|
||||
marketing: string;
|
||||
invite: string;
|
||||
gift: string;
|
||||
@@ -83,6 +86,7 @@ export function deriveDomain(
|
||||
| 'media'
|
||||
| 'static_cdn'
|
||||
| 'admin'
|
||||
| 'docs'
|
||||
| 'marketing'
|
||||
| 'invite'
|
||||
| 'gift',
|
||||
@@ -113,6 +117,7 @@ export function deriveEndpointsFromDomain(config: DomainConfig): DerivedEndpoint
|
||||
? buildUrl('https', deriveDomain('static_cdn', config), undefined)
|
||||
: buildUrl(public_scheme, deriveDomain('static_cdn', config), public_port),
|
||||
admin: buildUrl(public_scheme, deriveDomain('admin', config), public_port, '/admin'),
|
||||
docs: DOCS_ENDPOINT,
|
||||
marketing: buildUrl(public_scheme, deriveDomain('marketing', config), public_port, '/marketing'),
|
||||
invite: buildUrl(public_scheme, deriveDomain('invite', config), public_port, '/invite'),
|
||||
gift: buildUrl(public_scheme, deriveDomain('gift', config), public_port, '/gift'),
|
||||
|
||||
@@ -77,7 +77,6 @@ export interface MasterConfig {
|
||||
downloads: string;
|
||||
reports: string;
|
||||
harvests: string;
|
||||
static: string;
|
||||
};
|
||||
};
|
||||
s3_downloads?: {
|
||||
@@ -146,6 +145,7 @@ export interface MasterConfig {
|
||||
mode: string;
|
||||
upload_relay: {
|
||||
endpoint: string;
|
||||
secret_base64: string;
|
||||
max_body_bytes: number;
|
||||
token_ttl_secs: number;
|
||||
keep_direct_countries: Array<string>;
|
||||
@@ -156,7 +156,6 @@ export interface MasterConfig {
|
||||
rpc_auth_token?: string;
|
||||
media_proxy_endpoint?: string;
|
||||
api_rpc_endpoint?: string;
|
||||
push_enabled: boolean;
|
||||
};
|
||||
admin: {
|
||||
port: number;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {generateKeyPairSync} from 'node:crypto';
|
||||
import {getConfig, loadConfig, resetConfig} from '@fluxer/config/src/ConfigLoader';
|
||||
import {afterEach, beforeEach, describe, expect, test, vi} from 'vitest';
|
||||
|
||||
@@ -18,6 +19,7 @@ const MINIMAL_ENV: Record<string, string> = {
|
||||
FLUXER_S3_ACCESS_KEY_ID: 'test-key',
|
||||
FLUXER_S3_SECRET_ACCESS_KEY: 'test-secret',
|
||||
FLUXER_MEDIA_PROXY_SECRET_KEY: 'test-media-secret',
|
||||
FLUXER_MEDIA_PROXY_UPLOAD_RELAY_SECRET_BASE64: 'AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=',
|
||||
FLUXER_ADMIN_SECRET_KEY_BASE: 'test-admin-secret',
|
||||
FLUXER_ADMIN_OAUTH_CLIENT_SECRET: 'test-admin-oauth-secret',
|
||||
FLUXER_MARKETING_SECRET_KEY_BASE: 'test-marketing-secret',
|
||||
@@ -26,10 +28,22 @@ const MINIMAL_ENV: Record<string, string> = {
|
||||
FLUXER_GATEWAY_RPC_AUTH_TOKEN: 'test-gateway-token',
|
||||
FLUXER_SUDO_MODE_SECRET: 'test-sudo-secret',
|
||||
FLUXER_CONNECTION_INITIATION_SECRET: 'test-connection-secret',
|
||||
FLUXER_VAPID_PUBLIC_KEY: 'test-vapid-public-key',
|
||||
FLUXER_VAPID_PRIVATE_KEY: 'test-vapid-private-key',
|
||||
FLUXER_VAPID_PUBLIC_KEY: 'BB76bTFIuoqmxJtTfZX0yGTn1f_qu9H03B_nkj8OyExJFkN7Y-HBZZzShnHZoEhXKc5ZRy3jFu7OkBbnaQG-4aw',
|
||||
FLUXER_VAPID_PRIVATE_KEY: 'Xgi-3P8J-I3Q6U1HlCcXMuc_tKLGAM9nIfznX3Hz68o',
|
||||
};
|
||||
|
||||
function generateVapidPair(): {publicKey: string; privateKey: string} {
|
||||
const {privateKey} = generateKeyPairSync('ec', {namedCurve: 'prime256v1'});
|
||||
const jwk = privateKey.export({format: 'jwk'});
|
||||
const x = Buffer.from(jwk.x ?? '', 'base64url');
|
||||
const y = Buffer.from(jwk.y ?? '', 'base64url');
|
||||
const d = Buffer.from(jwk.d ?? '', 'base64url');
|
||||
return {
|
||||
publicKey: Buffer.concat([Buffer.from([0x04]), x, y]).toString('base64url'),
|
||||
privateKey: d.toString('base64url'),
|
||||
};
|
||||
}
|
||||
|
||||
function stubMinimalEnv(overrides: Record<string, string> = {}): void {
|
||||
for (const [key, value] of Object.entries({...MINIMAL_ENV, ...overrides})) {
|
||||
vi.stubEnv(key, value);
|
||||
@@ -201,6 +215,51 @@ describe('ConfigLoader', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
test('rejects an empty client API endpoint override', async () => {
|
||||
stubMinimalEnv({FLUXER_API_CLIENT_ENDPOINT: ''});
|
||||
await expect(loadConfig()).rejects.toThrow('FLUXER_API_CLIENT_ENDPOINT is required');
|
||||
});
|
||||
|
||||
test('defaults the passkey relying party to the deployment domain', async () => {
|
||||
stubMinimalEnv({
|
||||
FLUXER_BASE_DOMAIN: 'chat.example.com',
|
||||
FLUXER_PUBLIC_SCHEME: 'https',
|
||||
FLUXER_PUBLIC_PORT: '443',
|
||||
});
|
||||
|
||||
const config = await loadConfig();
|
||||
|
||||
expect(config.auth.passkeys.rp_id).toBe('chat.example.com');
|
||||
});
|
||||
|
||||
test('derives the passkey origin only when the operator clears the default list', async () => {
|
||||
stubMinimalEnv({
|
||||
FLUXER_BASE_DOMAIN: 'chat.example.com',
|
||||
FLUXER_PUBLIC_SCHEME: 'https',
|
||||
FLUXER_PUBLIC_PORT: '443',
|
||||
FLUXER_PASSKEY_ADDITIONAL_ALLOWED_ORIGINS: '',
|
||||
});
|
||||
|
||||
const config = await loadConfig();
|
||||
|
||||
expect(config.auth.passkeys.additional_allowed_origins).toEqual(['https://chat.example.com']);
|
||||
});
|
||||
|
||||
test('keeps explicit passkey relying party values', async () => {
|
||||
stubMinimalEnv({
|
||||
FLUXER_BASE_DOMAIN: 'chat.example.com',
|
||||
FLUXER_PUBLIC_SCHEME: 'https',
|
||||
FLUXER_PUBLIC_PORT: '443',
|
||||
FLUXER_PASSKEY_RP_ID: 'example.com',
|
||||
FLUXER_PASSKEY_ADDITIONAL_ALLOWED_ORIGINS: 'https://example.com,https://app.example.com',
|
||||
});
|
||||
|
||||
const config = await loadConfig();
|
||||
|
||||
expect(config.auth.passkeys.rp_id).toBe('example.com');
|
||||
expect(config.auth.passkeys.additional_allowed_origins).toEqual(['https://example.com', 'https://app.example.com']);
|
||||
});
|
||||
|
||||
test('parses typed named environment variables', async () => {
|
||||
stubMinimalEnv({
|
||||
FLUXER_API_PORT: '9090',
|
||||
@@ -212,7 +271,6 @@ describe('ConfigLoader', () => {
|
||||
FLUXER_POSTGRES_PREPARED_STATEMENTS: 'false',
|
||||
FLUXER_API_WORKER_MODE: 'single_task',
|
||||
FLUXER_API_WORKER_TASK: 'processStripeWebhook',
|
||||
FLUXER_GATEWAY_PUSH_ENABLED: 'false',
|
||||
FLUXER_ACCOUNT_POLICY_DSL: '{"version":1,"id":"env_policy","rules":[]}',
|
||||
FLUXER_LIVEKIT_ENABLED: 'true',
|
||||
FLUXER_LIVEKIT_DEFAULT_REGION:
|
||||
@@ -230,7 +288,6 @@ describe('ConfigLoader', () => {
|
||||
expect(config.database.postgres.prepared_statements).toBe(false);
|
||||
expect(config.services.api.worker?.mode).toBe('single_task');
|
||||
expect(config.services.api.worker?.task).toBe('processStripeWebhook');
|
||||
expect(config.services.gateway.push_enabled).toBe(false);
|
||||
expect(config.integrations.risk_integration.account_policy_dsl).toEqual({
|
||||
version: 1,
|
||||
id: 'env_policy',
|
||||
@@ -239,6 +296,14 @@ describe('ConfigLoader', () => {
|
||||
expect(config.integrations.voice.default_region?.id).toBe('local');
|
||||
});
|
||||
|
||||
test('ignores FLUXER_GATEWAY_PUSH_ENABLED, which only the gateway reads', async () => {
|
||||
stubMinimalEnv({FLUXER_GATEWAY_PUSH_ENABLED: 'false'});
|
||||
|
||||
const config = await loadConfig();
|
||||
|
||||
expect(config.services.gateway).not.toHaveProperty('push_enabled');
|
||||
});
|
||||
|
||||
test('rejects single task worker mode without task env', async () => {
|
||||
stubMinimalEnv({FLUXER_API_WORKER_MODE: 'single_task'});
|
||||
await expect(loadConfig()).rejects.toThrow('FLUXER_API_WORKER_TASK');
|
||||
@@ -249,6 +314,16 @@ describe('ConfigLoader', () => {
|
||||
await expect(loadConfig()).rejects.toThrow('FLUXER_POSTGRES_PORT');
|
||||
});
|
||||
|
||||
test('rejects a non-integer port', async () => {
|
||||
stubMinimalEnv({FLUXER_API_PORT: '80a'});
|
||||
await expect(loadConfig()).rejects.toThrow('FLUXER_API_PORT must be an integer, got "80a"');
|
||||
});
|
||||
|
||||
test('rejects malformed JSON for a JSON-shaped variable', async () => {
|
||||
stubMinimalEnv({FLUXER_LIVEKIT_DEFAULT_REGION: '{bad'});
|
||||
await expect(loadConfig()).rejects.toThrow('FLUXER_LIVEKIT_DEFAULT_REGION must be valid JSON');
|
||||
});
|
||||
|
||||
test('keeps Postgres prepared statements on by default', async () => {
|
||||
stubMinimalEnv();
|
||||
expect((await loadConfig()).database.postgres.prepared_statements).toBe(true);
|
||||
@@ -283,6 +358,16 @@ describe('ConfigLoader', () => {
|
||||
await expect(loadConfig()).rejects.toThrow('FLUXER_API_REQUEST_TIMEOUT_MS');
|
||||
});
|
||||
|
||||
test('applies the KV mode from the environment', async () => {
|
||||
stubMinimalEnv({FLUXER_KV_MODE: 'cluster'});
|
||||
expect((await loadConfig()).internal.kv_mode).toBe('cluster');
|
||||
});
|
||||
|
||||
test('rejects an unknown KV mode', async () => {
|
||||
stubMinimalEnv({FLUXER_KV_MODE: 'sentinel'});
|
||||
await expect(loadConfig()).rejects.toThrow('Invalid FLUXER_KV_MODE: sentinel');
|
||||
});
|
||||
|
||||
test('rejects unsafe production Postgres defaults', async () => {
|
||||
stubMinimalEnv({FLUXER_ENV: 'production'});
|
||||
await expect(loadConfig()).rejects.toThrow('FLUXER_POSTGRES_HOST');
|
||||
@@ -405,6 +490,158 @@ describe('ConfigLoader', () => {
|
||||
});
|
||||
});
|
||||
|
||||
test('rejects an enabled captcha with no keys for the selected provider', async () => {
|
||||
stubMinimalEnv({FLUXER_CAPTCHA_ENABLED: 'true', FLUXER_CAPTCHA_PROVIDER: 'hcaptcha'});
|
||||
await expect(loadConfig()).rejects.toThrow('FLUXER_CAPTCHA_HCAPTCHA_SITE_KEY is required');
|
||||
});
|
||||
|
||||
test('rejects an enabled captcha with a site key but no secret key', async () => {
|
||||
stubMinimalEnv({
|
||||
FLUXER_CAPTCHA_ENABLED: 'true',
|
||||
FLUXER_CAPTCHA_PROVIDER: 'turnstile',
|
||||
FLUXER_CAPTCHA_TURNSTILE_SITE_KEY: 'turnstile-site-key',
|
||||
});
|
||||
await expect(loadConfig()).rejects.toThrow('FLUXER_CAPTCHA_TURNSTILE_SECRET_KEY is required');
|
||||
});
|
||||
|
||||
test('rejects an enabled captcha with no provider', async () => {
|
||||
stubMinimalEnv({FLUXER_CAPTCHA_ENABLED: 'true'});
|
||||
await expect(loadConfig()).rejects.toThrow(
|
||||
'FLUXER_CAPTCHA_PROVIDER must be hcaptcha or turnstile when FLUXER_CAPTCHA_ENABLED is true',
|
||||
);
|
||||
});
|
||||
|
||||
test('accepts an enabled captcha with both keys for the selected provider', async () => {
|
||||
stubMinimalEnv({
|
||||
FLUXER_CAPTCHA_ENABLED: 'true',
|
||||
FLUXER_CAPTCHA_PROVIDER: 'hcaptcha',
|
||||
FLUXER_CAPTCHA_HCAPTCHA_SITE_KEY: 'hcaptcha-site-key',
|
||||
FLUXER_CAPTCHA_HCAPTCHA_SECRET_KEY: 'hcaptcha-secret-key',
|
||||
});
|
||||
|
||||
const config = await loadConfig();
|
||||
|
||||
expect(config.integrations.captcha.enabled).toBe(true);
|
||||
expect(config.integrations.captcha.hcaptcha?.secret_key).toBe('hcaptcha-secret-key');
|
||||
});
|
||||
|
||||
test('leaves a disabled captcha unvalidated', async () => {
|
||||
stubMinimalEnv({FLUXER_CAPTCHA_PROVIDER: 'hcaptcha'});
|
||||
expect((await loadConfig()).integrations.captcha.enabled).toBe(false);
|
||||
});
|
||||
|
||||
test('leaves Bluesky login off with no legal URLs by default', async () => {
|
||||
stubMinimalEnv();
|
||||
|
||||
const config = await loadConfig();
|
||||
|
||||
expect(config.auth.bluesky.enabled).toBe(false);
|
||||
expect(config.auth.bluesky.tos_uri).toBe('');
|
||||
expect(config.auth.bluesky.policy_uri).toBe('');
|
||||
expect(config.auth.bluesky.keys).toEqual([]);
|
||||
});
|
||||
|
||||
test('applies explicit Bluesky legal URLs from the environment', async () => {
|
||||
stubMinimalEnv({
|
||||
FLUXER_AUTH_BLUESKY_ENABLED: 'true',
|
||||
FLUXER_AUTH_BLUESKY_TOS_URI: 'https://chat.example.com/terms',
|
||||
FLUXER_AUTH_BLUESKY_POLICY_URI: 'https://chat.example.com/privacy',
|
||||
});
|
||||
|
||||
const config = await loadConfig();
|
||||
|
||||
expect(config.auth.bluesky.enabled).toBe(true);
|
||||
expect(config.auth.bluesky.tos_uri).toBe('https://chat.example.com/terms');
|
||||
expect(config.auth.bluesky.policy_uri).toBe('https://chat.example.com/privacy');
|
||||
});
|
||||
|
||||
test('reads the upload relay secret through the override table', async () => {
|
||||
const secret = Buffer.alloc(32, 9).toString('base64');
|
||||
stubMinimalEnv({FLUXER_MEDIA_PROXY_UPLOAD_RELAY_SECRET_BASE64: secret});
|
||||
|
||||
const config = await loadConfig();
|
||||
|
||||
expect(config.services.media_proxy.upload_relay.secret_base64).toBe(secret);
|
||||
});
|
||||
|
||||
test('defaults the upload relay body limit to the media proxy ceiling', async () => {
|
||||
stubMinimalEnv();
|
||||
expect((await loadConfig()).services.media_proxy.upload_relay.max_body_bytes).toBe(524_288_000);
|
||||
});
|
||||
|
||||
test('rejects a missing upload relay secret in upload mode', async () => {
|
||||
stubMinimalEnv();
|
||||
vi.stubEnv('FLUXER_MEDIA_PROXY_UPLOAD_RELAY_SECRET_BASE64', '');
|
||||
|
||||
await expect(loadConfig()).rejects.toThrow(
|
||||
'FLUXER_MEDIA_PROXY_UPLOAD_RELAY_SECRET_BASE64 is required in upload mode',
|
||||
);
|
||||
});
|
||||
|
||||
test('rejects a non-base64 upload relay secret', async () => {
|
||||
stubMinimalEnv({FLUXER_MEDIA_PROXY_UPLOAD_RELAY_SECRET_BASE64: 'not base64!'});
|
||||
await expect(loadConfig()).rejects.toThrow('FLUXER_MEDIA_PROXY_UPLOAD_RELAY_SECRET_BASE64 must be base64');
|
||||
});
|
||||
|
||||
test('rejects an upload relay secret shorter than 32 bytes', async () => {
|
||||
stubMinimalEnv({FLUXER_MEDIA_PROXY_UPLOAD_RELAY_SECRET_BASE64: Buffer.alloc(16, 7).toString('base64')});
|
||||
await expect(loadConfig()).rejects.toThrow(
|
||||
'FLUXER_MEDIA_PROXY_UPLOAD_RELAY_SECRET_BASE64 must decode to at least 32 bytes',
|
||||
);
|
||||
});
|
||||
|
||||
test('leaves the upload relay secret optional outside upload mode', async () => {
|
||||
stubMinimalEnv({FLUXER_MEDIA_PROXY_MODE: 'mp'});
|
||||
vi.stubEnv('FLUXER_MEDIA_PROXY_UPLOAD_RELAY_SECRET_BASE64', '');
|
||||
|
||||
const config = await loadConfig();
|
||||
|
||||
expect(config.services.media_proxy.upload_relay.secret_base64).toBe('');
|
||||
});
|
||||
|
||||
test('rejects a VAPID public key that is not a 65-byte uncompressed point', async () => {
|
||||
const {privateKey} = generateVapidPair();
|
||||
stubMinimalEnv({
|
||||
FLUXER_VAPID_PUBLIC_KEY: Buffer.alloc(64, 4).toString('base64url'),
|
||||
FLUXER_VAPID_PRIVATE_KEY: privateKey,
|
||||
});
|
||||
await expect(loadConfig()).rejects.toThrow(
|
||||
'FLUXER_VAPID_PUBLIC_KEY must be the base64url 65-byte uncompressed P-256 point',
|
||||
);
|
||||
});
|
||||
|
||||
test('rejects a VAPID private key that is not a 32-byte scalar', async () => {
|
||||
const {publicKey} = generateVapidPair();
|
||||
stubMinimalEnv({
|
||||
FLUXER_VAPID_PUBLIC_KEY: publicKey,
|
||||
FLUXER_VAPID_PRIVATE_KEY: Buffer.alloc(31, 9).toString('base64url'),
|
||||
});
|
||||
await expect(loadConfig()).rejects.toThrow('FLUXER_VAPID_PRIVATE_KEY must be the base64url 32-byte P-256 scalar');
|
||||
});
|
||||
|
||||
test('rejects a well formed VAPID scalar that does not derive the public point', async () => {
|
||||
const {publicKey} = generateVapidPair();
|
||||
const other = generateVapidPair();
|
||||
stubMinimalEnv({
|
||||
FLUXER_VAPID_PUBLIC_KEY: publicKey,
|
||||
FLUXER_VAPID_PRIVATE_KEY: other.privateKey,
|
||||
});
|
||||
await expect(loadConfig()).rejects.toThrow('FLUXER_VAPID_PRIVATE_KEY does not match FLUXER_VAPID_PUBLIC_KEY');
|
||||
});
|
||||
|
||||
test('accepts a generated VAPID pair', async () => {
|
||||
const pair = generateVapidPair();
|
||||
stubMinimalEnv({
|
||||
FLUXER_VAPID_PUBLIC_KEY: pair.publicKey,
|
||||
FLUXER_VAPID_PRIVATE_KEY: pair.privateKey,
|
||||
});
|
||||
|
||||
const config = await loadConfig();
|
||||
|
||||
expect(config.auth.vapid.public_key).toBe(pair.publicKey);
|
||||
expect(config.auth.vapid.private_key).toBe(pair.privateKey);
|
||||
});
|
||||
|
||||
test('requires a complete environment', async () => {
|
||||
vi.stubEnv('FLUXER_ENV', 'test');
|
||||
await expect(loadConfig()).rejects.toThrow();
|
||||
|
||||
@@ -31,8 +31,8 @@ describe('parseEnvValue', () => {
|
||||
test('parses JSON arrays', () => {
|
||||
expect(parseEnvValue('[1, 2, 3]')).toEqual([1, 2, 3]);
|
||||
});
|
||||
test('returns raw string for invalid JSON-like values', () => {
|
||||
expect(parseEnvValue('{not json}')).toBe('{not json}');
|
||||
test('rejects invalid JSON-like values', () => {
|
||||
expect(() => parseEnvValue('{not json}')).toThrow('must be valid JSON');
|
||||
});
|
||||
test('returns raw string for plain strings', () => {
|
||||
expect(parseEnvValue('hello')).toBe('hello');
|
||||
@@ -106,4 +106,53 @@ describe('buildNamedFluxerEnvOverrides', () => {
|
||||
integrations: {stripe: {prices: {monthly_usd: 'price_monthly_usd'}}},
|
||||
});
|
||||
});
|
||||
|
||||
test('maps the internal scheme and KV provider names', () => {
|
||||
expect(buildNamedFluxerEnvOverrides({FLUXER_INTERNAL_SCHEME: 'https', FLUXER_KV_PROVIDER: 'redis'})).toMatchObject({
|
||||
domain: {internal_scheme: 'https'},
|
||||
internal: {kv_provider: 'redis'},
|
||||
});
|
||||
});
|
||||
|
||||
test('rejects a non-integer value for an integer override', () => {
|
||||
expect(() => buildNamedFluxerEnvOverrides({FLUXER_API_PORT: '80a'})).toThrow(
|
||||
'FLUXER_API_PORT must be an integer, got "80a"',
|
||||
);
|
||||
});
|
||||
|
||||
test('leaves the default in place for a blank integer override', () => {
|
||||
expect(buildNamedFluxerEnvOverrides({FLUXER_API_PORT: ''})).toEqual({});
|
||||
});
|
||||
|
||||
test('the canonical name wins over its alias regardless of declaration order', () => {
|
||||
expect(
|
||||
buildNamedFluxerEnvOverrides({
|
||||
FLUXER_MEDIA_PROXY_ENDPOINT: 'http://alias',
|
||||
FLUXER_INTERNAL_MEDIA_PROXY_ENDPOINT: 'http://canonical',
|
||||
FLUXER_NATS_CORE_URL: 'nats://alias',
|
||||
FLUXER_NATS_URL: 'nats://canonical',
|
||||
}),
|
||||
).toMatchObject({
|
||||
internal: {media_proxy: 'http://canonical'},
|
||||
services: {nats: {core_url: 'nats://canonical'}},
|
||||
});
|
||||
});
|
||||
|
||||
test('an alias alone still applies', () => {
|
||||
expect(
|
||||
buildNamedFluxerEnvOverrides({
|
||||
FLUXER_MEDIA_PROXY_ENDPOINT: 'http://alias',
|
||||
FLUXER_NATS_CORE_URL: 'nats://alias',
|
||||
}),
|
||||
).toMatchObject({
|
||||
internal: {media_proxy: 'http://alias'},
|
||||
services: {nats: {core_url: 'nats://alias'}},
|
||||
});
|
||||
});
|
||||
|
||||
test('rejects malformed JSON for a JSON-shaped override', () => {
|
||||
expect(() => buildNamedFluxerEnvOverrides({FLUXER_LIVEKIT_DEFAULT_REGION: '{bad'})).toThrow(
|
||||
'FLUXER_LIVEKIT_DEFAULT_REGION must be valid JSON',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -11,7 +11,7 @@ const base: Pick<MasterConfig, 's3' | 's3_downloads'>['s3'] = {
|
||||
region: 'us-east-1',
|
||||
access_key_id: 'MAIN_KEY',
|
||||
secret_access_key: 'MAIN_SECRET',
|
||||
buckets: {cdn: 'cdn', uploads: 'uploads', downloads: 'downloads', reports: 'r', harvests: 'h', static: 's'},
|
||||
buckets: {cdn: 'cdn', uploads: 'uploads', downloads: 'downloads', reports: 'r', harvests: 'h'},
|
||||
};
|
||||
|
||||
describe('resolveDownloadsProvider', () => {
|
||||
|
||||
@@ -15,7 +15,8 @@ const NAMED_FLUXER_ENV_OVERRIDES: Record<string, NamedEnvOverride> = {
|
||||
FLUXER_ENV: {path: ['env']},
|
||||
FLUXER_BASE_DOMAIN: {path: ['domain', 'base_domain']},
|
||||
FLUXER_PUBLIC_SCHEME: {path: ['domain', 'public_scheme']},
|
||||
FLUXER_PUBLIC_PORT: {path: ['domain', 'public_port'], parse: parseEnvValue},
|
||||
FLUXER_INTERNAL_SCHEME: {path: ['domain', 'internal_scheme']},
|
||||
FLUXER_PUBLIC_PORT: {path: ['domain', 'public_port'], parse: parseInteger},
|
||||
FLUXER_STATIC_CDN_DOMAIN: {path: ['domain', 'static_cdn_domain']},
|
||||
FLUXER_INVITE_DOMAIN: {path: ['domain', 'invite_domain']},
|
||||
FLUXER_GIFT_DOMAIN: {path: ['domain', 'gift_domain']},
|
||||
@@ -26,34 +27,36 @@ const NAMED_FLUXER_ENV_OVERRIDES: Record<string, NamedEnvOverride> = {
|
||||
FLUXER_MEDIA_ENDPOINT: {path: ['endpoint_overrides', 'media']},
|
||||
FLUXER_STATIC_CDN_ENDPOINT: {path: ['endpoint_overrides', 'static_cdn']},
|
||||
FLUXER_ADMIN_ENDPOINT: {path: ['endpoint_overrides', 'admin']},
|
||||
FLUXER_DOCS_ENDPOINT: {path: ['endpoint_overrides', 'docs']},
|
||||
FLUXER_MARKETING_ENDPOINT: {path: ['endpoint_overrides', 'marketing']},
|
||||
FLUXER_INVITE_ENDPOINT: {path: ['endpoint_overrides', 'invite']},
|
||||
FLUXER_GIFT_ENDPOINT: {path: ['endpoint_overrides', 'gift']},
|
||||
FLUXER_TRUST_CLIENT_IP_HEADER: {path: ['proxy', 'trust_client_ip_header'], parse: parseEnvValue},
|
||||
FLUXER_CLIENT_IP_HEADER_NAME: {path: ['proxy', 'client_ip_header']},
|
||||
FLUXER_CASSANDRA_HOSTS: {path: ['database', 'cassandra', 'hosts'], parse: parseCsv},
|
||||
FLUXER_CASSANDRA_PORT: {path: ['database', 'cassandra', 'port'], parse: parseEnvValue},
|
||||
FLUXER_CASSANDRA_PORT: {path: ['database', 'cassandra', 'port'], parse: parseInteger},
|
||||
FLUXER_CASSANDRA_KEYSPACE: {path: ['database', 'cassandra', 'keyspace']},
|
||||
FLUXER_CASSANDRA_LOCAL_DC: {path: ['database', 'cassandra', 'local_dc']},
|
||||
FLUXER_CASSANDRA_USERNAME: {path: ['database', 'cassandra', 'username']},
|
||||
FLUXER_CASSANDRA_PASSWORD: {path: ['database', 'cassandra', 'password']},
|
||||
FLUXER_POSTGRES_URL: {path: ['database', 'postgres', 'url']},
|
||||
FLUXER_POSTGRES_HOST: {path: ['database', 'postgres', 'host']},
|
||||
FLUXER_POSTGRES_PORT: {path: ['database', 'postgres', 'port'], parse: parseEnvValue},
|
||||
FLUXER_POSTGRES_PORT: {path: ['database', 'postgres', 'port'], parse: parseInteger},
|
||||
FLUXER_POSTGRES_DATABASE: {path: ['database', 'postgres', 'database']},
|
||||
FLUXER_POSTGRES_USERNAME: {path: ['database', 'postgres', 'username']},
|
||||
FLUXER_POSTGRES_PASSWORD: {path: ['database', 'postgres', 'password']},
|
||||
FLUXER_POSTGRES_SSL: {path: ['database', 'postgres', 'ssl'], parse: parseEnvValue},
|
||||
FLUXER_POSTGRES_SSL_CA: {path: ['database', 'postgres', 'ssl_ca']},
|
||||
FLUXER_POSTGRES_MAX_CONNECTIONS: {path: ['database', 'postgres', 'max_connections'], parse: parseEnvValue},
|
||||
FLUXER_POSTGRES_MAX_CONNECTIONS: {path: ['database', 'postgres', 'max_connections'], parse: parseInteger},
|
||||
FLUXER_POSTGRES_KV_TABLE: {path: ['database', 'postgres', 'kv_table']},
|
||||
FLUXER_POSTGRES_PREPARED_STATEMENTS: {path: ['database', 'postgres', 'prepared_statements'], parse: parseEnvValue},
|
||||
FLUXER_DATABASE_BACKEND: {path: ['database', 'backend']},
|
||||
FLUXER_KV_URL: {path: ['internal', 'kv']},
|
||||
FLUXER_KV_PROVIDER: {path: ['internal', 'kv_provider']},
|
||||
FLUXER_KV_MODE: {path: ['internal', 'kv_mode']},
|
||||
FLUXER_INTERNAL_API_ENDPOINT: {path: ['internal', 'api']},
|
||||
FLUXER_INTERNAL_GATEWAY_ENDPOINT: {path: ['internal', 'gateway']},
|
||||
FLUXER_INTERNAL_MEDIA_PROXY_ENDPOINT: {path: ['internal', 'media_proxy']},
|
||||
FLUXER_MEDIA_PROXY_ENDPOINT: {path: ['internal', 'media_proxy']},
|
||||
FLUXER_S3_ENDPOINT: {path: ['s3', 'endpoint']},
|
||||
FLUXER_S3_PUBLIC_ENDPOINT: {path: ['s3', 'presigned_url_base']},
|
||||
FLUXER_S3_FORCE_PATH_STYLE: {path: ['s3', 'force_path_style'], parse: parseEnvValue},
|
||||
@@ -65,7 +68,6 @@ const NAMED_FLUXER_ENV_OVERRIDES: Record<string, NamedEnvOverride> = {
|
||||
FLUXER_S3_BUCKET_DOWNLOADS: {path: ['s3', 'buckets', 'downloads']},
|
||||
FLUXER_S3_BUCKET_REPORTS: {path: ['s3', 'buckets', 'reports']},
|
||||
FLUXER_S3_BUCKET_HARVESTS: {path: ['s3', 'buckets', 'harvests']},
|
||||
FLUXER_S3_BUCKET_STATIC: {path: ['s3', 'buckets', 'static']},
|
||||
FLUXER_S3_DOWNLOADS_ENDPOINT: {path: ['s3_downloads', 'endpoint']},
|
||||
FLUXER_S3_DOWNLOADS_PUBLIC_ENDPOINT: {path: ['s3_downloads', 'presigned_url_base']},
|
||||
FLUXER_S3_DOWNLOADS_FORCE_PATH_STYLE: {path: ['s3_downloads', 'force_path_style'], parse: parseEnvValue},
|
||||
@@ -73,13 +75,12 @@ const NAMED_FLUXER_ENV_OVERRIDES: Record<string, NamedEnvOverride> = {
|
||||
FLUXER_S3_DOWNLOADS_ACCESS_KEY_ID: {path: ['s3_downloads', 'access_key_id']},
|
||||
FLUXER_S3_DOWNLOADS_SECRET_ACCESS_KEY: {path: ['s3_downloads', 'secret_access_key']},
|
||||
FLUXER_NATS_URL: {path: ['services', 'nats', 'core_url']},
|
||||
FLUXER_NATS_CORE_URL: {path: ['services', 'nats', 'core_url']},
|
||||
FLUXER_NATS_JETSTREAM_URL: {path: ['services', 'nats', 'jetstream_url']},
|
||||
FLUXER_NATS_AUTH_TOKEN: {path: ['services', 'nats', 'auth_token']},
|
||||
FLUXER_API_PORT: {path: ['services', 'api', 'port'], parse: parseEnvValue},
|
||||
FLUXER_API_HEADERS_TIMEOUT_MS: {path: ['services', 'api', 'headers_timeout_ms'], parse: parseEnvValue},
|
||||
FLUXER_API_REQUEST_TIMEOUT_MS: {path: ['services', 'api', 'request_timeout_ms'], parse: parseEnvValue},
|
||||
FLUXER_API_MAX_INFLIGHT_REQUESTS: {path: ['services', 'api', 'max_inflight_requests'], parse: parseEnvValue},
|
||||
FLUXER_API_PORT: {path: ['services', 'api', 'port'], parse: parseInteger},
|
||||
FLUXER_API_HEADERS_TIMEOUT_MS: {path: ['services', 'api', 'headers_timeout_ms'], parse: parseInteger},
|
||||
FLUXER_API_REQUEST_TIMEOUT_MS: {path: ['services', 'api', 'request_timeout_ms'], parse: parseInteger},
|
||||
FLUXER_API_MAX_INFLIGHT_REQUESTS: {path: ['services', 'api', 'max_inflight_requests'], parse: parseInteger},
|
||||
FLUXER_API_IP_BAN_EXEMPT_IPS: {path: ['services', 'api', 'ip_ban_exempt_ips'], parse: parseCsv},
|
||||
FLUXER_API_DESKTOP_GITHUB_REDIRECT_COUNTRIES: {
|
||||
path: ['services', 'api', 'desktop_github_redirect_countries'],
|
||||
@@ -110,27 +111,27 @@ const NAMED_FLUXER_ENV_OVERRIDES: Record<string, NamedEnvOverride> = {
|
||||
},
|
||||
FLUXER_API_WORKER_VOICE_RECONCILIATION_INTERVAL_MS: {
|
||||
path: ['services', 'api', 'worker', 'voice_reconciliation', 'interval_ms'],
|
||||
parse: parseEnvValue,
|
||||
parse: parseInteger,
|
||||
},
|
||||
FLUXER_API_WORKER_VOICE_RECONCILIATION_STAGGER_DELAY_MS: {
|
||||
path: ['services', 'api', 'worker', 'voice_reconciliation', 'stagger_delay_ms'],
|
||||
parse: parseEnvValue,
|
||||
parse: parseInteger,
|
||||
},
|
||||
FLUXER_API_WORKER_VOICE_RECONCILIATION_LOCK_TTL_SECONDS: {
|
||||
path: ['services', 'api', 'worker', 'voice_reconciliation', 'lock_ttl_seconds'],
|
||||
parse: parseEnvValue,
|
||||
parse: parseInteger,
|
||||
},
|
||||
FLUXER_API_WORKER_VOICE_RECONCILIATION_CADENCE_TTL_SECONDS: {
|
||||
path: ['services', 'api', 'worker', 'voice_reconciliation', 'cadence_ttl_seconds'],
|
||||
parse: parseEnvValue,
|
||||
parse: parseInteger,
|
||||
},
|
||||
FLUXER_API_WORKER_VOICE_RECONCILIATION_GATEWAY_ONLY_GRACE_MS: {
|
||||
path: ['services', 'api', 'worker', 'voice_reconciliation', 'gateway_only_grace_ms'],
|
||||
parse: parseEnvValue,
|
||||
parse: parseInteger,
|
||||
},
|
||||
FLUXER_API_WORKER_VOICE_RECONCILIATION_LIVEKIT_ONLY_GRACE_MS: {
|
||||
path: ['services', 'api', 'worker', 'voice_reconciliation', 'livekit_only_grace_ms'],
|
||||
parse: parseEnvValue,
|
||||
parse: parseInteger,
|
||||
},
|
||||
FLUXER_API_WORKER_LANE_CONCURRENCY_OVERRIDES: {
|
||||
path: ['services', 'api', 'worker', 'lane_concurrency_overrides'],
|
||||
@@ -151,15 +152,15 @@ const NAMED_FLUXER_ENV_OVERRIDES: Record<string, NamedEnvOverride> = {
|
||||
},
|
||||
FLUXER_API_EMBEDS_CACHE_DEFAULT_TTL_SECONDS: {
|
||||
path: ['services', 'api', 'embeds', 'cache_default_ttl_seconds'],
|
||||
parse: parseEnvValue,
|
||||
parse: parseInteger,
|
||||
},
|
||||
FLUXER_API_EMBEDS_CACHE_MAX_TTL_SECONDS: {
|
||||
path: ['services', 'api', 'embeds', 'cache_max_ttl_seconds'],
|
||||
parse: parseEnvValue,
|
||||
parse: parseInteger,
|
||||
},
|
||||
FLUXER_API_EMBEDS_CACHE_MIN_TTL_SECONDS: {
|
||||
path: ['services', 'api', 'embeds', 'cache_min_ttl_seconds'],
|
||||
parse: parseEnvValue,
|
||||
parse: parseInteger,
|
||||
},
|
||||
FLUXER_API_EMBEDS_CACHE_RESPECT_REMOTE_TTL: {
|
||||
path: ['services', 'api', 'embeds', 'cache_respect_remote_ttl'],
|
||||
@@ -170,58 +171,58 @@ const NAMED_FLUXER_ENV_OVERRIDES: Record<string, NamedEnvOverride> = {
|
||||
parse: parseEnvValue,
|
||||
},
|
||||
FLUXER_MEDIA_PROXY_HOST: {path: ['services', 'media_proxy', 'host']},
|
||||
FLUXER_MEDIA_PROXY_PORT: {path: ['services', 'media_proxy', 'port'], parse: parseEnvValue},
|
||||
FLUXER_MEDIA_PROXY_PORT: {path: ['services', 'media_proxy', 'port'], parse: parseInteger},
|
||||
FLUXER_MEDIA_PROXY_SECRET_KEY: {path: ['services', 'media_proxy', 'secret_key']},
|
||||
FLUXER_MEDIA_PROXY_MODE: {path: ['services', 'media_proxy', 'mode']},
|
||||
FLUXER_MEDIA_PROXY_UPLOAD_RELAY_ENDPOINT: {path: ['services', 'media_proxy', 'upload_relay', 'endpoint']},
|
||||
FLUXER_MEDIA_PROXY_UPLOAD_RELAY_SECRET_BASE64: {path: ['services', 'media_proxy', 'upload_relay', 'secret_base64']},
|
||||
FLUXER_MEDIA_PROXY_UPLOAD_RELAY_MAX_BODY_BYTES: {
|
||||
path: ['services', 'media_proxy', 'upload_relay', 'max_body_bytes'],
|
||||
parse: parseEnvValue,
|
||||
parse: parseInteger,
|
||||
},
|
||||
FLUXER_MEDIA_PROXY_UPLOAD_RELAY_TOKEN_TTL_SECS: {
|
||||
path: ['services', 'media_proxy', 'upload_relay', 'token_ttl_secs'],
|
||||
parse: parseEnvValue,
|
||||
parse: parseInteger,
|
||||
},
|
||||
FLUXER_MEDIA_PROXY_UPLOAD_RELAY_KEEP_DIRECT_COUNTRIES: {
|
||||
path: ['services', 'media_proxy', 'upload_relay', 'keep_direct_countries'],
|
||||
parse: parseCsv,
|
||||
},
|
||||
FLUXER_ADMIN_PORT: {path: ['services', 'admin', 'port'], parse: parseEnvValue},
|
||||
FLUXER_ADMIN_PORT: {path: ['services', 'admin', 'port'], parse: parseInteger},
|
||||
FLUXER_ADMIN_BASE_PATH: {path: ['services', 'admin', 'base_path']},
|
||||
FLUXER_ADMIN_SECRET_KEY_BASE: {path: ['services', 'admin', 'secret_key_base']},
|
||||
FLUXER_ADMIN_OAUTH_CLIENT_SECRET: {path: ['services', 'admin', 'oauth_client_secret']},
|
||||
FLUXER_MARKETING_HOST: {path: ['services', 'marketing', 'host']},
|
||||
FLUXER_MARKETING_PORT: {path: ['services', 'marketing', 'port'], parse: parseEnvValue},
|
||||
FLUXER_MARKETING_PORT: {path: ['services', 'marketing', 'port'], parse: parseInteger},
|
||||
FLUXER_MARKETING_BASE_PATH: {path: ['services', 'marketing', 'base_path']},
|
||||
FLUXER_MARKETING_SECRET_KEY_BASE: {path: ['services', 'marketing', 'secret_key_base']},
|
||||
FLUXER_APP_PROXY_PORT: {path: ['services', 'app_proxy', 'port'], parse: parseEnvValue},
|
||||
FLUXER_APP_PROXY_PORT: {path: ['services', 'app_proxy', 'port'], parse: parseInteger},
|
||||
FLUXER_STATIC_DIR: {path: ['services', 'app_proxy', 'assets_dir']},
|
||||
FLUXER_GATEWAY_PORT: {path: ['services', 'gateway', 'port'], parse: parseEnvValue},
|
||||
FLUXER_GATEWAY_PORT: {path: ['services', 'gateway', 'port'], parse: parseInteger},
|
||||
FLUXER_GATEWAY_ROLE: {path: ['services', 'gateway', 'gateway_role']},
|
||||
FLUXER_GATEWAY_MEDIA_PROXY_ENDPOINT: {path: ['services', 'gateway', 'media_proxy_endpoint']},
|
||||
FLUXER_GATEWAY_API_RPC_ENDPOINT: {path: ['services', 'gateway', 'api_rpc_endpoint']},
|
||||
FLUXER_GATEWAY_RPC_AUTH_TOKEN: {path: ['services', 'gateway', 'rpc_auth_token']},
|
||||
FLUXER_GATEWAY_PUSH_ENABLED: {path: ['services', 'gateway', 'push_enabled'], parse: parseEnvValue},
|
||||
FLUXER_GATEWAY_LOGGER_LEVEL: {path: ['services', 'gateway', 'logger_level']},
|
||||
FLUXER_GATEWAY_HTTP_FAILURE_THRESHOLD: {
|
||||
path: ['services', 'gateway', 'gateway_http_failure_threshold'],
|
||||
parse: parseEnvValue,
|
||||
parse: parseInteger,
|
||||
},
|
||||
FLUXER_GATEWAY_HTTP_RECOVERY_TIMEOUT_MS: {
|
||||
path: ['services', 'gateway', 'gateway_http_recovery_timeout_ms'],
|
||||
parse: parseEnvValue,
|
||||
parse: parseInteger,
|
||||
},
|
||||
FLUXER_GATEWAY_HTTP_RPC_MAX_CONCURRENCY: {
|
||||
path: ['services', 'gateway', 'gateway_http_rpc_max_concurrency'],
|
||||
parse: parseEnvValue,
|
||||
parse: parseInteger,
|
||||
},
|
||||
FLUXER_GATEWAY_NATS_RPC_MAX_HANDLERS: {
|
||||
path: ['services', 'gateway', 'gateway_nats_rpc_max_handlers'],
|
||||
parse: parseEnvValue,
|
||||
parse: parseInteger,
|
||||
},
|
||||
FLUXER_GATEWAY_SHUTDOWN_DRAIN_WAIT_MS: {
|
||||
path: ['services', 'gateway', 'shutdown_drain_wait_ms'],
|
||||
parse: parseEnvValue,
|
||||
parse: parseInteger,
|
||||
},
|
||||
FLUXER_GATEWAY_CLUSTER_ENABLED: {path: ['services', 'gateway', 'cluster_enabled'], parse: parseEnvValue},
|
||||
FLUXER_GATEWAY_CLUSTER_DISCOVERY_DNS_NAME: {path: ['services', 'gateway', 'cluster_discovery_dns_name']},
|
||||
@@ -230,7 +231,7 @@ const NAMED_FLUXER_ENV_OVERRIDES: Record<string, NamedEnvOverride> = {
|
||||
},
|
||||
FLUXER_GATEWAY_CLUSTER_DISCOVERY_POLL_INTERVAL_MS: {
|
||||
path: ['services', 'gateway', 'cluster_discovery_poll_interval_ms'],
|
||||
parse: parseEnvValue,
|
||||
parse: parseInteger,
|
||||
},
|
||||
FLUXER_SUDO_MODE_SECRET: {path: ['auth', 'sudo_mode_secret']},
|
||||
FLUXER_CONNECTION_INITIATION_SECRET: {path: ['auth', 'connection_initiation_secret']},
|
||||
@@ -258,7 +259,7 @@ const NAMED_FLUXER_ENV_OVERRIDES: Record<string, NamedEnvOverride> = {
|
||||
FLUXER_EMAIL_APP_BASE_URL: {path: ['integrations', 'email', 'app_base_url']},
|
||||
FLUXER_EMAIL_WEBHOOK_SECRET: {path: ['integrations', 'email', 'webhook_secret']},
|
||||
FLUXER_EMAIL_SMTP_HOST: {path: ['integrations', 'email', 'smtp', 'host']},
|
||||
FLUXER_EMAIL_SMTP_PORT: {path: ['integrations', 'email', 'smtp', 'port'], parse: parseEnvValue},
|
||||
FLUXER_EMAIL_SMTP_PORT: {path: ['integrations', 'email', 'smtp', 'port'], parse: parseInteger},
|
||||
FLUXER_EMAIL_SMTP_USERNAME: {path: ['integrations', 'email', 'smtp', 'username']},
|
||||
FLUXER_EMAIL_SMTP_PASSWORD: {path: ['integrations', 'email', 'smtp', 'password']},
|
||||
FLUXER_EMAIL_SMTP_SECURE: {path: ['integrations', 'email', 'smtp', 'secure'], parse: parseEnvValue},
|
||||
@@ -330,14 +331,14 @@ const NAMED_FLUXER_ENV_OVERRIDES: Record<string, NamedEnvOverride> = {
|
||||
FLUXER_NCMEC_REPORTER_EMAIL: {path: ['integrations', 'ncmec', 'reporter_email']},
|
||||
FLUXER_CLAMAV_ENABLED: {path: ['integrations', 'clamav', 'enabled'], parse: parseEnvValue},
|
||||
FLUXER_CLAMAV_HOST: {path: ['integrations', 'clamav', 'host']},
|
||||
FLUXER_CLAMAV_PORT: {path: ['integrations', 'clamav', 'port'], parse: parseEnvValue},
|
||||
FLUXER_CLAMAV_PORT: {path: ['integrations', 'clamav', 'port'], parse: parseInteger},
|
||||
FLUXER_CLAMAV_FAIL_OPEN: {path: ['integrations', 'clamav', 'fail_open'], parse: parseEnvValue},
|
||||
FLUXER_KLIPY_API_KEY: {path: ['integrations', 'klipy', 'api_key']},
|
||||
FLUXER_YOUTUBE_API_KEY: {path: ['integrations', 'youtube', 'api_key']},
|
||||
FLUXER_BUNNY_PURGE_ENABLED: {path: ['integrations', 'bunny', 'purge_enabled'], parse: parseEnvValue},
|
||||
FLUXER_BLOCKLIST_FEEDS_ENABLED: {path: ['integrations', 'blocklist_feeds', 'enabled'], parse: parseEnvValue},
|
||||
FLUXER_BUNNY_API_KEY: {path: ['integrations', 'bunny', 'api_key']},
|
||||
FLUXER_BUNNY_PULL_ZONE_ID: {path: ['integrations', 'bunny', 'pull_zone_id'], parse: parseEnvValue},
|
||||
FLUXER_BUNNY_PULL_ZONE_ID: {path: ['integrations', 'bunny', 'pull_zone_id'], parse: parseInteger},
|
||||
FLUXER_RISK_INTEGRATION_ENABLED: {path: ['integrations', 'risk_integration', 'enabled'], parse: parseEnvValue},
|
||||
FLUXER_RISK_IPINFO_API_KEY: {path: ['integrations', 'risk_integration', 'ipinfo_api_key']},
|
||||
FLUXER_ACCOUNT_POLICY_DSL: {
|
||||
@@ -354,7 +355,7 @@ const NAMED_FLUXER_ENV_OVERRIDES: Record<string, NamedEnvOverride> = {
|
||||
},
|
||||
FLUXER_RISK_TOR_REVERSE_DNS_TIMEOUT_MS: {
|
||||
path: ['integrations', 'risk_integration', 'tor', 'reverse_dns_timeout_ms'],
|
||||
parse: parseEnvValue,
|
||||
parse: parseInteger,
|
||||
},
|
||||
FLUXER_PUSH_APNS_ENABLED: {path: ['integrations', 'push', 'apns', 'enabled'], parse: parseEnvValue},
|
||||
FLUXER_PUSH_APNS_TEAM_ID: {path: ['integrations', 'push', 'apns', 'team_id']},
|
||||
@@ -401,18 +402,18 @@ const NAMED_FLUXER_ENV_OVERRIDES: Record<string, NamedEnvOverride> = {
|
||||
},
|
||||
FLUXER_ABUSE_DIRECT_CONTACT_SPAM_DISTINCT_TARGET_THRESHOLD: {
|
||||
path: ['instance', 'abuse_policy', 'direct_contact_spam', 'distinct_target_threshold'],
|
||||
parse: parseEnvValue,
|
||||
parse: parseInteger,
|
||||
},
|
||||
FLUXER_ABUSE_DIRECT_CONTACT_SPAM_TARGET_WINDOW_MS: {
|
||||
path: ['instance', 'abuse_policy', 'direct_contact_spam', 'target_window_ms'],
|
||||
parse: parseEnvValue,
|
||||
parse: parseInteger,
|
||||
},
|
||||
FLUXER_ABUSE_DIRECT_CONTACT_SPAM_ACTION: {
|
||||
path: ['instance', 'abuse_policy', 'direct_contact_spam', 'action'],
|
||||
},
|
||||
FLUXER_DISCOVERY_ENABLED: {path: ['discovery', 'enabled'], parse: parseEnvValue},
|
||||
FLUXER_DISCOVERY_MIN_MEMBER_COUNT: {path: ['discovery', 'min_member_count'], parse: parseEnvValue},
|
||||
FLUXER_DELETION_GRACE_PERIOD_HOURS: {path: ['deletion_grace_period_hours'], parse: parseEnvValue},
|
||||
FLUXER_DISCOVERY_MIN_MEMBER_COUNT: {path: ['discovery', 'min_member_count'], parse: parseInteger},
|
||||
FLUXER_DELETION_GRACE_PERIOD_HOURS: {path: ['deletion_grace_period_hours'], parse: parseInteger},
|
||||
FLUXER_RELAX_REGISTRATION_RATE_LIMITS: {path: ['dev', 'relax_registration_rate_limits'], parse: parseEnvValue},
|
||||
FLUXER_DISABLE_RATE_LIMITS: {path: ['dev', 'disable_rate_limits'], parse: parseEnvValue},
|
||||
FLUXER_TEST_MODE_ENABLED: {path: ['dev', 'test_mode_enabled'], parse: parseEnvValue},
|
||||
@@ -467,16 +468,27 @@ export function parseEnvValue(raw: string): unknown {
|
||||
if (/^-?\d+\.\d+$/.test(trimmed)) {
|
||||
return Number.parseFloat(trimmed);
|
||||
}
|
||||
if ((trimmed.startsWith('{') && trimmed.endsWith('}')) || (trimmed.startsWith('[') && trimmed.endsWith(']'))) {
|
||||
if (trimmed.startsWith('{') || trimmed.startsWith('[')) {
|
||||
try {
|
||||
return JSON.parse(trimmed);
|
||||
} catch {
|
||||
return raw;
|
||||
} catch (error) {
|
||||
throw new Error(`must be valid JSON: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
}
|
||||
return raw;
|
||||
}
|
||||
|
||||
function parseInteger(raw: string): number | undefined {
|
||||
const trimmed = raw.trim();
|
||||
if (trimmed.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
if (!/^-?\d+$/.test(trimmed)) {
|
||||
throw new Error(`must be an integer, got ${JSON.stringify(raw)}`);
|
||||
}
|
||||
return Number.parseInt(trimmed, 10);
|
||||
}
|
||||
|
||||
function parseCsv(raw: string): Array<string> {
|
||||
return raw
|
||||
.split(',')
|
||||
@@ -500,14 +512,29 @@ export function setNestedValue(target: ConfigContainer, keys: Array<ConfigPathKe
|
||||
setNestedValue(toChildContainer(getChildValue(target, first), rest[0]), rest, value);
|
||||
}
|
||||
|
||||
const NAMED_FLUXER_ENV_ALIASES: Record<string, string | undefined> = {
|
||||
FLUXER_INTERNAL_MEDIA_PROXY_ENDPOINT: 'FLUXER_MEDIA_PROXY_ENDPOINT',
|
||||
FLUXER_NATS_URL: 'FLUXER_NATS_CORE_URL',
|
||||
};
|
||||
|
||||
export function buildNamedFluxerEnvOverrides(env: NodeJS.ProcessEnv): ConfigObject {
|
||||
const overrides: ConfigObject = {};
|
||||
for (const [envKey, mapping] of Object.entries(NAMED_FLUXER_ENV_OVERRIDES)) {
|
||||
const raw = env[envKey];
|
||||
const alias = NAMED_FLUXER_ENV_ALIASES[envKey];
|
||||
const raw = env[envKey] ?? (alias === undefined ? undefined : env[alias]);
|
||||
if (raw === undefined) {
|
||||
continue;
|
||||
}
|
||||
setNestedValue(overrides, mapping.path, (mapping.parse ?? ((value: string) => value))(raw));
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = (mapping.parse ?? ((value: string) => value))(raw);
|
||||
} catch (error) {
|
||||
throw new Error(`${envKey} ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
if (parsed === undefined) {
|
||||
continue;
|
||||
}
|
||||
setNestedValue(overrides, mapping.path, parsed);
|
||||
}
|
||||
return overrides;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user