mirror of
https://github.com/fluxerapp/fluxer.git
synced 2026-09-02 21:04:06 +03:00
fix(config): carry the public port into derived endpoints (#2329)
This commit is contained in:
Generated
+1
@@ -1958,6 +1958,7 @@ dependencies = [
|
||||
"base64",
|
||||
"chrono",
|
||||
"cookie",
|
||||
"fluxer_common",
|
||||
"hmac 0.13.0",
|
||||
"maud",
|
||||
"openapiv3",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
@@ -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',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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""#));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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!(
|
||||
|
||||
@@ -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).
|
||||
@@ -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(
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user