feat(web): prepare the fluxer.com domain migration (#2952)

This commit is contained in:
Hampus
2026-09-25 13:43:34 +02:00
committed by GitHub
parent e62ae77643
commit 6730a242db
223 changed files with 13412 additions and 2669 deletions
+65
View File
@@ -71,6 +71,27 @@ pub fn build_bootstrap_script(
)
}
pub fn rewrite_endpoints_for_same_origin_host(instance: &mut serde_json::Value, host: &str) {
let Some(endpoints) = instance
.get_mut("endpoints")
.and_then(serde_json::Value::as_object_mut)
else {
return;
};
let origin = format!("https://{host}");
let api = format!("{origin}/api");
for (key, value) in [
("api_client", &api),
("api", &api),
("webapp", &origin),
("app", &origin),
] {
if let Some(endpoint) = endpoints.get_mut(key) {
*endpoint = serde_json::Value::String(value.clone());
}
}
}
fn api_public_endpoint<'a>(
configured: Option<&'a str>,
discovery: &'a DiscoveryResponse,
@@ -541,6 +562,50 @@ mod tests {
assert_eq!(api_public_endpoint(None, &discovery), None);
}
#[test]
fn a_same_origin_host_takes_over_the_client_and_web_app_endpoints() {
let mut instance = serde_json::json!({
"endpoints": {
"api": "https://web.fluxer.app/api",
"api_client": "https://web.fluxer.app/api",
"api_public": "https://api.fluxer.app",
"gateway": "wss://gateway.fluxer.app",
"webapp": "https://web.fluxer.app",
"app": "https://web.fluxer.app",
"marketing": "https://fluxer.app"
}
});
rewrite_endpoints_for_same_origin_host(&mut instance, "fluxer.com");
assert_eq!(
instance["endpoints"],
serde_json::json!({
"api": "https://fluxer.com/api",
"api_client": "https://fluxer.com/api",
"api_public": "https://api.fluxer.app",
"gateway": "wss://gateway.fluxer.app",
"webapp": "https://fluxer.com",
"app": "https://fluxer.com",
"marketing": "https://fluxer.app"
})
);
}
#[test]
fn a_same_origin_host_never_invents_endpoints_discovery_left_out() {
let mut instance = serde_json::json!({
"endpoints": {"api_client": "https://web.fluxer.app/api"}
});
rewrite_endpoints_for_same_origin_host(&mut instance, "fluxer.com");
assert_eq!(
instance,
serde_json::json!({"endpoints": {"api_client": "https://fluxer.com/api"}})
);
let mut without_endpoints = serde_json::json!({"name": "test"});
rewrite_endpoints_for_same_origin_host(&mut without_endpoints, "fluxer.com");
assert_eq!(without_endpoints, serde_json::json!({"name": "test"}));
}
#[test]
fn the_boot_script_hands_the_repaired_endpoint_to_both_globals() {
let discovery = discovery_offering("https://chat.example.test:8443/api");
+152
View File
@@ -356,6 +356,8 @@ pub struct AppProxyConfig {
pub geoip_s3_config: Option<GeoipS3Config>,
pub trust_client_ip_header: bool,
pub client_ip_header_name: String,
pub same_origin_hosts: Vec<String>,
pub manifest_scope_extensions: Vec<String>,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
@@ -511,10 +513,101 @@ impl AppProxyConfig {
)
.trim()
.to_ascii_lowercase(),
same_origin_hosts: parse_same_origin_hosts(
"FLUXER_APP_PROXY_SAME_ORIGIN_HOSTS",
&cfg::read_env("FLUXER_APP_PROXY_SAME_ORIGIN_HOSTS", ""),
),
manifest_scope_extensions: parse_manifest_scope_extensions(
"FLUXER_APP_PROXY_MANIFEST_SCOPE_EXTENSIONS",
&cfg::read_env("FLUXER_APP_PROXY_MANIFEST_SCOPE_EXTENSIONS", ""),
),
}
}
}
fn parse_manifest_scope_extensions(name: &'static str, raw: &str) -> Vec<String> {
let mut origins: Vec<String> = Vec::new();
for value in raw
.split([',', ' ', '\t', '\n'])
.map(str::trim)
.filter(|value| !value.is_empty())
{
match parse_manifest_scope_extension_origin(name, value) {
Ok(origin) => {
if !origins.contains(&origin) {
origins.push(origin);
}
}
Err(error) => warn_invalid(&error),
}
}
origins
}
fn parse_manifest_scope_extension_origin(
name: &'static str,
value: &str,
) -> Result<String, InvalidAppProxyEnvironmentError> {
let origin = Url::parse(value).ok().filter(|url| {
url.scheme() == "https"
&& url.host_str().is_some()
&& url.username().is_empty()
&& url.password().is_none()
&& url.path() == "/"
&& url.query().is_none()
&& url.fragment().is_none()
});
match origin {
Some(url) => Ok(url.origin().ascii_serialization()),
None => Err(InvalidAppProxyEnvironmentError::new(
name,
value,
"an HTTPS origin without a path, query or credentials",
)),
}
}
fn parse_same_origin_hosts(name: &'static str, raw: &str) -> Vec<String> {
let mut hosts: Vec<String> = Vec::new();
for value in raw
.split([',', ' ', '\t', '\n'])
.map(str::trim)
.filter(|value| !value.is_empty())
{
match parse_same_origin_host(name, value) {
Ok(host) => {
if !hosts.contains(&host) {
hosts.push(host);
}
}
Err(error) => warn_invalid(&error),
}
}
hosts
}
fn parse_same_origin_host(
name: &'static str,
value: &str,
) -> Result<String, InvalidAppProxyEnvironmentError> {
let host = value.trim_end_matches('.').to_ascii_lowercase();
let is_hostname = !host.is_empty()
&& Url::parse(&format!("https://{host}/")).is_ok_and(|url| {
url.host_str() == Some(host.as_str())
&& url.port().is_none()
&& url.path() == "/"
&& url.username().is_empty()
});
if !is_hostname {
return Err(InvalidAppProxyEnvironmentError::new(
name,
value,
"a bare hostname without a scheme, port or path",
));
}
Ok(host)
}
fn resolve_discovery_upstream_url_from_env() -> String {
resolve_discovery_upstream_url(|name| env::var(name).ok())
}
@@ -699,6 +792,65 @@ mod tests {
assert!(error.to_string().contains("FLUXER_PUBLIC_ORIGIN"));
}
#[test]
fn same_origin_hosts_default_to_none() {
assert!(parse_same_origin_hosts("TEST_SAME_ORIGIN_HOSTS", "").is_empty());
assert!(parse_same_origin_hosts("TEST_SAME_ORIGIN_HOSTS", " , ").is_empty());
}
#[test]
fn same_origin_hosts_are_normalised_and_deduplicated() {
assert_eq!(
parse_same_origin_hosts(
"TEST_SAME_ORIGIN_HOSTS",
" web.fluxer.app, Fluxer.COM,fluxer.com. ,fluxer.com"
),
vec!["web.fluxer.app".to_owned(), "fluxer.com".to_owned()]
);
}
#[test]
fn same_origin_hosts_drop_anything_but_a_bare_hostname() {
assert_eq!(
parse_same_origin_hosts(
"TEST_SAME_ORIGIN_HOSTS",
"https://fluxer.com,fluxer.com:443,fluxer.com/api,[email protected],canary.fluxer.com"
),
vec!["canary.fluxer.com".to_owned()]
);
}
#[test]
fn manifest_scope_extensions_default_to_none() {
assert!(parse_manifest_scope_extensions("TEST_SCOPE_EXTENSIONS", "").is_empty());
assert!(parse_manifest_scope_extensions("TEST_SCOPE_EXTENSIONS", " , ").is_empty());
}
#[test]
fn manifest_scope_extensions_are_normalised_and_deduplicated() {
assert_eq!(
parse_manifest_scope_extensions(
"TEST_SCOPE_EXTENSIONS",
" https://Fluxer.COM, https://fluxer.com/ ,https://canary.fluxer.com:443"
),
vec![
"https://fluxer.com".to_owned(),
"https://canary.fluxer.com".to_owned()
]
);
}
#[test]
fn manifest_scope_extensions_drop_anything_but_an_https_origin() {
assert_eq!(
parse_manifest_scope_extensions(
"TEST_SCOPE_EXTENSIONS",
"fluxer.com,http://fluxer.com,https://fluxer.com/app,https://fluxer.com/?a=1,https://[email protected],https://canary.fluxer.com"
),
vec!["https://canary.fluxer.com".to_owned()]
);
}
#[test]
fn csp_config_default_has_no_extra_sources() {
let c = CspConfig::default();
+131 -3
View File
@@ -1,7 +1,9 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use crate::bootstrap::{build_bootstrap_script, inject_bootstrap};
use crate::config::HttpEndpoint;
use crate::bootstrap::{
build_bootstrap_script, inject_bootstrap, rewrite_endpoints_for_same_origin_host,
};
use crate::config::{AppProxyConfig, HttpEndpoint};
use crate::csp::{RuntimeCspSources, generate_nonce};
use crate::discovery_cache::{DiscoveryResponse, discovery_endpoint};
use crate::geoip::build_geoip_response;
@@ -141,13 +143,16 @@ async fn serve_static_file(
async fn serve_spa_index(state: &AppState, headers: &HeaderMap) -> Response {
let should_bust_dev_assets = state.config.index_upstream_url.is_some();
let discovery = match refresh_discovery_for_spa(state).await {
let mut discovery = match refresh_discovery_for_spa(state).await {
Some(d) => d,
None => {
tracing::error!("discovery cache empty, cannot serve SPA");
return StatusCode::SERVICE_UNAVAILABLE.into_response();
}
};
if let Some(host) = same_origin_host(&state.config, headers) {
rewrite_endpoints_for_same_origin_host(&mut discovery.data, host);
}
let nonce = generate_nonce();
let runtime_csp_sources = build_runtime_csp_sources(state, &discovery);
@@ -192,6 +197,29 @@ async fn serve_spa_index(state: &AppState, headers: &HeaderMap) -> Response {
build_spa_response(html, csp, should_bust_dev_assets)
}
fn same_origin_host<'a>(config: &'a AppProxyConfig, headers: &HeaderMap) -> Option<&'a str> {
if config.same_origin_hosts.is_empty() {
return None;
}
let host = request_hostname(headers.get(header::HOST)?.to_str().ok()?)?;
config
.same_origin_hosts
.iter()
.find(|candidate| candidate.eq_ignore_ascii_case(host))
.map(String::as_str)
}
fn request_hostname(authority: &str) -> Option<&str> {
let authority = authority.trim();
let hostname = if authority.starts_with('[') {
&authority[..=authority.find(']')?]
} else {
authority.split(':').next()?
};
let hostname = hostname.trim_end_matches('.');
(!hostname.is_empty()).then_some(hostname)
}
#[derive(Debug)]
struct SpaDocumentSizeLimitError {
attempted_bytes: usize,
@@ -432,6 +460,10 @@ fn build_spa_response(html: Box<str>, csp: HeaderValue, dev_no_store: bool) -> R
headers.insert(header::CACHE_CONTROL, HeaderValue::from_static("no-cache"));
}
super::set_security_headers(headers);
headers.insert(
HeaderName::from_static("x-fluxer-app-shell"),
HeaderValue::from_static("1"),
);
headers.insert(
axum::http::HeaderName::from_static("accept-ch"),
HeaderValue::from_static(ACCEPT_CH_VALUE),
@@ -755,6 +787,8 @@ mod tests {
const DISCOVERY_BODY_WITH_BOTH_ENDPOINTS: &str = r#"{"api_code_version":"proxy-test","endpoints":{"static_cdn":"https://cdn.example.test","media":"https://media.example.test"}}"#;
const DISCOVERY_BODY_WITH_WEB_APP_ENDPOINTS: &str = r#"{"api_code_version":"proxy-test","endpoints":{"api":"https://web.fluxer.app/api","api_client":"https://web.fluxer.app/api","api_public":"https://api.fluxer.app","webapp":"https://web.fluxer.app","static_cdn":"https://cdn.example.test","media":"https://media.example.test"}}"#;
const DISCOVERY_BODY_WITHOUT_ENDPOINTS: &str = r#"{"api_code_version":"proxy-test"}"#;
const DISCOVERY_BODY_SELF_HOSTED: &str = r#"{"api_code_version":"proxy-test","endpoints":{"static_cdn":"https://cdn.example.test","media":"https://media.example.test"},"features":{"self_hosted":true}}"#;
@@ -924,6 +958,100 @@ mod tests {
String::from_utf8(bytes.to_vec()).unwrap()
}
async fn spa_state_with_same_origin_hosts(hosts: &[&str]) -> AppState {
let mut state = assemble_spa_state(
ReleaseChannel::Stable,
Some(SHELL_WITH_ENDPOINT_HOLES),
DISCOVERY_BODY_WITH_WEB_APP_ENDPOINTS,
None,
None,
)
.await;
let mut config = (*state.config).clone();
config.same_origin_hosts = hosts.iter().map(|host| (*host).to_owned()).collect();
state.config = Arc::new(config);
state
}
fn request_from_host(host: &str) -> HeaderMap {
let mut headers = HeaderMap::new();
headers.insert(header::HOST, HeaderValue::from_str(host).unwrap());
headers
}
#[test]
fn the_request_hostname_drops_its_port_and_trailing_dot() {
assert_eq!(request_hostname("fluxer.com"), Some("fluxer.com"));
assert_eq!(request_hostname("Fluxer.com:443"), Some("Fluxer.com"));
assert_eq!(request_hostname("fluxer.com.:8443"), Some("fluxer.com"));
assert_eq!(request_hostname("[::1]:8080"), Some("[::1]"));
assert_eq!(request_hostname(":443"), None);
assert_eq!(request_hostname("[::1"), None);
}
#[tokio::test]
async fn a_listed_host_gets_same_origin_endpoints_in_its_bootstrap() {
let state = spa_state_with_same_origin_hosts(&["web.fluxer.app", "fluxer.com"]).await;
let response = serve_spa_index(&state, &request_from_host("FLUXER.com:443")).await;
assert_eq!(response.status(), StatusCode::OK);
let served = read_document(response).await;
assert!(served.contains(r#""api_client":"https://fluxer.com/api""#));
assert!(served.contains(r#""api":"https://fluxer.com/api""#));
assert!(served.contains(r#""webapp":"https://fluxer.com""#));
assert!(served.contains(r#""api_public":"https://api.fluxer.app""#));
assert!(!served.contains("https://web.fluxer.app"));
let cached = state.discovery_cache.get().await.unwrap();
assert_eq!(
cached.data["endpoints"]["api_client"], "https://web.fluxer.app/api",
"the shared discovery snapshot was rewritten for one request"
);
let response = serve_spa_index(&state, &request_from_host("web.fluxer.app")).await;
let served = read_document(response).await;
assert!(served.contains(r#""api_client":"https://web.fluxer.app/api""#));
assert!(!served.contains("https://fluxer.com"));
}
#[tokio::test]
async fn an_unlisted_host_keeps_the_discovered_endpoints() {
let state = spa_state_with_same_origin_hosts(&["fluxer.com"]).await;
for headers in [request_from_host("evil.example"), HeaderMap::new()] {
let response = serve_spa_index(&state, &headers).await;
let served = read_document(response).await;
assert!(served.contains(r#""api_client":"https://web.fluxer.app/api""#));
assert!(served.contains(r#""webapp":"https://web.fluxer.app""#));
assert!(!served.contains("https://fluxer.com"));
}
}
#[tokio::test]
async fn no_host_is_rewritten_when_none_is_configured() {
let state = spa_state_with_same_origin_hosts(&[]).await;
let response = serve_spa_index(&state, &request_from_host("fluxer.com")).await;
let served = read_document(response).await;
assert!(served.contains(r#""api_client":"https://web.fluxer.app/api""#));
}
#[tokio::test]
async fn the_spa_document_marks_itself_as_the_app_shell() {
let state =
spa_state_serving(ReleaseChannel::Canary, Some(SHELL_WITH_ENDPOINT_HOLES)).await;
let response = serve_spa_index(&state, &HeaderMap::new()).await;
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(
response
.headers()
.get("x-fluxer-app-shell")
.and_then(|value| value.to_str().ok()),
Some("1")
);
}
#[tokio::test]
async fn the_live_branch_serves_a_rendered_document_and_not_the_raw_shell() {
let state =
+124 -3
View File
@@ -26,15 +26,46 @@ pub async fn version_json(State(state): State<AppState>) -> Response {
pub async fn manifest_json(State(state): State<AppState>) -> Response {
let static_cdn_endpoint = runtime_static_cdn_endpoint(&state).await;
serve_static_text_file_with_cdn(
serve_static_text_file_with_substitutions(
&state,
"manifest.json",
"application/manifest+json",
static_cdn_endpoint.as_ref(),
Some(&state.config.manifest_scope_extensions),
)
.await
}
fn with_scope_extensions(text: String, origins: &[String]) -> String {
if origins.is_empty() {
return text;
}
let Ok(serde_json::Value::Object(mut manifest)) = serde_json::from_str(&text) else {
return text;
};
let scope_extensions = origins
.iter()
.map(|origin| serde_json::json!({ "type": "origin", "origin": origin }))
.collect();
manifest.insert(
"scope_extensions".to_owned(),
serde_json::Value::Array(scope_extensions),
);
serde_json::to_string_pretty(&manifest).unwrap_or(text)
}
fn substitute_placeholders(
text: &str,
static_cdn_endpoint: &str,
scope_extensions: Option<&[String]>,
) -> String {
let text = text.replace("{{STATIC_CDN_ENDPOINT}}", static_cdn_endpoint);
match scope_extensions {
Some(origins) => with_scope_extensions(text, origins),
None => text,
}
}
pub async fn browserconfig_xml(State(state): State<AppState>) -> Response {
let static_cdn_endpoint = runtime_static_cdn_endpoint(&state).await;
serve_static_text_file_with_cdn(
@@ -73,6 +104,23 @@ async fn serve_static_text_file_with_cdn(
filename: &str,
content_type: &str,
static_cdn_endpoint: Option<&HttpEndpoint>,
) -> Response {
serve_static_text_file_with_substitutions(
state,
filename,
content_type,
static_cdn_endpoint,
None,
)
.await
}
async fn serve_static_text_file_with_substitutions(
state: &AppState,
filename: &str,
content_type: &str,
static_cdn_endpoint: Option<&HttpEndpoint>,
scope_extensions: Option<&[String]>,
) -> Response {
let static_dir = state.config.static_dir.as_str();
let file_path = Path::new(static_dir).join(filename);
@@ -104,8 +152,7 @@ async fn serve_static_text_file_with_cdn(
let replacement = static_cdn_endpoint.map_or("", HttpEndpoint::as_str);
let body: axum::body::Body = match std::str::from_utf8(&content) {
Ok(text) => text
.replace("{{STATIC_CDN_ENDPOINT}}", replacement)
Ok(text) => substitute_placeholders(text, replacement, scope_extensions)
.into_bytes()
.into(),
Err(_) => content.into(),
@@ -201,6 +248,80 @@ fn is_content_hash(value: &str) -> bool {
mod tests {
use super::*;
const BUILT_MANIFEST: &str = r#"{
"id": "/",
"start_url": "/app",
"scope": "/",
"scope_extensions": [],
"icons": [
{
"src": "{{STATIC_CDN_ENDPOINT}}/web/android-chrome-192x192.png"
}
]
}"#;
fn substituted_manifest(origins: &[&str]) -> serde_json::Value {
let origins: Vec<String> = origins.iter().map(|origin| (*origin).to_owned()).collect();
let text =
substitute_placeholders(BUILT_MANIFEST, "https://fluxerstatic.com", Some(&origins));
serde_json::from_str(&text).expect("substituted manifest must stay valid JSON")
}
#[test]
fn manifest_scope_extensions_default_to_the_built_empty_list() {
let manifest = substituted_manifest(&[]);
assert_eq!(manifest["scope_extensions"], serde_json::json!([]));
assert_eq!(
manifest["icons"][0]["src"],
"https://fluxerstatic.com/web/android-chrome-192x192.png"
);
}
#[test]
fn manifest_scope_extensions_list_each_configured_origin() {
let manifest = substituted_manifest(&["https://fluxer.com", "https://canary.fluxer.com"]);
assert_eq!(
manifest["scope_extensions"],
serde_json::json!([
{ "type": "origin", "origin": "https://fluxer.com" },
{ "type": "origin", "origin": "https://canary.fluxer.com" }
])
);
assert_eq!(manifest["id"], "/");
assert_eq!(manifest["start_url"], "/app");
}
#[test]
fn manifest_scope_extensions_are_added_when_the_build_has_none() {
let text = substitute_placeholders(
r#"{"id":"/"}"#,
"",
Some(&["https://fluxer.com".to_owned()]),
);
let manifest: serde_json::Value = serde_json::from_str(&text).expect("valid JSON");
assert_eq!(
manifest["scope_extensions"],
serde_json::json!([{ "type": "origin", "origin": "https://fluxer.com" }])
);
}
#[test]
fn other_text_files_are_left_alone() {
let origins = ["https://fluxer.com".to_owned()];
assert_eq!(
substitute_placeholders(
"not json {{STATIC_CDN_ENDPOINT}}",
"https://cdn",
Some(&origins)
),
"not json https://cdn"
);
assert_eq!(
substitute_placeholders(BUILT_MANIFEST, "", None),
BUILT_MANIFEST.replace("{{STATIC_CDN_ENDPOINT}}", "")
);
}
#[test]
fn mime_html() {
assert_eq!(guess_mime("i.html"), "text/html; charset=utf-8");