fix(config): carry the public port into derived endpoints (#2329)

This commit is contained in:
Hampus
2026-09-01 20:47:18 +02:00
committed by GitHub
parent a93f9dd0af
commit bc40073a02
21 changed files with 2037 additions and 55 deletions
Generated
+1
View File
@@ -1958,6 +1958,7 @@ dependencies = [
"base64",
"chrono",
"cookie",
"fluxer_common",
"hmac 0.13.0",
"maud",
"openapiv3",
+1
View File
@@ -11,6 +11,7 @@ axum = { version = "0.8.9", features = ["macros"] }
base64 = "0.22.1"
chrono = { version = "0.4", default-features = false, features = ["serde"] }
cookie = "0.18.1"
fluxer_common = { path = "../fluxer_common" }
hmac = "0.13.0"
maud = { version = "0.27.0", features = ["axum"] }
rand = "0.10"
+2 -1
View File
@@ -24,6 +24,7 @@ RUN TAILWIND_OXIDE_VERSION="4.2.1" \
COPY Cargo.lock Cargo.lock
COPY fluxer_admin fluxer_admin
COPY fluxer_common fluxer_common
COPY packages/fonts/manifest.json packages/fonts/manifest.json
COPY packages/fonts/NOTICE.md packages/fonts/NOTICE.md
COPY packages/fonts/LICENSE-IBM-PLEX.txt packages/fonts/LICENSE-IBM-PLEX.txt
@@ -31,7 +32,7 @@ COPY packages/fonts/files/FluxerSans packages/fonts/files/FluxerSans
COPY packages/fonts/files/FluxerMono packages/fonts/files/FluxerMono
RUN printf '%s\n' \
'[workspace]' \
'members = ["fluxer_admin"]' \
'members = ["fluxer_admin", "fluxer_common"]' \
'resolver = "2"' \
'' \
'[workspace.package]' \
+118 -20
View File
@@ -1,5 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use fluxer_common::config::normalize_public_endpoint_from_env;
use std::env;
const DEFAULT_ADMIN_OAUTH_CLIENT_ID: &str = "1234567890123456789";
@@ -42,14 +43,14 @@ pub enum RuntimeEnv {
impl AdminConfig {
pub fn from_env() -> Self {
let base_path = normalize_base_path(&read_env("FLUXER_ADMIN_BASE_PATH", ""));
let admin_endpoint = trim_trailing_slash(&read_env(
let admin_endpoint = normalize_public_endpoint_from_env(&trim_trailing_slash(&read_env(
"FLUXER_ADMIN_ENDPOINT",
"https://admin.fluxer.app",
));
let oauth_redirect_uri = read_env_preferred(
)));
let oauth_redirect_uri = normalize_public_endpoint_from_env(&read_env_preferred(
&["FLUXER_ADMIN_OAUTH_REDIRECT_URI"],
&format!("{admin_endpoint}/oauth2_callback"),
);
));
Self {
env: RuntimeEnv::from_env_value(&read_env("FLUXER_ENV", "development")),
@@ -63,17 +64,19 @@ impl AdminConfig {
"FLUXER_API_ENDPOINT",
"https://api.fluxer.app",
)),
media_endpoint: trim_trailing_slash(&read_env(
media_endpoint: normalize_public_endpoint_from_env(&trim_trailing_slash(&read_env(
"FLUXER_MEDIA_ENDPOINT",
"https://media.fluxer.app",
))),
static_cdn_endpoint: normalize_public_endpoint_from_env(&trim_trailing_slash(
&read_env("FLUXER_STATIC_CDN_ENDPOINT", ""),
)),
static_cdn_endpoint: trim_trailing_slash(&read_env("FLUXER_STATIC_CDN_ENDPOINT", "")),
admin_endpoint,
web_app_endpoint: trim_trailing_slash(&read_env(
web_app_endpoint: normalize_public_endpoint_from_env(&trim_trailing_slash(&read_env(
"FLUXER_APP_ENDPOINT",
"https://app.fluxer.app",
)),
))),
kv_url: read_env("FLUXER_KV_URL", ""),
oauth_client_id: read_env(
"FLUXER_ADMIN_OAUTH_CLIENT_ID",
@@ -166,6 +169,39 @@ pub(crate) fn read_bool_env(names: &[&str], fallback: bool) -> bool {
#[cfg(test)]
mod tests {
use super::*;
use std::sync::Mutex;
static ENV_LOCK: Mutex<()> = Mutex::new(());
const MANAGED_ENV: [&str; 11] = [
"FLUXER_ENV",
"FLUXER_ADMIN_HOST",
"FLUXER_ADMIN_PORT",
"FLUXER_ADMIN_ENDPOINT",
"FLUXER_ADMIN_OAUTH_CLIENT_ID",
"FLUXER_ADMIN_OAUTH_REDIRECT_URI",
"FLUXER_MASTER_CONFIG",
"FLUXER_APP_ENDPOINT",
"FLUXER_MEDIA_ENDPOINT",
"FLUXER_STATIC_CDN_ENDPOINT",
"FLUXER_BASE_DOMAIN",
];
fn config_from_env(vars: &[(&str, &str)]) -> AdminConfig {
let _guard = ENV_LOCK.lock().unwrap();
for name in MANAGED_ENV {
unsafe { env::remove_var(name) };
}
unsafe { env::remove_var("FLUXER_PUBLIC_PORT") };
for (name, value) in vars {
unsafe { env::set_var(name, value) };
}
let config = AdminConfig::from_env();
for (name, _) in vars {
unsafe { env::remove_var(name) };
}
config
}
#[test]
fn normalize_base_path_strips_trailing_slashes() {
@@ -287,18 +323,7 @@ mod tests {
#[test]
fn from_env_uses_defaults() {
for var in &[
"FLUXER_ENV",
"FLUXER_ADMIN_HOST",
"FLUXER_ADMIN_PORT",
"FLUXER_ADMIN_ENDPOINT",
"FLUXER_ADMIN_OAUTH_CLIENT_ID",
"FLUXER_ADMIN_OAUTH_REDIRECT_URI",
"FLUXER_MASTER_CONFIG",
] {
unsafe { env::remove_var(var) };
}
let config = AdminConfig::from_env();
let config = config_from_env(&[]);
assert_eq!(config.env, RuntimeEnv::Development);
assert_eq!(config.host, "0.0.0.0");
assert_eq!(config.port, 3020);
@@ -308,4 +333,77 @@ mod tests {
"https://admin.fluxer.app/oauth2_callback"
);
}
#[test]
fn a_non_default_public_port_reaches_the_public_endpoints() {
let config = config_from_env(&[
("FLUXER_BASE_DOMAIN", "fluxer.example"),
("FLUXER_PUBLIC_PORT", "19080"),
("FLUXER_ADMIN_ENDPOINT", "http://fluxer.example/admin"),
("FLUXER_APP_ENDPOINT", "http://fluxer.example:19080"),
("FLUXER_MEDIA_ENDPOINT", "http://fluxer.example/media"),
("FLUXER_STATIC_CDN_ENDPOINT", "https://cdn.example.net"),
(
"FLUXER_ADMIN_OAUTH_REDIRECT_URI",
"http://fluxer.example/admin/oauth2_callback",
),
]);
assert_eq!(config.admin_endpoint, "http://fluxer.example:19080/admin");
assert_eq!(config.media_endpoint, "http://fluxer.example:19080/media");
assert_eq!(config.web_app_endpoint, "http://fluxer.example:19080");
assert_eq!(config.static_cdn_endpoint, "https://cdn.example.net");
assert_eq!(
config.oauth_redirect_uri,
format!("{}/oauth2_callback", config.admin_endpoint)
);
}
#[test]
fn a_default_public_port_leaves_the_public_endpoints_alone() {
let config = config_from_env(&[
("FLUXER_BASE_DOMAIN", "fluxer.example"),
("FLUXER_PUBLIC_PORT", "443"),
("FLUXER_ADMIN_ENDPOINT", "https://fluxer.example/admin"),
("FLUXER_APP_ENDPOINT", "https://fluxer.example"),
("FLUXER_MEDIA_ENDPOINT", "https://fluxer.example/media"),
("FLUXER_STATIC_CDN_ENDPOINT", "https://fluxer.example"),
(
"FLUXER_ADMIN_OAUTH_REDIRECT_URI",
"https://fluxer.example/admin/oauth2_callback",
),
]);
assert_eq!(config.admin_endpoint, "https://fluxer.example/admin");
assert_eq!(config.media_endpoint, "https://fluxer.example/media");
assert_eq!(config.web_app_endpoint, "https://fluxer.example");
assert_eq!(config.static_cdn_endpoint, "https://fluxer.example");
assert_eq!(
config.oauth_redirect_uri,
"https://fluxer.example/admin/oauth2_callback"
);
}
#[test]
fn the_oauth_redirect_uri_matches_the_api_derived_admin_endpoint() {
let config = config_from_env(&[
("FLUXER_BASE_DOMAIN", "fluxer.example"),
("FLUXER_PUBLIC_PORT", "19080"),
("FLUXER_ADMIN_ENDPOINT", "http://fluxer.example/admin"),
(
"FLUXER_ADMIN_OAUTH_REDIRECT_URI",
"http://fluxer.example/admin/oauth2_callback",
),
]);
let api_admin_endpoint = fluxer_common::config::normalize_public_endpoint(
"http://fluxer.example/admin",
"fluxer.example",
Some(19080),
);
assert_eq!(
config.oauth_redirect_uri,
format!("{api_admin_endpoint}/oauth2_callback")
);
}
}
@@ -4,6 +4,15 @@ import {Config} from '../Config';
import {Logger} from '../Logger';
import {VoiceRepository} from './VoiceRepository';
export function resolveLivekitEndpoint(configuredUrl: string | undefined, apiPublicUrl: string): string {
if (configuredUrl) {
return configuredUrl;
}
const apiPublic = new URL(apiPublicUrl);
const protocol = apiPublic.protocol === 'https:' ? 'wss' : 'ws';
return `${protocol}://${apiPublic.host}/livekit`;
}
export class VoiceDataInitializer {
async initialize(): Promise<void> {
if (!Config.voice.enabled || !Config.voice.defaultRegion) {
@@ -19,7 +28,7 @@ export class VoiceDataInitializer {
try {
const repository = new VoiceRepository();
const serverId = `${defaultRegion.id}-server-1`;
const livekitEndpoint = this.resolveLivekitEndpoint();
const livekitEndpoint = resolveLivekitEndpoint(Config.voice.url, Config.endpoints.apiPublic);
const existingRegions = await repository.listRegions();
if (existingRegions.length === 0) {
Logger.info('[VoiceDataInitializer] Creating default voice region from config...');
@@ -91,12 +100,4 @@ export class VoiceDataInitializer {
Logger.error({error}, '[VoiceDataInitializer] Failed to initialise config-managed voice topology');
}
}
private resolveLivekitEndpoint(): string {
if (Config.voice.url) {
return Config.voice.url;
}
const protocol = new URL(Config.endpoints.apiPublic).protocol.slice(0, -1) === 'https' ? 'wss' : 'ws';
return `${protocol}://${new URL(Config.endpoints.apiPublic).hostname}/livekit`;
}
}
@@ -0,0 +1,48 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {describe, expect, it} from 'vitest';
import {resolveLivekitEndpoint} from '../VoiceDataInitializer';
describe('resolveLivekitEndpoint', () => {
it('keeps the public port when the instance does not serve on the default port', () => {
expect(resolveLivekitEndpoint(undefined, 'http://inplace.localhost:19480/api')).toBe(
'ws://inplace.localhost:19480/livekit',
);
expect(resolveLivekitEndpoint('', 'https://fluxer.example.com:8443/api')).toBe(
'wss://fluxer.example.com:8443/livekit',
);
});
it('omits the port when the public endpoint uses the default port for its scheme', () => {
expect(resolveLivekitEndpoint(undefined, 'https://fluxer.example.com/api')).toBe(
'wss://fluxer.example.com/livekit',
);
expect(resolveLivekitEndpoint(undefined, 'https://fluxer.example.com:443/api')).toBe(
'wss://fluxer.example.com/livekit',
);
expect(resolveLivekitEndpoint(undefined, 'http://fluxer.example.com:80/api')).toBe(
'ws://fluxer.example.com/livekit',
);
});
it('maps https to wss and http to ws', () => {
expect(resolveLivekitEndpoint(undefined, 'https://fluxer.example.com/api')).toBe(
'wss://fluxer.example.com/livekit',
);
expect(resolveLivekitEndpoint(undefined, 'http://fluxer.example.com/api')).toBe('ws://fluxer.example.com/livekit');
});
it('brackets ipv6 literals so the derived endpoint stays parseable', () => {
expect(resolveLivekitEndpoint(undefined, 'http://[::1]:19480/api')).toBe('ws://[::1]:19480/livekit');
expect(resolveLivekitEndpoint(undefined, 'https://[2001:db8::1]/api')).toBe('wss://[2001:db8::1]/livekit');
const derived = new URL(resolveLivekitEndpoint(undefined, 'http://[::1]:19480/api'));
expect(derived.host).toBe('[::1]:19480');
expect(derived.pathname).toBe('/livekit');
});
it('returns the configured voice url unchanged when one is set', () => {
expect(resolveLivekitEndpoint('wss://voice.example.com', 'http://inplace.localhost:19480/api')).toBe(
'wss://voice.example.com',
);
});
});
+154 -2
View File
@@ -2,6 +2,7 @@
use crate::config::AppProxyConfig;
use crate::discovery_cache::DiscoveryResponse;
use reqwest::Url;
use serde::Serialize;
#[derive(Serialize)]
@@ -43,11 +44,14 @@ pub fn build_bootstrap_script(
geoip: &serde_json::Value,
nonce: &str,
) -> String {
let api_public_endpoint =
api_public_endpoint(config.bootstrap_api_public_endpoint.as_deref(), discovery);
let payload = BootstrapPayload {
config: BootstrapConfig {
release_channel: config.release_channel.as_str(),
bootstrap_api_endpoint: &config.bootstrap_api_endpoint,
bootstrap_api_public_endpoint: config.bootstrap_api_public_endpoint.as_deref(),
bootstrap_api_public_endpoint: api_public_endpoint,
},
instance: &discovery.data,
geoip,
@@ -56,7 +60,7 @@ pub fn build_bootstrap_script(
let legacy = LegacyConfig {
release_channel: config.release_channel.as_str(),
bootstrap_api_endpoint: &config.bootstrap_api_endpoint,
bootstrap_api_public_endpoint: config.bootstrap_api_public_endpoint.as_deref(),
bootstrap_api_public_endpoint: api_public_endpoint,
};
let bootstrap_json = escape_json_for_script(&serde_json::to_string(&payload).unwrap());
@@ -67,6 +71,50 @@ pub fn build_bootstrap_script(
)
}
fn api_public_endpoint<'a>(
configured: Option<&'a str>,
discovery: &'a DiscoveryResponse,
) -> Option<&'a str> {
let configured = configured?;
let Some(discovered) = discovered_api_public(discovery) else {
return Some(configured);
};
if has_explicit_port(configured) || !has_explicit_port(discovered) {
return Some(configured);
}
let (Ok(configured_url), Ok(discovered_url)) = (Url::parse(configured), Url::parse(discovered))
else {
return Some(configured);
};
if configured_url.scheme() != discovered_url.scheme()
|| configured_url.host_str() != discovered_url.host_str()
|| configured_url.path() != discovered_url.path()
{
return Some(configured);
}
Some(discovered)
}
fn discovered_api_public(discovery: &DiscoveryResponse) -> Option<&str> {
discovery
.data
.get("endpoints")
.and_then(|endpoints| endpoints.get("api_public"))
.and_then(serde_json::Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
}
fn has_explicit_port(url: &str) -> bool {
let Some(scheme_end) = url.find("://") else {
return false;
};
let rest = &url[scheme_end + 3..];
let authority_end = rest.find(['/', '\\', '?', '#']).unwrap_or(rest.len());
let host = rest[..authority_end].rsplit('@').next().unwrap_or_default();
host[host.rfind(']').map_or(0, |index| index + 1)..].contains(':')
}
const MEDIA_PRECONNECT_TAG: &str = r#"<link rel="preconnect" href="{{MEDIA_ENDPOINT}}">"#;
const STATIC_PRECONNECT_TAGS: [&str; 2] = [
r#"<link rel="preconnect" href="{{STATIC_CDN_ENDPOINT}}">"#,
@@ -411,4 +459,108 @@ mod tests {
let json = serde_json::to_string(&payload).unwrap();
assert!(!json.contains("bootstrapApiPublicEndpoint"));
}
fn discovery_offering(api_public: &str) -> DiscoveryResponse {
DiscoveryResponse {
data: serde_json::json!({"endpoints": {"api_public": api_public}}),
}
}
#[test]
fn the_discovered_port_repairs_a_portless_configured_endpoint() {
let discovery = discovery_offering("https://chat.example.test:8443/api");
assert_eq!(
api_public_endpoint(Some("https://chat.example.test/api"), &discovery),
Some("https://chat.example.test:8443/api")
);
}
#[test]
fn a_configured_endpoint_that_already_carries_a_port_is_left_alone() {
let discovery = discovery_offering("https://chat.example.test:8443/api");
assert_eq!(
api_public_endpoint(Some("https://chat.example.test:9443/api"), &discovery),
Some("https://chat.example.test:9443/api")
);
}
#[test]
fn a_discovered_endpoint_on_another_host_is_ignored() {
let discovery = discovery_offering("https://api.example.test:8443/api");
assert_eq!(
api_public_endpoint(Some("https://chat.example.test/api"), &discovery),
Some("https://chat.example.test/api")
);
}
#[test]
fn a_discovered_endpoint_on_another_path_is_ignored() {
let discovery = discovery_offering("https://chat.example.test:8443/v9/api");
assert_eq!(
api_public_endpoint(Some("https://chat.example.test/api"), &discovery),
Some("https://chat.example.test/api")
);
}
#[test]
fn a_discovered_endpoint_on_another_scheme_is_ignored() {
let discovery = discovery_offering("http://chat.example.test:8443/api");
assert_eq!(
api_public_endpoint(Some("https://chat.example.test/api"), &discovery),
Some("https://chat.example.test/api")
);
}
#[test]
fn a_discovery_document_without_a_snapshot_leaves_the_configured_endpoint_alone() {
let discovery = DiscoveryResponse {
data: serde_json::json!({}),
};
assert_eq!(
api_public_endpoint(Some("https://chat.example.test/api"), &discovery),
Some("https://chat.example.test/api")
);
}
#[test]
fn a_discovery_document_without_a_public_api_endpoint_changes_nothing() {
let discovery = DiscoveryResponse {
data: serde_json::json!({"endpoints": {"api_public": " "}}),
};
assert_eq!(
api_public_endpoint(Some("https://chat.example.test/api"), &discovery),
Some("https://chat.example.test/api")
);
}
#[test]
fn a_portless_discovered_endpoint_leaves_a_default_install_alone() {
let discovery = discovery_offering("https://chat.example.test/api");
assert_eq!(
api_public_endpoint(Some("https://chat.example.test/api"), &discovery),
Some("https://chat.example.test/api")
);
}
#[test]
fn an_unconfigured_public_endpoint_is_never_invented_from_discovery() {
let discovery = discovery_offering("https://chat.example.test:8443/api");
assert_eq!(api_public_endpoint(None, &discovery), None);
}
#[test]
fn the_boot_script_hands_the_repaired_endpoint_to_both_globals() {
let discovery = discovery_offering("https://chat.example.test:8443/api");
let mut config = AppProxyConfig::from_env();
config.bootstrap_api_public_endpoint = Some("https://chat.example.test/api".to_owned());
let script =
build_bootstrap_script(&config, &discovery, &serde_json::json!({}), "scriptnonce");
assert!(
script.contains(r#""bootstrapApiPublicEndpoint":"https://chat.example.test:8443/api""#)
);
assert!(script.contains(
r#""PUBLIC_BOOTSTRAP_API_PUBLIC_ENDPOINT":"https://chat.example.test:8443/api""#
));
assert!(!script.contains(r#""https://chat.example.test/api""#));
}
}
+84 -3
View File
@@ -200,9 +200,7 @@ impl AppProxyConfig {
env!("CARGO_PKG_VERSION"),
),
bootstrap_api_endpoint: cfg::read_env("PUBLIC_BOOTSTRAP_API_ENDPOINT", "/api"),
bootstrap_api_public_endpoint: cfg::non_empty_env(
"PUBLIC_BOOTSTRAP_API_PUBLIC_ENDPOINT",
),
bootstrap_api_public_endpoint: resolve_bootstrap_api_public_endpoint_from_env(),
csp: CspConfig::from_env(),
geoip_source,
geoip_s3_config,
@@ -275,6 +273,27 @@ fn resolve_postgres_prepared_statements_from_env() -> bool {
resolve_postgres_prepared_statements(|name| env::var(name).ok())
}
fn resolve_bootstrap_api_public_endpoint_from_env() -> Option<String> {
resolve_bootstrap_api_public_endpoint(|name| env::var(name).ok())
}
fn resolve_bootstrap_api_public_endpoint<F>(mut read_var: F) -> Option<String>
where
F: FnMut(&str) -> Option<String>,
{
let endpoint = read_var("PUBLIC_BOOTSTRAP_API_PUBLIC_ENDPOINT")
.map(|value| value.trim().to_owned())
.filter(|value| !value.is_empty())?;
let base_domain = read_var("FLUXER_BASE_DOMAIN").unwrap_or_default();
let public_port = read_var("FLUXER_PUBLIC_PORT").and_then(|port| port.trim().parse().ok());
Some(cfg::normalize_public_endpoint(
&endpoint,
&base_domain,
public_port,
))
}
fn resolve_time_freeze_enabled<F>(mut read_var: F) -> bool
where
F: FnMut(&str) -> Option<String>,
@@ -373,6 +392,68 @@ mod tests {
resolve_postgres_prepared_statements(|name| env.get(name).map(|value| value.to_string()))
}
fn resolve_bootstrap_endpoint_from_pairs(pairs: &[(&str, &str)]) -> Option<String> {
let env: HashMap<&str, &str> = pairs.iter().copied().collect();
resolve_bootstrap_api_public_endpoint(|name| env.get(name).map(|value| value.to_string()))
}
#[test]
fn a_non_default_public_port_reaches_the_boot_html_api_endpoint() {
assert_eq!(
resolve_bootstrap_endpoint_from_pairs(&[
(
"PUBLIC_BOOTSTRAP_API_PUBLIC_ENDPOINT",
"http://fluxer.example/api",
),
("FLUXER_BASE_DOMAIN", "fluxer.example"),
("FLUXER_PUBLIC_PORT", "19080"),
]),
Some("http://fluxer.example:19080/api".to_owned())
);
}
#[test]
fn a_default_public_port_leaves_the_boot_html_api_endpoint_alone() {
assert_eq!(
resolve_bootstrap_endpoint_from_pairs(&[
(
"PUBLIC_BOOTSTRAP_API_PUBLIC_ENDPOINT",
"https://fluxer.example/api",
),
("FLUXER_BASE_DOMAIN", "fluxer.example"),
("FLUXER_PUBLIC_PORT", "443"),
]),
Some("https://fluxer.example/api".to_owned())
);
}
#[test]
fn the_boot_html_api_endpoint_keeps_a_port_it_already_carries() {
assert_eq!(
resolve_bootstrap_endpoint_from_pairs(&[
(
"PUBLIC_BOOTSTRAP_API_PUBLIC_ENDPOINT",
"http://fluxer.example:19080/api",
),
("FLUXER_BASE_DOMAIN", "fluxer.example"),
("FLUXER_PUBLIC_PORT", "19080"),
]),
Some("http://fluxer.example:19080/api".to_owned())
);
}
#[test]
fn the_boot_html_api_endpoint_is_untouched_without_a_base_domain_and_port() {
assert_eq!(
resolve_bootstrap_endpoint_from_pairs(&[(
"PUBLIC_BOOTSTRAP_API_PUBLIC_ENDPOINT",
"http://fluxer.example/api",
)]),
Some("http://fluxer.example/api".to_owned())
);
assert_eq!(resolve_bootstrap_endpoint_from_pairs(&[]), None);
}
#[test]
fn csp_config_default_has_no_extra_sources() {
let c = CspConfig::default();
+298
View File
@@ -196,6 +196,62 @@ pub fn trim_trailing_slash(value: &str) -> String {
value.trim_end_matches('/').to_owned()
}
fn is_default_port(scheme: &str, port: u16) -> bool {
matches!(
(scheme, port),
("http", 80) | ("https", 443) | ("ws", 80) | ("wss", 443)
)
}
fn strip_trailing_dot(host: &str) -> &str {
host.strip_suffix('.').unwrap_or(host)
}
pub fn normalize_public_endpoint(url: &str, base_domain: &str, public_port: Option<u16>) -> String {
let domain = base_domain.trim().to_lowercase();
let domain = strip_trailing_dot(&domain);
let Some(port) = public_port.filter(|port| *port != 0) else {
return url.to_owned();
};
if domain.is_empty() {
return url.to_owned();
}
let Ok(parsed) = reqwest::Url::parse(url) else {
return url.to_owned();
};
let host = parsed.host_str().unwrap_or_default().to_lowercase();
if strip_trailing_dot(&host) != domain {
return url.to_owned();
}
if is_default_port(parsed.scheme(), port) {
return url.to_owned();
}
let Some(scheme_end) = url.find("://") else {
return url.to_owned();
};
let authority_start = scheme_end + 3;
if url[authority_start..].starts_with('/') {
return url.to_owned();
}
let authority_end = url[authority_start..]
.find(['/', '\\', '?', '#'])
.map_or(url.len(), |index| authority_start + index);
let authority = &url[authority_start..authority_end];
let host = authority.rsplit('@').next().unwrap_or_default();
if host[host.rfind(']').map_or(0, |index| index + 1)..].contains(':') {
return url.to_owned();
}
format!("{}:{port}{}", &url[..authority_end], &url[authority_end..])
}
pub fn normalize_public_endpoint_from_env(url: &str) -> String {
normalize_public_endpoint(
url,
&read_env("FLUXER_BASE_DOMAIN", ""),
non_empty_env("FLUXER_PUBLIC_PORT").and_then(|port| port.parse().ok()),
)
}
#[cfg(test)]
mod tests {
use super::*;
@@ -284,6 +340,248 @@ mod tests {
assert_eq!(normalize_base_path("/"), "");
}
#[test]
fn default_https_install_is_untouched() {
for url in [
"https://fluxer.example",
"https://fluxer.example/media",
"https://fluxer.example/admin/oauth2_callback",
"wss://fluxer.example/gateway",
] {
assert_eq!(
url,
normalize_public_endpoint(url, "fluxer.example", Some(443))
);
}
}
#[test]
fn default_http_install_is_untouched() {
for url in [
"http://fluxer.example",
"http://fluxer.example/media",
"ws://fluxer.example/gateway",
] {
assert_eq!(
url,
normalize_public_endpoint(url, "fluxer.example", Some(80))
);
}
}
#[test]
fn inserts_a_non_default_port_for_the_base_domain() {
assert_eq!(
"http://fluxer.example:19080/media",
normalize_public_endpoint("http://fluxer.example/media", "fluxer.example", Some(19080))
);
assert_eq!(
"http://fluxer.example:19080",
normalize_public_endpoint("http://fluxer.example", "fluxer.example", Some(19080))
);
assert_eq!(
"https://fluxer.example:8443/admin/oauth2_callback",
normalize_public_endpoint(
"https://fluxer.example/admin/oauth2_callback",
"fluxer.example",
Some(8443)
)
);
}
#[test]
fn a_default_port_for_the_urls_own_scheme_is_never_inserted() {
assert_eq!(
"ws://fluxer.example/gateway",
normalize_public_endpoint("ws://fluxer.example/gateway", "fluxer.example", Some(80))
);
assert_eq!(
"wss://fluxer.example/gateway",
normalize_public_endpoint("wss://fluxer.example/gateway", "fluxer.example", Some(443))
);
assert_eq!(
"http://fluxer.example:443/media",
normalize_public_endpoint("http://fluxer.example/media", "fluxer.example", Some(443))
);
assert_eq!(
"https://fluxer.example:80/media",
normalize_public_endpoint("https://fluxer.example/media", "fluxer.example", Some(80))
);
}
#[test]
fn another_host_is_never_touched() {
for url in [
"https://cdn.example.net/assets",
"https://media.example.net",
"http://api:8080",
"http://media-proxy:8080",
] {
assert_eq!(
url,
normalize_public_endpoint(url, "fluxer.example", Some(19080))
);
}
assert_eq!(
"https://sub.fluxer.example/media",
normalize_public_endpoint(
"https://sub.fluxer.example/media",
"fluxer.example",
Some(19080)
)
);
}
#[test]
fn an_explicit_port_is_never_touched() {
for url in [
"http://fluxer.example:19080/media",
"http://fluxer.example:8080/media",
"https://fluxer.example:443/media",
"http://fluxer.example:80/media",
"http://user:pass@fluxer.example:19080/media",
] {
assert_eq!(
url,
normalize_public_endpoint(url, "fluxer.example", Some(19080))
);
}
}
#[test]
fn is_idempotent() {
let once =
normalize_public_endpoint("http://fluxer.example/media", "fluxer.example", Some(19080));
let twice = normalize_public_endpoint(&once, "fluxer.example", Some(19080));
assert_eq!("http://fluxer.example:19080/media", once);
assert_eq!(once, twice);
}
#[test]
fn an_unset_port_leaves_every_url_alone() {
assert_eq!(
"http://fluxer.example/media",
normalize_public_endpoint("http://fluxer.example/media", "fluxer.example", None)
);
assert_eq!(
"http://fluxer.example/media",
normalize_public_endpoint("http://fluxer.example/media", "fluxer.example", Some(0))
);
}
#[test]
fn an_empty_base_domain_leaves_every_url_alone() {
assert_eq!(
"http://fluxer.example/media",
normalize_public_endpoint("http://fluxer.example/media", "", Some(19080))
);
assert_eq!(
"http://fluxer.example/media",
normalize_public_endpoint("http://fluxer.example/media", " ", Some(19080))
);
}
#[test]
fn a_url_that_does_not_parse_is_returned_unchanged() {
for url in ["", "/api", "fluxer.example/media", "not a url", "://x"] {
assert_eq!(
url,
normalize_public_endpoint(url, "fluxer.example", Some(19080))
);
}
}
#[test]
fn matches_the_host_case_insensitively_and_ignores_a_trailing_dot() {
assert_eq!(
"http://FLUXER.example:19080/Media",
normalize_public_endpoint("http://FLUXER.example/Media", "Fluxer.Example", Some(19080))
);
assert_eq!(
"http://fluxer.example.:19080/media",
normalize_public_endpoint(
"http://fluxer.example./media",
"fluxer.example",
Some(19080)
)
);
assert_eq!(
"http://fluxer.example:19080/media",
normalize_public_endpoint(
"http://fluxer.example/media",
"fluxer.example.",
Some(19080)
)
);
}
#[test]
fn preserves_path_query_fragment_and_trailing_slash() {
assert_eq!(
"http://fluxer.example:19080/",
normalize_public_endpoint("http://fluxer.example/", "fluxer.example", Some(19080))
);
assert_eq!(
"http://fluxer.example:19080?a=1",
normalize_public_endpoint("http://fluxer.example?a=1", "fluxer.example", Some(19080))
);
assert_eq!(
"http://fluxer.example:19080#top",
normalize_public_endpoint("http://fluxer.example#top", "fluxer.example", Some(19080))
);
assert_eq!(
"http://fluxer.example:19080/media/x.png?v=1#frag",
normalize_public_endpoint(
"http://fluxer.example/media/x.png?v=1#frag",
"fluxer.example",
Some(19080)
)
);
}
#[test]
fn keeps_credentials_and_ipv6_literals_intact() {
assert_eq!(
"http://user:pass@fluxer.example:19080/media",
normalize_public_endpoint(
"http://user:pass@fluxer.example/media",
"fluxer.example",
Some(19080)
)
);
assert_eq!(
"http://[::1]:19080/media",
normalize_public_endpoint("http://[::1]/media", "[::1]", Some(19080))
);
assert_eq!(
"http://[::1]:8080/media",
normalize_public_endpoint("http://[::1]:8080/media", "[::1]", Some(19080))
);
}
#[test]
fn matches_the_typescript_normalizer_on_the_shared_vectors() {
let raw = include_str!("testdata/public_endpoint_vectors.json");
let vectors: serde_json::Value = serde_json::from_str(raw).expect("vectors parse as json");
let vectors = vectors.as_array().expect("vectors are an array");
assert!(!vectors.is_empty());
for vector in vectors {
let url = vector["url"].as_str().expect("vector carries a url");
let base_domain = vector["base_domain"]
.as_str()
.expect("vector carries a base domain");
let public_port = vector["public_port"].as_u64().map(|port| port as u16);
let expected = vector["normalized"]
.as_str()
.expect("vector carries a normalized url");
assert_eq!(
expected,
normalize_public_endpoint(url, base_domain, public_port),
"vector {url} @ {base_domain} port {public_port:?}"
);
}
}
#[test]
fn trim_trailing_slash_works() {
assert_eq!(
+440
View File
@@ -0,0 +1,440 @@
[
{
"url": "https://fluxer.example",
"base_domain": "fluxer.example",
"public_port": 443,
"normalized": "https://fluxer.example"
},
{
"url": "https://fluxer.example/media",
"base_domain": "fluxer.example",
"public_port": 443,
"normalized": "https://fluxer.example/media"
},
{
"url": "https://fluxer.example/admin",
"base_domain": "fluxer.example",
"public_port": 443,
"normalized": "https://fluxer.example/admin"
},
{
"url": "https://fluxer.example/admin/oauth2_callback",
"base_domain": "fluxer.example",
"public_port": 443,
"normalized": "https://fluxer.example/admin/oauth2_callback"
},
{
"url": "wss://fluxer.example/gateway",
"base_domain": "fluxer.example",
"public_port": 443,
"normalized": "wss://fluxer.example/gateway"
},
{
"url": "http://fluxer.example",
"base_domain": "fluxer.example",
"public_port": 80,
"normalized": "http://fluxer.example"
},
{
"url": "http://fluxer.example/media",
"base_domain": "fluxer.example",
"public_port": 80,
"normalized": "http://fluxer.example/media"
},
{
"url": "ws://fluxer.example/gateway",
"base_domain": "fluxer.example",
"public_port": 80,
"normalized": "ws://fluxer.example/gateway"
},
{
"url": "http://fluxer.example",
"base_domain": "fluxer.example",
"public_port": 19080,
"normalized": "http://fluxer.example:19080"
},
{
"url": "http://fluxer.example/",
"base_domain": "fluxer.example",
"public_port": 19080,
"normalized": "http://fluxer.example:19080/"
},
{
"url": "http://fluxer.example/media",
"base_domain": "fluxer.example",
"public_port": 19080,
"normalized": "http://fluxer.example:19080/media"
},
{
"url": "http://fluxer.example/api",
"base_domain": "fluxer.example",
"public_port": 19080,
"normalized": "http://fluxer.example:19080/api"
},
{
"url": "http://fluxer.example/admin",
"base_domain": "fluxer.example",
"public_port": 19080,
"normalized": "http://fluxer.example:19080/admin"
},
{
"url": "http://fluxer.example/admin/oauth2_callback",
"base_domain": "fluxer.example",
"public_port": 19080,
"normalized": "http://fluxer.example:19080/admin/oauth2_callback"
},
{
"url": "ws://fluxer.example/gateway",
"base_domain": "fluxer.example",
"public_port": 19080,
"normalized": "ws://fluxer.example:19080/gateway"
},
{
"url": "https://fluxer.example/admin/oauth2_callback",
"base_domain": "fluxer.example",
"public_port": 8443,
"normalized": "https://fluxer.example:8443/admin/oauth2_callback"
},
{
"url": "wss://fluxer.example/gateway",
"base_domain": "fluxer.example",
"public_port": 8443,
"normalized": "wss://fluxer.example:8443/gateway"
},
{
"url": "https://fluxer.example",
"base_domain": "fluxer.example",
"public_port": 8443,
"normalized": "https://fluxer.example:8443"
},
{
"url": "http://fluxer.example/media",
"base_domain": "fluxer.example",
"public_port": 443,
"normalized": "http://fluxer.example:443/media"
},
{
"url": "https://fluxer.example/media",
"base_domain": "fluxer.example",
"public_port": 80,
"normalized": "https://fluxer.example:80/media"
},
{
"url": "ws://fluxer.example/gateway",
"base_domain": "fluxer.example",
"public_port": 443,
"normalized": "ws://fluxer.example:443/gateway"
},
{
"url": "wss://fluxer.example/gateway",
"base_domain": "fluxer.example",
"public_port": 80,
"normalized": "wss://fluxer.example:80/gateway"
},
{
"url": "http://fluxer.example/media",
"base_domain": "fluxer.example",
"public_port": null,
"normalized": "http://fluxer.example/media"
},
{
"url": "http://fluxer.example/media",
"base_domain": "fluxer.example",
"public_port": 0,
"normalized": "http://fluxer.example/media"
},
{
"url": "http://fluxer.example/media",
"base_domain": "",
"public_port": 19080,
"normalized": "http://fluxer.example/media"
},
{
"url": "http://fluxer.example/media",
"base_domain": " ",
"public_port": 19080,
"normalized": "http://fluxer.example/media"
},
{
"url": "http://fluxer.example:19080/media",
"base_domain": "fluxer.example",
"public_port": 19080,
"normalized": "http://fluxer.example:19080/media"
},
{
"url": "http://fluxer.example:8080/media",
"base_domain": "fluxer.example",
"public_port": 19080,
"normalized": "http://fluxer.example:8080/media"
},
{
"url": "https://fluxer.example:443/media",
"base_domain": "fluxer.example",
"public_port": 19080,
"normalized": "https://fluxer.example:443/media"
},
{
"url": "http://fluxer.example:80/media",
"base_domain": "fluxer.example",
"public_port": 19080,
"normalized": "http://fluxer.example:80/media"
},
{
"url": "https://fluxer.example:8443/admin/oauth2_callback",
"base_domain": "fluxer.example",
"public_port": 8443,
"normalized": "https://fluxer.example:8443/admin/oauth2_callback"
},
{
"url": "ws://fluxer.example:19080/gateway",
"base_domain": "fluxer.example",
"public_port": 19080,
"normalized": "ws://fluxer.example:19080/gateway"
},
{
"url": "http://fluxer.example:8080?a=1",
"base_domain": "fluxer.example",
"public_port": 19080,
"normalized": "http://fluxer.example:8080?a=1"
},
{
"url": "https://cdn.example.net/assets",
"base_domain": "fluxer.example",
"public_port": 19080,
"normalized": "https://cdn.example.net/assets"
},
{
"url": "https://media.example.net",
"base_domain": "fluxer.example",
"public_port": 19080,
"normalized": "https://media.example.net"
},
{
"url": "https://sub.fluxer.example/media",
"base_domain": "fluxer.example",
"public_port": 19080,
"normalized": "https://sub.fluxer.example/media"
},
{
"url": "https://fluxer.example/media",
"base_domain": "example",
"public_port": 19080,
"normalized": "https://fluxer.example/media"
},
{
"url": "https://fluxer.example.evil.net/media",
"base_domain": "fluxer.example",
"public_port": 19080,
"normalized": "https://fluxer.example.evil.net/media"
},
{
"url": "http://api:8080",
"base_domain": "fluxer.example",
"public_port": 19080,
"normalized": "http://api:8080"
},
{
"url": "http://media-proxy:8080",
"base_domain": "fluxer.example",
"public_port": 19080,
"normalized": "http://media-proxy:8080"
},
{
"url": "http://FLUXER.example/Media",
"base_domain": "Fluxer.Example",
"public_port": 19080,
"normalized": "http://FLUXER.example:19080/Media"
},
{
"url": "HTTPS://fluxer.example/media",
"base_domain": "fluxer.example",
"public_port": 8443,
"normalized": "HTTPS://fluxer.example:8443/media"
},
{
"url": "http://fluxer.example./media",
"base_domain": "fluxer.example",
"public_port": 19080,
"normalized": "http://fluxer.example.:19080/media"
},
{
"url": "http://fluxer.example/media",
"base_domain": "fluxer.example.",
"public_port": 19080,
"normalized": "http://fluxer.example:19080/media"
},
{
"url": "http://fluxer.example./media",
"base_domain": "fluxer.example.",
"public_port": 19080,
"normalized": "http://fluxer.example.:19080/media"
},
{
"url": "http://fluxer.example?a=1",
"base_domain": "fluxer.example",
"public_port": 19080,
"normalized": "http://fluxer.example:19080?a=1"
},
{
"url": "http://fluxer.example#top",
"base_domain": "fluxer.example",
"public_port": 19080,
"normalized": "http://fluxer.example:19080#top"
},
{
"url": "http://fluxer.example/media/x.png?v=1#frag",
"base_domain": "fluxer.example",
"public_port": 19080,
"normalized": "http://fluxer.example:19080/media/x.png?v=1#frag"
},
{
"url": "http://fluxer.example/media/",
"base_domain": "fluxer.example",
"public_port": 19080,
"normalized": "http://fluxer.example:19080/media/"
},
{
"url": "http://fluxer.example/a%20b",
"base_domain": "fluxer.example",
"public_port": 19080,
"normalized": "http://fluxer.example:19080/a%20b"
},
{
"url": "http://fluxer.example#/media",
"base_domain": "fluxer.example",
"public_port": 19080,
"normalized": "http://fluxer.example:19080#/media"
},
{
"url": "http://user:pass@fluxer.example/media",
"base_domain": "fluxer.example",
"public_port": 19080,
"normalized": "http://user:pass@fluxer.example:19080/media"
},
{
"url": "http://user:pass@fluxer.example:19080/media",
"base_domain": "fluxer.example",
"public_port": 19080,
"normalized": "http://user:pass@fluxer.example:19080/media"
},
{
"url": "http://user:p@ss@fluxer.example/media",
"base_domain": "fluxer.example",
"public_port": 19080,
"normalized": "http://user:p@ss@fluxer.example:19080/media"
},
{
"url": "http://[::1]/media",
"base_domain": "[::1]",
"public_port": 19080,
"normalized": "http://[::1]:19080/media"
},
{
"url": "http://[::1]:8080/media",
"base_domain": "[::1]",
"public_port": 19080,
"normalized": "http://[::1]:8080/media"
},
{
"url": "http://127.0.0.1/media",
"base_domain": "127.0.0.1",
"public_port": 19080,
"normalized": "http://127.0.0.1:19080/media"
},
{
"url": "http://[2001:db8::1]/media",
"base_domain": "[2001:db8::1]",
"public_port": 19080,
"normalized": "http://[2001:db8::1]:19080/media"
},
{
"url": "http://[2001:DB8::1]/media",
"base_domain": "[2001:db8::1]",
"public_port": 19080,
"normalized": "http://[2001:DB8::1]:19080/media"
},
{
"url": "",
"base_domain": "fluxer.example",
"public_port": 19080,
"normalized": ""
},
{
"url": "/api",
"base_domain": "fluxer.example",
"public_port": 19080,
"normalized": "/api"
},
{
"url": "fluxer.example/media",
"base_domain": "fluxer.example",
"public_port": 19080,
"normalized": "fluxer.example/media"
},
{
"url": "not a url",
"base_domain": "fluxer.example",
"public_port": 19080,
"normalized": "not a url"
},
{
"url": "://x",
"base_domain": "fluxer.example",
"public_port": 19080,
"normalized": "://x"
},
{
"url": "file:///x",
"base_domain": "fluxer.example",
"public_port": 19080,
"normalized": "file:///x"
},
{
"url": "mailto:a@fluxer.example",
"base_domain": "fluxer.example",
"public_port": 19080,
"normalized": "mailto:a@fluxer.example"
},
{
"url": "http://fluxer.example\\evil",
"base_domain": "fluxer.example",
"public_port": 19080,
"normalized": "http://fluxer.example:19080\\evil"
},
{
"url": "https://fluxer.example:/media",
"base_domain": "fluxer.example",
"public_port": 8443,
"normalized": "https://fluxer.example:/media"
},
{
"url": "//fluxer.example/media",
"base_domain": "fluxer.example",
"public_port": 19080,
"normalized": "//fluxer.example/media"
},
{
"url": "https:fluxer.example/media",
"base_domain": "fluxer.example",
"public_port": 8443,
"normalized": "https:fluxer.example/media"
},
{
"url": "https:/fluxer.example/media",
"base_domain": "fluxer.example",
"public_port": 8443,
"normalized": "https:/fluxer.example/media"
},
{
"url": "https:////fluxer.example/media",
"base_domain": "fluxer.example",
"public_port": 8443,
"normalized": "https:////fluxer.example/media"
},
{
"url": "android:apk-key-hash:9NrCFDLVR8_ObZC-EWzaRA",
"base_domain": "fluxer.example",
"public_port": 8443,
"normalized": "android:apk-key-hash:9NrCFDLVR8_ObZC-EWzaRA"
}
]
@@ -29,6 +29,7 @@ env_config() ->
#{
<<"env">> => env_binary("FLUXER_ENV", <<"development">>),
<<"internal">> => env_internal_config(),
<<"public">> => env_public_config(),
<<"proxy">> => env_proxy_config(),
<<"services">> => env_services_config(),
<<"auth">> => env_auth_config(),
@@ -42,6 +43,14 @@ env_internal_config() ->
<<"api">> => env_binary("FLUXER_INTERNAL_API_ENDPOINT", <<"http://127.0.0.1:8080">>)
}.
-spec env_public_config() -> map().
env_public_config() ->
#{
<<"base_domain">> => env_optional_binary("FLUXER_BASE_DOMAIN"),
<<"scheme">> => env_optional_binary("FLUXER_PUBLIC_SCHEME"),
<<"port">> => env_optional_binary("FLUXER_PUBLIC_PORT")
}.
-spec env_proxy_config() -> map().
env_proxy_config() ->
#{
@@ -185,12 +194,13 @@ build_config(RawConfig) ->
Apns = get_map(Push, [<<"apns">>]),
Fcm = get_map(Push, [<<"fcm">>]),
Proxy = get_map(RawConfig, [<<"proxy">>]),
Public = get_map(RawConfig, [<<"public">>]),
lists:foldl(fun maps:merge/2, #{}, [
build_core_config(Service, Internal, Nats, Proxy),
build_push_config(Service),
build_push_config(Service, Public),
build_sharding_config(Service),
build_http_config(Service),
build_cluster_config(Service),
build_cluster_config(Service, Public),
build_vapid_config(Vapid),
build_apns_config(Apns),
build_fcm_config(Fcm),
@@ -215,8 +225,8 @@ build_core_config(Service, Internal, Nats, Proxy) ->
)
}.
-spec build_push_config(map()) -> config().
build_push_config(Service) ->
-spec build_push_config(map(), map()) -> config().
build_push_config(Service, Public) ->
#{
push_enabled => get_bool(Service, <<"push_enabled">>, true),
push_user_guild_settings_cache_mb =>
@@ -228,8 +238,8 @@ build_push_config(Service) ->
push_badge_counts_cache_mb => get_int(Service, <<"push_badge_counts_cache_mb">>, 256),
push_badge_counts_cache_ttl_seconds =>
get_int(Service, <<"push_badge_counts_cache_ttl_seconds">>, 60),
static_cdn_endpoint => get_binary(
Service, <<"static_cdn_endpoint">>, <<"http://localhost:8088">>
static_cdn_endpoint => public_endpoint(
get_binary(Service, <<"static_cdn_endpoint">>, <<"http://localhost:8088">>), Public
),
push_dispatcher_max_inflight => get_int(
Service, <<"push_dispatcher_max_inflight">>, 16
@@ -276,8 +286,8 @@ build_http_config(Service) ->
get_int(Service, <<"gateway_http_cleanup_max_age_ms">>, 300000)
}.
-spec build_cluster_config(map()) -> config().
build_cluster_config(Service) ->
-spec build_cluster_config(map(), map()) -> config().
build_cluster_config(Service, Public) ->
#{
cluster_enabled => get_bool(Service, <<"cluster_enabled">>, false),
cluster_discovery_dns_name =>
@@ -290,7 +300,9 @@ build_cluster_config(Service) ->
get_int(Service, <<"cluster_discovery_poll_interval_ms">>, 5000),
cluster_static_peers =>
parse_node_list(get_optional_binary(Service, <<"cluster_static_peers">>)),
media_proxy_endpoint => get_optional_binary(Service, <<"media_proxy_endpoint">>)
media_proxy_endpoint => public_endpoint(
get_optional_binary(Service, <<"media_proxy_endpoint">>), Public
)
}.
-spec build_vapid_config(map()) -> config().
@@ -601,6 +613,17 @@ normalize_gateway_role(Value) when is_list(Value) ->
normalize_gateway_role(_) ->
all.
-spec public_endpoint(binary() | undefined, map()) -> binary() | undefined.
public_endpoint(undefined, _Public) ->
undefined;
public_endpoint(Url, Public) ->
gateway_public_endpoint:normalize(
Url,
get_optional_binary(Public, <<"base_domain">>),
get_optional_binary(Public, <<"scheme">>),
get_optional_int(Public, <<"port">>)
).
-spec optional_string(binary() | undefined) -> string() | undefined.
optional_string(undefined) -> undefined;
optional_string(Bin) when is_binary(Bin) -> binary_to_list(Bin).
@@ -0,0 +1,97 @@
%% SPDX-License-Identifier: AGPL-3.0-or-later
-module(gateway_public_endpoint).
-typing([eqwalizer]).
-export([normalize/4]).
-spec normalize(binary(), binary() | undefined, binary() | undefined, integer() | undefined) ->
binary().
normalize(Url, BaseDomain, _PublicScheme, PublicPort) when
is_binary(Url), is_binary(BaseDomain), is_integer(PublicPort), PublicPort > 0
->
case base_domain(BaseDomain) of
<<>> -> Url;
Domain -> insert_port(Url, Domain, PublicPort)
end;
normalize(Url, _BaseDomain, _PublicScheme, _PublicPort) ->
Url.
-spec base_domain(binary()) -> binary().
base_domain(BaseDomain) ->
case string:trim(BaseDomain) of
Trimmed when is_binary(Trimmed) -> trim_root_dot(Trimmed);
_ -> <<>>
end.
-spec insert_port(binary(), binary(), integer()) -> binary().
insert_port(Url, Domain, Port) ->
case binary:split(Url, <<"://">>) of
[Scheme, Rest] -> insert_authority_port(Url, Scheme, Rest, Domain, Port);
[_] -> Url
end.
-spec insert_authority_port(binary(), binary(), binary(), binary(), integer()) -> binary().
insert_authority_port(Url, _Scheme, <<"/", _/binary>>, _Domain, _Port) ->
Url;
insert_authority_port(Url, Scheme, Rest, Domain, Port) ->
{Authority, Tail} = split_authority(Rest),
case insertable(Scheme, Authority, Domain, Port) of
true -> join_authority_port(Scheme, Authority, Tail, Port);
false -> Url
end.
-spec insertable(binary(), binary(), binary(), integer()) -> boolean().
insertable(Scheme, Authority, Domain, Port) ->
Host = after_last(Authority, <<"@">>),
Default = default_port(Scheme),
is_integer(Default) andalso Port =/= Default andalso not has_port(Host) andalso
same_host(Host, Domain).
-spec join_authority_port(binary(), binary(), binary(), integer()) -> binary().
join_authority_port(Scheme, Authority, Tail, Port) ->
PortBin = integer_to_binary(Port),
<<Scheme/binary, "://", Authority/binary, ":", PortBin/binary, Tail/binary>>.
-spec split_authority(binary()) -> {binary(), binary()}.
split_authority(Rest) ->
case binary:match(Rest, [<<"/">>, <<"\\">>, <<"?">>, <<"#">>]) of
{Pos, _} -> split_binary(Rest, Pos);
nomatch -> {Rest, <<>>}
end.
-spec after_last(binary(), binary()) -> binary().
after_last(Bin, Separator) ->
case binary:split(Bin, Separator) of
[_, Rest] -> after_last(Rest, Separator);
[Tail] -> Tail
end.
-spec has_port(binary()) -> boolean().
has_port(Host) ->
binary:match(after_last(Host, <<"]">>), <<":">>) =/= nomatch.
-spec same_host(binary(), binary()) -> boolean().
same_host(<<"[", _/binary>> = Host, Domain) ->
string:equal(Host, Domain, true);
same_host(Host, Domain) ->
string:equal(trim_root_dot(Host), Domain, true).
-spec trim_root_dot(binary()) -> binary().
trim_root_dot(<<>>) ->
<<>>;
trim_root_dot(Host) ->
case binary:last(Host) of
$. -> binary:part(Host, 0, byte_size(Host) - 1);
_ -> Host
end.
-spec default_port(binary()) -> integer() | undefined.
default_port(Scheme) ->
case string:lowercase(Scheme) of
<<"http">> -> 80;
<<"https">> -> 443;
<<"ws">> -> 80;
<<"wss">> -> 443;
_ -> undefined
end.
@@ -150,6 +150,52 @@ optional_string_test() ->
?assertEqual("hello", fluxer_gateway_config:optional_string(<<"hello">>)),
?assertEqual("", fluxer_gateway_config:optional_string(<<>>)).
public_endpoints_env_non_default_port_test() ->
with_envs(
[
{"FLUXER_BASE_DOMAIN", "fluxer.example"},
{"FLUXER_PUBLIC_SCHEME", "https"},
{"FLUXER_PUBLIC_PORT", "8443"},
{"FLUXER_GATEWAY_MEDIA_PROXY_ENDPOINT", "https://fluxer.example/media"},
{"FLUXER_GATEWAY_STATIC_CDN_ENDPOINT", "https://fluxer.example"}
],
fun() ->
Config = fluxer_gateway_config:load(),
?assertEqual(
<<"https://fluxer.example:8443/media">>,
maps:get(media_proxy_endpoint, Config)
),
?assertEqual(
<<"https://fluxer.example:8443">>, maps:get(static_cdn_endpoint, Config)
)
end
).
public_endpoints_env_default_port_test() ->
with_envs(
[
{"FLUXER_BASE_DOMAIN", "fluxer.example"},
{"FLUXER_PUBLIC_SCHEME", "https"},
{"FLUXER_PUBLIC_PORT", "443"},
{"FLUXER_GATEWAY_MEDIA_PROXY_ENDPOINT", "https://fluxer.example/media"},
{"FLUXER_GATEWAY_STATIC_CDN_ENDPOINT", "https://cdn.othercdn.net"}
],
fun() ->
Config = fluxer_gateway_config:load(),
?assertEqual(
<<"https://fluxer.example/media">>, maps:get(media_proxy_endpoint, Config)
),
?assertEqual(
<<"https://cdn.othercdn.net">>, maps:get(static_cdn_endpoint, Config)
)
end
).
public_endpoints_defaults_test() ->
Config = fluxer_gateway_config:build_config(#{}),
?assertEqual(undefined, maps:get(media_proxy_endpoint, Config)),
?assertEqual(<<"http://localhost:8088">>, maps:get(static_cdn_endpoint, Config)).
with_envs([], Fun) ->
Fun();
with_envs([{Name, Value} | Rest], Fun) ->
@@ -0,0 +1,248 @@
%% SPDX-License-Identifier: AGPL-3.0-or-later
-module(gateway_public_endpoint_tests).
-typing([eqwalizer]).
-include_lib("eunit/include/eunit.hrl").
-define(VECTORS_PATH, "../fluxer_common/src/testdata/public_endpoint_vectors.json").
normalize_default_https_install_test() ->
?assertEqual(
<<"https://fluxer.example/media">>,
normalize(<<"https://fluxer.example/media">>, <<"fluxer.example">>, <<"https">>, 443)
),
?assertEqual(
<<"https://fluxer.example">>,
normalize(<<"https://fluxer.example">>, <<"fluxer.example">>, <<"https">>, 443)
),
?assertEqual(
<<"wss://fluxer.example/gateway">>,
normalize(<<"wss://fluxer.example/gateway">>, <<"fluxer.example">>, <<"https">>, 443)
).
normalize_default_http_install_test() ->
?assertEqual(
<<"http://fluxer.example/media">>,
normalize(<<"http://fluxer.example/media">>, <<"fluxer.example">>, <<"http">>, 80)
),
?assertEqual(
<<"ws://fluxer.example/gateway">>,
normalize(<<"ws://fluxer.example/gateway">>, <<"fluxer.example">>, <<"http">>, 80)
).
normalize_inserts_non_default_port_test() ->
?assertEqual(
<<"https://fluxer.example:8443/media">>,
normalize(<<"https://fluxer.example/media">>, <<"fluxer.example">>, <<"https">>, 8443)
),
?assertEqual(
<<"https://fluxer.example:8443">>,
normalize(<<"https://fluxer.example">>, <<"fluxer.example">>, <<"https">>, 8443)
),
?assertEqual(
<<"http://fluxer.example:8080/media">>,
normalize(<<"http://fluxer.example/media">>, <<"fluxer.example">>, <<"http">>, 8080)
),
?assertEqual(
<<"http://fluxer.example:443/media">>,
normalize(<<"http://fluxer.example/media">>, <<"fluxer.example">>, <<"http">>, 443)
).
normalize_preserves_url_parts_test() ->
?assertEqual(
<<"https://fluxer.example:8443/">>,
normalize(<<"https://fluxer.example/">>, <<"fluxer.example">>, <<"https">>, 8443)
),
?assertEqual(
<<"https://fluxer.example:8443/Media/Path?q=A%20b#Frag">>,
normalize(
<<"https://fluxer.example/Media/Path?q=A%20b#Frag">>,
<<"fluxer.example">>,
<<"https">>,
8443
)
),
?assertEqual(
<<"https://user@fluxer.example:8443/media">>,
normalize(
<<"https://user@fluxer.example/media">>, <<"fluxer.example">>, <<"https">>, 8443
)
).
normalize_host_matching_test() ->
?assertEqual(
<<"https://Fluxer.EXAMPLE.:8443/media">>,
normalize(<<"https://Fluxer.EXAMPLE./media">>, <<"fluxer.example">>, <<"https">>, 8443)
),
?assertEqual(
<<"https://cdn.othercdn.net/assets">>,
normalize(
<<"https://cdn.othercdn.net/assets">>, <<"fluxer.example">>, <<"https">>, 8443
)
),
?assertEqual(
<<"https://media.fluxer.example/media">>,
normalize(
<<"https://media.fluxer.example/media">>, <<"fluxer.example">>, <<"https">>, 8443
)
).
normalize_keeps_explicit_port_test() ->
?assertEqual(
<<"https://fluxer.example:9443/media">>,
normalize(
<<"https://fluxer.example:9443/media">>, <<"fluxer.example">>, <<"https">>, 8443
)
),
?assertEqual(
<<"https://fluxer.example:8443/media">>,
normalize(
<<"https://fluxer.example:8443/media">>, <<"fluxer.example">>, <<"https">>, 8443
)
),
?assertEqual(
<<"https://fluxer.example:/media">>,
normalize(<<"https://fluxer.example:/media">>, <<"fluxer.example">>, <<"https">>, 8443)
).
normalize_missing_inputs_test() ->
?assertEqual(
<<"https://fluxer.example/media">>,
normalize(
<<"https://fluxer.example/media">>, <<"fluxer.example">>, <<"https">>, undefined
)
),
?assertEqual(
<<"https://fluxer.example/media">>,
normalize(<<"https://fluxer.example/media">>, undefined, <<"https">>, 8443)
),
?assertEqual(
<<"https://fluxer.example/media">>,
normalize(<<"https://fluxer.example/media">>, <<>>, <<"https">>, 8443)
).
normalize_unparsable_url_test() ->
?assertEqual(
<<"not a url">>,
normalize(<<"not a url">>, <<"fluxer.example">>, <<"https">>, 8443)
),
?assertEqual(<<>>, normalize(<<>>, <<"fluxer.example">>, <<"https">>, 8443)),
?assertEqual(
<<"//fluxer.example/media">>,
normalize(<<"//fluxer.example/media">>, <<"fluxer.example">>, <<"https">>, 8443)
).
normalize_ipv6_host_test() ->
?assertEqual(
<<"http://[::1]:19080/media">>,
normalize(<<"http://[::1]/media">>, <<"[::1]">>, <<"http">>, 19080)
),
?assertEqual(
<<"http://[2001:DB8::1]:19080/media">>,
normalize(<<"http://[2001:DB8::1]/media">>, <<"[2001:db8::1]">>, <<"http">>, 19080)
),
?assertEqual(
<<"http://[::1]:8080/media">>,
normalize(<<"http://[::1]:8080/media">>, <<"[::1]">>, <<"http">>, 19080)
).
normalize_userinfo_at_sign_test() ->
?assertEqual(
<<"http://user:p@ss@fluxer.example:19080/media">>,
normalize(
<<"http://user:p@ss@fluxer.example/media">>, <<"fluxer.example">>, <<"http">>, 19080
)
),
?assertEqual(
<<"http://user:p@ss@fluxer.example:19080/media">>,
normalize(
<<"http://user:p@ss@fluxer.example:19080/media">>,
<<"fluxer.example">>,
<<"http">>,
19080
)
).
normalize_backslash_authority_test() ->
?assertEqual(
<<"http://fluxer.example:19080\\evil">>,
normalize(<<"http://fluxer.example\\evil">>, <<"fluxer.example">>, <<"http">>, 19080)
).
normalize_zero_port_test() ->
?assertEqual(
<<"http://fluxer.example/media">>,
normalize(<<"http://fluxer.example/media">>, <<"fluxer.example">>, <<"http">>, 0)
).
normalize_single_root_dot_test() ->
?assertEqual(
<<"http://fluxer.example.:19080/media">>,
normalize(<<"http://fluxer.example./media">>, <<"fluxer.example.">>, <<"http">>, 19080)
),
?assertEqual(
<<"http://fluxer.example../media">>,
normalize(<<"http://fluxer.example../media">>, <<"fluxer.example">>, <<"http">>, 19080)
),
?assertEqual(
<<"http://fluxer.example/media">>,
normalize(<<"http://fluxer.example/media">>, <<"fluxer.example..">>, <<"http">>, 19080)
).
normalize_unsupported_scheme_test() ->
?assertEqual(
<<"file:///media">>,
normalize(<<"file:///media">>, <<"fluxer.example">>, <<"http">>, 19080)
),
?assertEqual(
<<"mailto:a@fluxer.example">>,
normalize(<<"mailto:a@fluxer.example">>, <<"fluxer.example">>, <<"http">>, 19080)
).
normalize_matches_shared_vectors_test() ->
Vectors = read_vectors(),
?assertMatch([_ | _], Vectors),
lists:foreach(fun run_vector/1, Vectors).
read_vectors() ->
case file:read_file(?VECTORS_PATH) of
{ok, Contents} ->
decode_vectors(Contents);
{error, Reason} ->
erlang:error({public_endpoint_vectors_unreadable, ?VECTORS_PATH, Reason})
end.
decode_vectors(Contents) ->
case json:decode(Contents) of
[_ | _] = Vectors -> Vectors;
_ -> erlang:error({public_endpoint_vectors_empty, ?VECTORS_PATH})
end.
run_vector(
#{<<"url">> := Url, <<"base_domain">> := BaseDomain, <<"normalized">> := Expected} = Vector
) when is_binary(Url), is_binary(BaseDomain), is_binary(Expected) ->
Port = vector_port(Vector),
?assertEqual(
{Url, BaseDomain, Port, Expected},
{Url, BaseDomain, Port, normalize(Url, BaseDomain, undefined, Port)}
);
run_vector(Vector) ->
erlang:error({public_endpoint_vector_malformed, Vector}).
vector_port(#{<<"public_port">> := null}) ->
undefined;
vector_port(#{<<"public_port">> := Port}) when is_integer(Port) ->
Port;
vector_port(Vector) ->
erlang:error({public_endpoint_vector_malformed, Vector}).
normalize_is_idempotent_test() ->
Once = normalize(
<<"https://fluxer.example/media">>, <<"fluxer.example">>, <<"https">>, 8443
),
Twice = normalize(Once, <<"fluxer.example">>, <<"https">>, 8443),
?assertEqual(<<"https://fluxer.example:8443/media">>, Once),
?assertEqual(Once, Twice).
normalize(Url, BaseDomain, Scheme, Port) ->
gateway_public_endpoint:normalize(Url, BaseDomain, Scheme, Port).
+54 -1
View File
@@ -39,7 +39,9 @@ impl MediaProxyUrlBuilder {
anyhow::bail!("gifs shard requires FLUXER_MEDIA_PROXY_SECRET_KEY");
}
let endpoint = endpoint.trim_end_matches('/').to_owned();
let endpoint = fluxer_common::config::normalize_public_endpoint_from_env(
endpoint.trim_end_matches('/'),
);
let endpoint_host = Url::parse(&endpoint)
.ok()
.and_then(|parsed| parsed.host_str().map(ToOwned::to_owned));
@@ -86,6 +88,8 @@ mod tests {
"FLUXER_MEDIA_ENDPOINT",
"FLUXER_MEDIA_PROXY_ENDPOINT",
"FLUXER_MEDIA_PROXY_SECRET_KEY",
"FLUXER_BASE_DOMAIN",
"FLUXER_PUBLIC_PORT",
];
let saved = keys
.iter()
@@ -187,6 +191,55 @@ mod tests {
)
}
#[test]
fn from_env_inserts_a_non_default_public_port() -> anyhow::Result<()> {
with_media_proxy_env(
&[
(
"FLUXER_MEDIA_PROXY_PUBLIC_ENDPOINT",
Some("http://fluxer.example/media"),
),
("FLUXER_MEDIA_PROXY_SECRET_KEY", Some("secret")),
("FLUXER_BASE_DOMAIN", Some("fluxer.example")),
("FLUXER_PUBLIC_PORT", Some("19080")),
],
|| {
let builder = MediaProxyUrlBuilder::from_env()?;
assert_eq!(builder.endpoint, "http://fluxer.example:19080/media");
assert_eq!(builder.endpoint_host.as_deref(), Some("fluxer.example"));
assert!(
builder
.external_proxy_url("https://img.example.net/a.webp")
.expect("proxy url")
.starts_with("http://fluxer.example:19080/media/external/")
);
Ok(())
},
)
}
#[test]
fn from_env_leaves_a_default_public_port_alone() -> anyhow::Result<()> {
with_media_proxy_env(
&[
(
"FLUXER_MEDIA_PROXY_PUBLIC_ENDPOINT",
Some("https://fluxer.example/media"),
),
("FLUXER_MEDIA_PROXY_SECRET_KEY", Some("secret")),
("FLUXER_BASE_DOMAIN", Some("fluxer.example")),
("FLUXER_PUBLIC_PORT", Some("443")),
],
|| {
let builder = MediaProxyUrlBuilder::from_env()?;
assert_eq!(builder.endpoint, "https://fluxer.example/media");
Ok(())
},
)
}
#[test]
fn from_env_rejects_internal_proxy_endpoint_without_public_endpoint() -> anyhow::Result<()> {
with_media_proxy_env(
+83 -4
View File
@@ -40,10 +40,13 @@ impl UnfurlShard {
.filter(|v| !v.is_empty());
let media_proxy_public_endpoint = std::env::var("FLUXER_MEDIA_PROXY_PUBLIC_ENDPOINT")
.ok()
.filter(|v| !v.is_empty());
let static_cdn_endpoint = std::env::var("FLUXER_UNFURL_STATIC_CDN_ENDPOINT")
.or_else(|_| std::env::var("FLUXER_STATIC_CDN_ENDPOINT"))
.unwrap_or_default();
.filter(|v| !v.is_empty())
.map(|v| fluxer_common::config::normalize_public_endpoint_from_env(&v));
let static_cdn_endpoint = fluxer_common::config::normalize_public_endpoint_from_env(
&std::env::var("FLUXER_UNFURL_STATIC_CDN_ENDPOINT")
.or_else(|_| std::env::var("FLUXER_STATIC_CDN_ENDPOINT"))
.unwrap_or_default(),
);
let (media_proxy_endpoint, media_proxy_secret) = match (
media_proxy_endpoint,
media_proxy_secret,
@@ -301,9 +304,85 @@ impl ShardService for UnfurlShard {
mod tests {
use super::*;
use crate::types::MessageEmbed;
use std::sync::Mutex;
const BLOCKED_URL: &str = "http://127.0.0.1/unreachable";
static ENV_LOCK: Mutex<()> = Mutex::new(());
const PUBLIC_ENDPOINT_ENV: [&str; 7] = [
"FLUXER_MEDIA_PROXY_ENDPOINT",
"FLUXER_MEDIA_PROXY_SECRET_KEY",
"FLUXER_MEDIA_PROXY_PUBLIC_ENDPOINT",
"FLUXER_UNFURL_STATIC_CDN_ENDPOINT",
"FLUXER_STATIC_CDN_ENDPOINT",
"FLUXER_BASE_DOMAIN",
"FLUXER_PUBLIC_PORT",
];
fn shard_from_env(vars: &[(&str, &str)]) -> UnfurlShard {
let _guard = ENV_LOCK.lock().unwrap();
for name in PUBLIC_ENDPOINT_ENV {
unsafe { std::env::remove_var(name) };
}
for (name, value) in vars {
unsafe { std::env::set_var(name, value) };
}
let shard = UnfurlShard::new(long_lifetime());
for (name, _) in vars {
unsafe { std::env::remove_var(name) };
}
shard
}
#[test]
fn a_non_default_public_port_reaches_the_public_media_and_static_endpoints() {
let shard = shard_from_env(&[
("FLUXER_MEDIA_PROXY_ENDPOINT", "http://media-proxy:8080"),
("FLUXER_MEDIA_PROXY_SECRET_KEY", "secret"),
(
"FLUXER_MEDIA_PROXY_PUBLIC_ENDPOINT",
"http://fluxer.example/media",
),
("FLUXER_STATIC_CDN_ENDPOINT", "http://fluxer.example"),
("FLUXER_BASE_DOMAIN", "fluxer.example"),
("FLUXER_PUBLIC_PORT", "19080"),
]);
assert_eq!(shard.static_cdn_endpoint, "http://fluxer.example:19080");
assert!(
shard
.media_proxy
.external_proxy_url("https://img.example.net/a.png")
.expect("proxy url")
.starts_with("http://fluxer.example:19080/media/external/")
);
}
#[test]
fn a_default_public_port_leaves_the_public_media_and_static_endpoints_alone() {
let shard = shard_from_env(&[
("FLUXER_MEDIA_PROXY_ENDPOINT", "http://media-proxy:8080"),
("FLUXER_MEDIA_PROXY_SECRET_KEY", "secret"),
(
"FLUXER_MEDIA_PROXY_PUBLIC_ENDPOINT",
"https://fluxer.example/media",
),
("FLUXER_STATIC_CDN_ENDPOINT", "https://fluxer.example"),
("FLUXER_BASE_DOMAIN", "fluxer.example"),
("FLUXER_PUBLIC_PORT", "443"),
]);
assert_eq!(shard.static_cdn_endpoint, "https://fluxer.example");
assert!(
shard
.media_proxy
.external_proxy_url("https://img.example.net/a.png")
.expect("proxy url")
.starts_with("https://fluxer.example/media/external/")
);
}
fn unfurl(url: &str, youtube_api_key: Option<&str>) -> UnfurlRequest {
UnfurlRequest::Unfurl {
url: url.to_owned(),
+36 -2
View File
@@ -1,7 +1,11 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {buildNamedFluxerEnvOverrides} from '@fluxer/config/src/config_loader/EnvironmentOverrides';
import {deriveEndpointsFromDomain} from '@fluxer/config/src/EndpointDerivation';
import {
type DerivedEndpoints,
deriveEndpointsFromDomain,
normalizePublicEndpoint,
} from '@fluxer/config/src/EndpointDerivation';
import type {MasterConfig} from '@fluxer/config/src/MasterConfig';
type ConfigObject = Record<string, unknown>;
@@ -433,6 +437,36 @@ function normalizeConfig(config: MasterConfig): MasterConfig {
return config;
}
function applyPublicPort(config: MasterConfig, endpoints: DerivedEndpoints): MasterConfig {
const {base_domain, public_port} = config.domain;
const normalize = (url: string) => normalizePublicEndpoint(url, base_domain, public_port);
const normalizedEndpoints = {...endpoints};
for (const key of Object.keys(normalizedEndpoints) as Array<keyof DerivedEndpoints>) {
normalizedEndpoints[key] = normalize(normalizedEndpoints[key]);
}
return {
...config,
endpoints: normalizedEndpoints,
services: {
...config.services,
media_proxy: {
...config.services.media_proxy,
upload_relay: {
...config.services.media_proxy.upload_relay,
endpoint: normalize(config.services.media_proxy.upload_relay.endpoint),
},
},
},
auth: {
...config.auth,
passkeys: {
...config.auth.passkeys,
additional_allowed_origins: config.auth.passkeys.additional_allowed_origins.map(normalize),
},
},
};
}
export async function loadConfig(): Promise<MasterConfig> {
if (cachedConfig) {
return cachedConfig;
@@ -441,7 +475,7 @@ export async function loadConfig(): Promise<MasterConfig> {
const normalized = normalizeConfig(merged);
const derived = deriveEndpointsFromDomain(normalized.domain);
const endpoints = {...derived, ...(normalized.endpoint_overrides ?? {})};
cachedConfig = {...normalized, endpoints};
cachedConfig = applyPublicPort(normalized, endpoints);
return cachedConfig;
}
+43 -4
View File
@@ -24,17 +24,56 @@ export interface DerivedEndpoints {
gift: string;
}
export function buildUrl(scheme: string, domain: string, port?: number, path?: string): string {
const isStandardPort =
function isStandardPort(scheme: string, port: number): boolean {
return (
(scheme === 'http' && port === 80) ||
(scheme === 'https' && port === 443) ||
(scheme === 'ws' && port === 80) ||
(scheme === 'wss' && port === 443);
const portPart = port && !isStandardPort ? `:${port}` : '';
(scheme === 'wss' && port === 443)
);
}
function stripTrailingDot(host: string): string {
return host.endsWith('.') ? host.slice(0, -1) : host;
}
export function buildUrl(scheme: string, domain: string, port?: number, path?: string): string {
const portPart = port && !isStandardPort(scheme, port) ? `:${port}` : '';
const pathPart = path || '';
return `${scheme}://${domain}${portPart}${pathPart}`;
}
export function normalizePublicEndpoint(url: string, baseDomain: string, publicPort?: number): string {
const domain = stripTrailingDot(baseDomain.trim().toLowerCase());
if (domain.length === 0 || !publicPort) {
return url;
}
let parsed: URL;
try {
parsed = new URL(url);
} catch {
return url;
}
if (stripTrailingDot(parsed.hostname.toLowerCase()) !== domain) {
return url;
}
if (isStandardPort(parsed.protocol.slice(0, -1), publicPort)) {
return url;
}
const schemeEnd = url.indexOf('://');
if (schemeEnd === -1 || url.startsWith('/', schemeEnd + 3)) {
return url;
}
const authorityStart = schemeEnd + 3;
const relativeEnd = url.slice(authorityStart).search(/[/\\?#]/);
const authorityEnd = relativeEnd === -1 ? url.length : authorityStart + relativeEnd;
const host = url.slice(authorityStart, authorityEnd).split('@').pop() ?? '';
if (host.slice(host.lastIndexOf(']') + 1).includes(':')) {
return url;
}
return `${url.slice(0, authorityEnd)}:${publicPort}${url.slice(authorityEnd)}`;
}
export function deriveDomain(
endpointType:
| 'api'
@@ -102,6 +102,105 @@ describe('ConfigLoader', () => {
expect(config.endpoints.app).toBe('http://localhost:8088');
});
test('inserts the public port into portless endpoints on a non-standard port', async () => {
stubMinimalEnv({
FLUXER_MARKETING_ENDPOINT: 'http://localhost',
FLUXER_MEDIA_ENDPOINT: 'http://localhost/media',
FLUXER_MEDIA_PROXY_UPLOAD_RELAY_ENDPOINT: 'http://localhost/media',
FLUXER_PASSKEY_ADDITIONAL_ALLOWED_ORIGINS: 'http://localhost',
});
const config = await loadConfig();
expect(config.endpoints.marketing).toBe('http://localhost:8088');
expect(config.endpoints.media).toBe('http://localhost:8088/media');
expect(config.services.media_proxy.upload_relay.endpoint).toBe('http://localhost:8088/media');
expect(config.auth.passkeys.additional_allowed_origins).toEqual(['http://localhost:8088']);
});
test('leaves a default https install untouched', async () => {
stubMinimalEnv({
FLUXER_BASE_DOMAIN: 'chat.example',
FLUXER_PUBLIC_SCHEME: 'https',
FLUXER_PUBLIC_PORT: '443',
FLUXER_MARKETING_ENDPOINT: 'https://chat.example',
FLUXER_MEDIA_ENDPOINT: 'https://chat.example/media',
FLUXER_MEDIA_PROXY_UPLOAD_RELAY_ENDPOINT: 'https://chat.example/media',
FLUXER_PASSKEY_ADDITIONAL_ALLOWED_ORIGINS: 'https://chat.example',
});
const config = await loadConfig();
expect(config.endpoints.marketing).toBe('https://chat.example');
expect(config.endpoints.media).toBe('https://chat.example/media');
expect(config.endpoints.api).toBe('https://chat.example/api');
expect(config.endpoints.gateway).toBe('wss://chat.example/gateway');
expect(config.services.media_proxy.upload_relay.endpoint).toBe('https://chat.example/media');
expect(config.auth.passkeys.additional_allowed_origins).toEqual(['https://chat.example']);
});
test('leaves a default http install untouched', async () => {
stubMinimalEnv({
FLUXER_BASE_DOMAIN: 'chat.example',
FLUXER_PUBLIC_SCHEME: 'http',
FLUXER_PUBLIC_PORT: '80',
FLUXER_MARKETING_ENDPOINT: 'http://chat.example',
FLUXER_MEDIA_ENDPOINT: 'http://chat.example/media',
FLUXER_MEDIA_PROXY_UPLOAD_RELAY_ENDPOINT: 'http://chat.example/media',
});
const config = await loadConfig();
expect(config.endpoints.marketing).toBe('http://chat.example');
expect(config.endpoints.media).toBe('http://chat.example/media');
expect(config.endpoints.gateway).toBe('ws://chat.example/gateway');
expect(config.services.media_proxy.upload_relay.endpoint).toBe('http://chat.example/media');
});
test('leaves foreign hosts and already ported endpoints untouched', async () => {
stubMinimalEnv({
FLUXER_STATIC_CDN_ENDPOINT: 'https://cdn.example.net',
FLUXER_MEDIA_ENDPOINT: 'https://media.example.net/media',
FLUXER_MARKETING_ENDPOINT: 'http://localhost:9999',
FLUXER_MEDIA_PROXY_UPLOAD_RELAY_ENDPOINT: 'http://localhost:8088/media',
});
const config = await loadConfig();
expect(config.endpoints.static_cdn).toBe('https://cdn.example.net');
expect(config.endpoints.media).toBe('https://media.example.net/media');
expect(config.endpoints.marketing).toBe('http://localhost:9999');
expect(config.services.media_proxy.upload_relay.endpoint).toBe('http://localhost:8088/media');
});
test('normalizes each passkey origin independently', async () => {
stubMinimalEnv({
FLUXER_PASSKEY_ADDITIONAL_ALLOWED_ORIGINS:
'http://localhost,http://localhost:3000,https://desktop.example.net,android:apk-key-hash:abc',
});
const config = await loadConfig();
expect(config.auth.passkeys.additional_allowed_origins).toEqual([
'http://localhost:8088',
'http://localhost:3000',
'https://desktop.example.net',
'android:apk-key-hash:abc',
]);
});
test('leaves the default passkey origins untouched', async () => {
stubMinimalEnv();
const config = await loadConfig();
expect(config.auth.passkeys.additional_allowed_origins).toEqual([
'https://fluxer.app',
'https://web.fluxer.app',
'https://web.canary.fluxer.app',
'android:apk-key-hash:keSY4bimyLqZQV7bKXgpa2xYuqXi0qZJzsYtp6gpx7w',
'android:apk-key-hash:zRmCKDKo3uCX2GDZISjJx8Rzo3J-Y3Gbp7s7mAaUH28',
]);
});
test('parses typed named environment variables', async () => {
stubMinimalEnv({
FLUXER_API_PORT: '9090',
@@ -5,6 +5,7 @@ import {
type DomainConfig,
deriveDomain,
deriveEndpointsFromDomain,
normalizePublicEndpoint,
} from '@fluxer/config/src/EndpointDerivation';
import {describe, expect, test} from 'vitest';
@@ -301,3 +302,95 @@ describe('deriveEndpointsFromDomain', () => {
});
});
});
describe('normalizePublicEndpoint', () => {
test('leaves a default https install untouched', () => {
expect(normalizePublicEndpoint('https://fluxer.dev', 'fluxer.dev', 443)).toBe('https://fluxer.dev');
expect(normalizePublicEndpoint('https://fluxer.dev/media', 'fluxer.dev', 443)).toBe('https://fluxer.dev/media');
expect(normalizePublicEndpoint('wss://fluxer.dev/gateway', 'fluxer.dev', 443)).toBe('wss://fluxer.dev/gateway');
});
test('leaves a default http install untouched', () => {
expect(normalizePublicEndpoint('http://fluxer.dev', 'fluxer.dev', 80)).toBe('http://fluxer.dev');
expect(normalizePublicEndpoint('http://fluxer.dev/media', 'fluxer.dev', 80)).toBe('http://fluxer.dev/media');
expect(normalizePublicEndpoint('ws://fluxer.dev/gateway', 'fluxer.dev', 80)).toBe('ws://fluxer.dev/gateway');
});
test('inserts a non-standard port', () => {
expect(normalizePublicEndpoint('https://fluxer.dev', 'fluxer.dev', 8443)).toBe('https://fluxer.dev:8443');
expect(normalizePublicEndpoint('https://fluxer.dev/media', 'fluxer.dev', 8443)).toBe(
'https://fluxer.dev:8443/media',
);
expect(normalizePublicEndpoint('wss://fluxer.dev/gateway', 'fluxer.dev', 8443)).toBe(
'wss://fluxer.dev:8443/gateway',
);
});
test('judges standard ports against the url scheme, not the public scheme', () => {
expect(normalizePublicEndpoint('http://fluxer.dev/media', 'fluxer.dev', 443)).toBe('http://fluxer.dev:443/media');
expect(normalizePublicEndpoint('https://fluxer.dev/media', 'fluxer.dev', 80)).toBe('https://fluxer.dev:80/media');
});
test('leaves a foreign host untouched', () => {
expect(normalizePublicEndpoint('https://cdn.example.net/media', 'fluxer.dev', 8443)).toBe(
'https://cdn.example.net/media',
);
expect(normalizePublicEndpoint('https://sub.fluxer.dev', 'fluxer.dev', 8443)).toBe('https://sub.fluxer.dev');
});
test('leaves an already ported url untouched', () => {
expect(normalizePublicEndpoint('https://fluxer.dev:8443/media', 'fluxer.dev', 8443)).toBe(
'https://fluxer.dev:8443/media',
);
expect(normalizePublicEndpoint('https://fluxer.dev:9000/media', 'fluxer.dev', 8443)).toBe(
'https://fluxer.dev:9000/media',
);
expect(normalizePublicEndpoint('https://fluxer.dev:443/media', 'fluxer.dev', 8443)).toBe(
'https://fluxer.dev:443/media',
);
});
test('is idempotent', () => {
const once = normalizePublicEndpoint('https://fluxer.dev/media', 'fluxer.dev', 8443);
expect(normalizePublicEndpoint(once, 'fluxer.dev', 8443)).toBe(once);
});
test('preserves path, query, fragment, trailing slash, and case', () => {
expect(normalizePublicEndpoint('https://fluxer.dev/Media/', 'fluxer.dev', 8443)).toBe(
'https://fluxer.dev:8443/Media/',
);
expect(normalizePublicEndpoint('https://fluxer.dev/media?a=B#Frag', 'fluxer.dev', 8443)).toBe(
'https://fluxer.dev:8443/media?a=B#Frag',
);
expect(normalizePublicEndpoint('https://fluxer.dev?a=B', 'fluxer.dev', 8443)).toBe('https://fluxer.dev:8443?a=B');
expect(normalizePublicEndpoint('https://fluxer.dev#Frag', 'fluxer.dev', 8443)).toBe('https://fluxer.dev:8443#Frag');
expect(normalizePublicEndpoint('https://user:pw@fluxer.dev/media', 'fluxer.dev', 8443)).toBe(
'https://user:pw@fluxer.dev:8443/media',
);
});
test('matches the host case-insensitively and ignores a trailing dot', () => {
expect(normalizePublicEndpoint('https://FLUXER.dev/media', 'fluxer.dev', 8443)).toBe(
'https://FLUXER.dev:8443/media',
);
expect(normalizePublicEndpoint('https://fluxer.dev./media', 'fluxer.dev', 8443)).toBe(
'https://fluxer.dev.:8443/media',
);
expect(normalizePublicEndpoint('https://fluxer.dev/media', 'FLUXER.dev.', 8443)).toBe(
'https://fluxer.dev:8443/media',
);
});
test('leaves unparseable and non-http values untouched', () => {
expect(normalizePublicEndpoint('not a url', 'fluxer.dev', 8443)).toBe('not a url');
expect(normalizePublicEndpoint('', 'fluxer.dev', 8443)).toBe('');
expect(normalizePublicEndpoint('android:apk-key-hash:abc', 'fluxer.dev', 8443)).toBe('android:apk-key-hash:abc');
expect(normalizePublicEndpoint('https://fluxer.dev:/media', 'fluxer.dev', 8443)).toBe('https://fluxer.dev:/media');
});
test('leaves malformed authorities untouched', () => {
expect(normalizePublicEndpoint('https:fluxer.dev/media', 'fluxer.dev', 8443)).toBe('https:fluxer.dev/media');
expect(normalizePublicEndpoint('https:/fluxer.dev/media', 'fluxer.dev', 8443)).toBe('https:/fluxer.dev/media');
expect(normalizePublicEndpoint('https:////fluxer.dev/media', 'fluxer.dev', 8443)).toBe(
'https:////fluxer.dev/media',
);
expect(normalizePublicEndpoint('https://fluxer.dev\\media', 'fluxer.dev', 8443)).toBe(
'https://fluxer.dev:8443\\media',
);
});
test('leaves everything untouched without a usable port or base domain', () => {
expect(normalizePublicEndpoint('https://fluxer.dev/media', 'fluxer.dev')).toBe('https://fluxer.dev/media');
expect(normalizePublicEndpoint('https://fluxer.dev/media', '', 8443)).toBe('https://fluxer.dev/media');
expect(normalizePublicEndpoint('https://fluxer.dev/media', ' ', 8443)).toBe('https://fluxer.dev/media');
});
});
@@ -0,0 +1,50 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {readFileSync} from 'node:fs';
import path from 'node:path';
import {fileURLToPath} from 'node:url';
import {normalizePublicEndpoint} from '@fluxer/config/src/EndpointDerivation';
import {describe, expect, test} from 'vitest';
interface PublicEndpointVector {
url: string;
base_domain: string;
public_port: number | null;
normalized: string;
}
const VECTORS_PATH = path.resolve(
path.dirname(fileURLToPath(import.meta.url)),
'../../../../fluxer_common/src/testdata/public_endpoint_vectors.json',
);
function readVectors(): Array<PublicEndpointVector> {
const parsed: unknown = JSON.parse(readFileSync(VECTORS_PATH, 'utf8'));
if (!Array.isArray(parsed) || parsed.length === 0) {
throw new Error(`no public endpoint vectors in ${VECTORS_PATH}`);
}
return parsed.map((vector, index) => {
if (
typeof vector?.url !== 'string' ||
typeof vector?.base_domain !== 'string' ||
typeof vector?.normalized !== 'string' ||
(vector?.public_port !== null && typeof vector?.public_port !== 'number')
) {
throw new Error(`malformed public endpoint vector at index ${index} in ${VECTORS_PATH}`);
}
return vector as PublicEndpointVector;
});
}
const vectors = readVectors();
describe('normalizePublicEndpoint shared vectors', () => {
test('reads the vector file the Rust conformance test reads', () => {
expect(vectors.length).toBeGreaterThan(0);
});
test.each(vectors)('$url @ $base_domain port $public_port', (vector) => {
expect(normalizePublicEndpoint(vector.url, vector.base_domain, vector.public_port ?? undefined)).toBe(
vector.normalized,
);
});
});