fix(app-proxy): serve instance branding before boot (#3396)

This commit is contained in:
Hampus authored and GitHub committed 2026-10-11 15:54:40 +02:00
1 parent 3694300d86
commit 20e3474393
11 files changed
+888 -100

No files matched your search

+2
View File
@@ -661,11 +661,13 @@ services:
FLUXER_S3_BUCKET_UPLOADS: ${FLUXER_S3_BUCKET_UPLOADS:-} FLUXER_S3_BUCKET_UPLOADS: ${FLUXER_S3_BUCKET_UPLOADS:-}
FLUXER_STATIC_CDN_ENDPOINT: ${FLUXER_STATIC_CDN_ENDPOINT:-} FLUXER_STATIC_CDN_ENDPOINT: ${FLUXER_STATIC_CDN_ENDPOINT:-}
FLUXER_MEDIA_ENDPOINT: ${FLUXER_MEDIA_ENDPOINT:-} FLUXER_MEDIA_ENDPOINT: ${FLUXER_MEDIA_ENDPOINT:-}
FLUXER_APP_PRODUCT_NAME: ${FLUXER_APP_PRODUCT_NAME:-}
FLUXER_APP_ICON_URL: ${FLUXER_APP_ICON_URL:-} FLUXER_APP_ICON_URL: ${FLUXER_APP_ICON_URL:-}
FLUXER_APP_SYMBOL_URL: ${FLUXER_APP_SYMBOL_URL:-} FLUXER_APP_SYMBOL_URL: ${FLUXER_APP_SYMBOL_URL:-}
FLUXER_APP_LOGO_URL: ${FLUXER_APP_LOGO_URL:-} FLUXER_APP_LOGO_URL: ${FLUXER_APP_LOGO_URL:-}
FLUXER_APP_WORDMARK_URL: ${FLUXER_APP_WORDMARK_URL:-} FLUXER_APP_WORDMARK_URL: ${FLUXER_APP_WORDMARK_URL:-}
FLUXER_APP_FAVICON_URL: ${FLUXER_APP_FAVICON_URL:-} FLUXER_APP_FAVICON_URL: ${FLUXER_APP_FAVICON_URL:-}
FLUXER_APP_THEME_COLOR: ${FLUXER_APP_THEME_COLOR:-}
DISCOVERY_UPSTREAM_URL: http://edge:8088/.well-known/fluxer DISCOVERY_UPSTREAM_URL: http://edge:8088/.well-known/fluxer
DISCOVERY_REFRESH_INTERVAL_MS: ${DISCOVERY_REFRESH_INTERVAL_MS:-} DISCOVERY_REFRESH_INTERVAL_MS: ${DISCOVERY_REFRESH_INTERVAL_MS:-}
FLUXER_APP_PROXY_INDEX_UPSTREAM_URL: ${FLUXER_APP_PROXY_INDEX_UPSTREAM_URL:-} FLUXER_APP_PROXY_INDEX_UPSTREAM_URL: ${FLUXER_APP_PROXY_INDEX_UPSTREAM_URL:-}
+316 -57
View File
@@ -2,6 +2,7 @@
use crate::config::{AppProxyConfig, HttpEndpoint}; use crate::config::{AppProxyConfig, HttpEndpoint};
use axum::http::header; use axum::http::header;
use reqwest::Url;
use std::sync::Arc; use std::sync::Arc;
use std::time::Duration; use std::time::Duration;
use tokio::sync::watch; use tokio::sync::watch;
@@ -14,27 +15,80 @@ const BRANDING_IMAGES: [(&str, &str); 5] = [
("wordmark_url", "FLUXER_APP_WORDMARK_URL"), ("wordmark_url", "FLUXER_APP_WORDMARK_URL"),
("favicon_url", "FLUXER_APP_FAVICON_URL"), ("favicon_url", "FLUXER_APP_FAVICON_URL"),
]; ];
const PRODUCT_NAME: (&str, &str) = ("product_name", "FLUXER_APP_PRODUCT_NAME");
const ICON_URL: (&str, &str) = ("icon_url", "FLUXER_APP_ICON_URL");
const FAVICON_URL: (&str, &str) = ("favicon_url", "FLUXER_APP_FAVICON_URL");
const THEME_COLOR: (&str, &str) = ("theme_color", "FLUXER_APP_THEME_COLOR");
const DISCOVERY_FETCH_TIMEOUT: Duration = Duration::from_secs(5); const DISCOVERY_FETCH_TIMEOUT: Duration = Duration::from_secs(5);
const DISCOVERY_BODY_BYTES_MAX: usize = 1024 * 1024; const DISCOVERY_BODY_BYTES_MAX: usize = 1024 * 1024;
const REFRESH_INTERVAL_MIN: Duration = Duration::from_secs(1); const REFRESH_INTERVAL_MIN: Duration = Duration::from_secs(1);
const REFRESH_BACKOFF_MAX: Duration = Duration::from_secs(300); const REFRESH_BACKOFF_MAX: Duration = Duration::from_secs(300);
pub fn origins_from_environment<F>(mut read_var: F) -> Vec<HttpEndpoint> pub fn snapshot_from_environment<F>(mut read_var: F) -> BrandingSnapshot
where where
F: FnMut(&str) -> Option<String>, F: FnMut(&str) -> Option<String>,
{ {
distinct_origins( snapshot_from_fields(|(_, name)| read_var(name))
BRANDING_IMAGES
.iter()
.filter_map(|(_, name)| Some((*name, read_var(name)?))),
)
} }
pub fn origins_from_discovery(discovery: &serde_json::Value) -> Option<Vec<HttpEndpoint>> { pub fn snapshot_from_discovery(discovery: &serde_json::Value) -> Option<BrandingSnapshot> {
let branding = discovery.pointer("/app_public/branding")?.as_object()?; let branding = discovery.pointer("/app_public/branding")?.as_object()?;
Some(distinct_origins(BRANDING_IMAGES.iter().filter_map( Some(snapshot_from_fields(|(key, _)| {
|(key, _)| Some((*key, branding.get(*key)?.as_str()?.to_owned())), branding.get(key)?.as_str().map(str::to_owned)
))) }))
}
fn snapshot_from_fields(
mut field: impl FnMut((&'static str, &'static str)) -> Option<String>,
) -> BrandingSnapshot {
let image_origins = distinct_origins(
BRANDING_IMAGES
.iter()
.filter_map(|&(key, name)| Some((name, field((key, name))?))),
);
let icon_url = non_empty(field(ICON_URL));
let favicon_url = non_empty(field(FAVICON_URL)).or_else(|| icon_url.clone());
BrandingSnapshot {
image_origins: image_origins.into(),
product_name: non_empty(field(PRODUCT_NAME)).map(Into::into),
icon_url: icon_url.as_deref().and_then(absolute_url).map(Into::into),
favicon_url: favicon_url
.as_deref()
.and_then(absolute_url)
.map(Into::into),
theme_color: field(THEME_COLOR)
.as_deref()
.and_then(theme_color)
.map(Into::into),
}
}
fn non_empty(value: Option<String>) -> Option<String> {
value
.map(|value| value.trim().to_owned())
.filter(|value| !value.is_empty())
}
fn absolute_url(value: &str) -> Option<String> {
Url::parse(value)
.ok()
.filter(|url| matches!(url.scheme(), "http" | "https") && url.host_str().is_some())
.map(String::from)
}
fn theme_color(value: &str) -> Option<String> {
let value = value.trim();
let digits = value.strip_prefix('#').unwrap_or(value);
if !matches!(digits.len(), 3 | 6) || !digits.bytes().all(|byte| byte.is_ascii_hexdigit()) {
return None;
}
let digits = digits.to_ascii_lowercase();
let color = if digits.len() == 3 {
digits.chars().flat_map(|digit| [digit, digit]).collect()
} else {
digits
};
(color != "000000").then(|| format!("#{color}"))
} }
fn distinct_origins(urls: impl Iterator<Item = (&'static str, String)>) -> Vec<HttpEndpoint> { fn distinct_origins(urls: impl Iterator<Item = (&'static str, String)>) -> Vec<HttpEndpoint> {
@@ -80,23 +134,36 @@ async fn fetch_discovery(
Ok(serde_json::from_slice(&body)?) Ok(serde_json::from_slice(&body)?)
} }
#[derive(Clone, Debug)] #[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct BrandingImageOrigins(watch::Sender<Arc<[HttpEndpoint]>>); pub struct BrandingSnapshot {
pub image_origins: Arc<[HttpEndpoint]>,
pub product_name: Option<Arc<str>>,
pub icon_url: Option<Arc<str>>,
pub favicon_url: Option<Arc<str>>,
pub theme_color: Option<Arc<str>>,
}
impl BrandingImageOrigins { #[derive(Clone, Debug)]
pub fn new(origins: Vec<HttpEndpoint>) -> Self { pub struct InstanceBranding(watch::Sender<Arc<BrandingSnapshot>>);
Self(watch::Sender::new(origins.into()))
impl InstanceBranding {
pub fn new(snapshot: BrandingSnapshot) -> Self {
Self(watch::Sender::new(Arc::new(snapshot)))
} }
pub fn from_config(config: &AppProxyConfig) -> Self { pub fn from_config(config: &AppProxyConfig) -> Self {
if config.self_hosted { if config.self_hosted {
Self::new(config.branding_image_origins.clone()) Self::new(config.branding.clone())
} else { } else {
Self::new(Vec::new()) Self::new(BrandingSnapshot::default())
} }
} }
pub fn current(&self) -> Arc<[HttpEndpoint]> { pub fn image_origins(&self) -> Arc<[HttpEndpoint]> {
self.0.borrow().image_origins.clone()
}
pub fn snapshot(&self) -> Arc<BrandingSnapshot> {
self.0.borrow().clone() self.0.borrow().clone()
} }
@@ -106,19 +173,30 @@ impl BrandingImageOrigins {
upstream_url: &str, upstream_url: &str,
) -> anyhow::Result<()> { ) -> anyhow::Result<()> {
let discovery = fetch_discovery(client, upstream_url).await?; let discovery = fetch_discovery(client, upstream_url).await?;
let Some(origins) = origins_from_discovery(&discovery) else { let Some(snapshot) = snapshot_from_discovery(&discovery) else {
anyhow::bail!("upstream discovery has no app_public.branding object"); anyhow::bail!("upstream discovery has no app_public.branding object");
}; };
let changed = self.0.send_if_modified(|current| { let changed = self.0.send_if_modified(|current| {
if **current == *origins { if **current == snapshot {
return false; return false;
} }
*current = origins.as_slice().into(); *current = Arc::new(snapshot.clone());
true true
}); });
if changed { if changed {
let origins: Vec<&str> = origins.iter().map(HttpEndpoint::csp_origin).collect(); let origins: Vec<&str> = snapshot
tracing::info!(?origins, "branding image origins updated"); .image_origins
.iter()
.map(HttpEndpoint::csp_origin)
.collect();
tracing::info!(
?origins,
product_name = snapshot.product_name.as_deref(),
icon_url = snapshot.icon_url.as_deref(),
favicon_url = snapshot.favicon_url.as_deref(),
theme_color = snapshot.theme_color.as_deref(),
"instance branding updated"
);
} }
Ok(()) Ok(())
} }
@@ -138,11 +216,11 @@ impl BrandingImageOrigins {
upstream_url: String, upstream_url: String,
interval: Duration, interval: Duration,
) -> JoinHandle<()> { ) -> JoinHandle<()> {
let origins = self.clone(); let branding = self.clone();
tokio::spawn(async move { tokio::spawn(async move {
let mut consecutive_failures: u32 = 0; let mut consecutive_failures: u32 = 0;
loop { loop {
let wait = match origins.refresh(&client, &upstream_url).await { let wait = match branding.refresh(&client, &upstream_url).await {
Ok(()) => { Ok(()) => {
consecutive_failures = 0; consecutive_failures = 0;
interval interval
@@ -157,7 +235,7 @@ impl BrandingImageOrigins {
url = %upstream_url, url = %upstream_url,
consecutive_failures, consecutive_failures,
backoff_ms = backoff.as_millis() as u64, backoff_ms = backoff.as_millis() as u64,
"branding image origin refresh failed, keeping the previous origins" "instance branding refresh failed, keeping the previous branding"
); );
backoff backoff
} }
@@ -182,6 +260,13 @@ mod tests {
HttpEndpoint::parse("TEST_ENDPOINT", value).unwrap() HttpEndpoint::parse("TEST_ENDPOINT", value).unwrap()
} }
fn seeded_with(origins: &[&str]) -> BrandingSnapshot {
BrandingSnapshot {
image_origins: origins.iter().map(|origin| endpoint(origin)).collect(),
..BrandingSnapshot::default()
}
}
fn fast_client() -> reqwest::Client { fn fast_client() -> reqwest::Client {
reqwest::Client::builder() reqwest::Client::builder()
.timeout(Duration::from_secs(2)) .timeout(Duration::from_secs(2))
@@ -227,7 +312,7 @@ mod tests {
}); });
assert_eq!( assert_eq!(
sources(&origins_from_discovery(&discovery).unwrap()), sources(&snapshot_from_discovery(&discovery).unwrap().image_origins),
["https://example.test", "http://assets.example.test:8443"] ["https://example.test", "http://assets.example.test:8443"]
); );
} }
@@ -246,27 +331,90 @@ mod tests {
} }
}); });
assert!(origins_from_discovery(&discovery).unwrap().is_empty());
}
#[test]
fn a_document_without_branding_is_not_a_discovery_snapshot() {
assert!(origins_from_discovery(&serde_json::json!({"api_code_version": "v1"})).is_none());
assert!(origins_from_discovery(&serde_json::json!({"app_public": {}})).is_none());
assert!( assert!(
origins_from_discovery(&serde_json::json!({"app_public": {"branding": null}})) snapshot_from_discovery(&discovery)
.is_none()
);
assert!(
origins_from_discovery(&serde_json::json!({"app_public": {"branding": {}}}))
.unwrap() .unwrap()
.image_origins
.is_empty() .is_empty()
); );
} }
#[test]
fn a_document_without_branding_is_not_a_discovery_snapshot() {
assert!(snapshot_from_discovery(&serde_json::json!({"api_code_version": "v1"})).is_none());
assert!(snapshot_from_discovery(&serde_json::json!({"app_public": {}})).is_none());
assert!(
snapshot_from_discovery(&serde_json::json!({"app_public": {"branding": null}}))
.is_none()
);
assert_eq!(
snapshot_from_discovery(&serde_json::json!({"app_public": {"branding": {}}})),
Some(BrandingSnapshot::default())
);
}
#[test]
fn the_favicon_falls_back_to_the_icon_like_the_app() {
let favicon = |branding: serde_json::Value| {
snapshot_from_discovery(&serde_json::json!({"app_public": {"branding": branding}}))
.unwrap()
.favicon_url
.map(|url| url.to_string())
};
assert_eq!(
favicon(serde_json::json!({
"icon_url": "https://example.test/icon.png",
"favicon_url": " https://example.test/favicon.png "
})),
Some("https://example.test/favicon.png".to_owned())
);
assert_eq!(
favicon(serde_json::json!({
"icon_url": "https://example.test/icon.png",
"favicon_url": null
})),
Some("https://example.test/icon.png".to_owned())
);
assert_eq!(
favicon(serde_json::json!({"icon_url": null, "favicon_url": null})),
None
);
assert_eq!(
favicon(serde_json::json!({"favicon_url": "javascript:alert(1)"})),
None
);
}
#[test]
fn the_environment_favicon_falls_back_to_the_icon() {
let favicon = |favicon: Option<&str>, icon: Option<&str>| {
snapshot_from_environment(|name| match name {
"FLUXER_APP_FAVICON_URL" => favicon.map(str::to_owned),
"FLUXER_APP_ICON_URL" => icon.map(str::to_owned),
_ => None,
})
.favicon_url
.map(|url| url.to_string())
};
assert_eq!(
favicon(
Some("https://example.test/favicon.png"),
Some("https://example.test/icon.png")
),
Some("https://example.test/favicon.png".to_owned())
);
assert_eq!(
favicon(Some(" "), Some("https://example.test/icon.png")),
Some("https://example.test/icon.png".to_owned())
);
assert_eq!(favicon(None, None), None);
}
#[test] #[test]
fn environment_branding_images_yield_their_distinct_origins() { fn environment_branding_images_yield_their_distinct_origins() {
let origins = origins_from_environment(|name| match name { let snapshot = snapshot_from_environment(|name| match name {
"FLUXER_APP_ICON_URL" => Some("https://example.test/logo.png".to_owned()), "FLUXER_APP_ICON_URL" => Some("https://example.test/logo.png".to_owned()),
"FLUXER_APP_FAVICON_URL" => Some("https://example.test/favicon.png".to_owned()), "FLUXER_APP_FAVICON_URL" => Some("https://example.test/favicon.png".to_owned()),
"FLUXER_APP_WORDMARK_URL" => Some("https://cdn.example.test/wordmark.svg".to_owned()), "FLUXER_APP_WORDMARK_URL" => Some("https://cdn.example.test/wordmark.svg".to_owned()),
@@ -276,26 +424,109 @@ mod tests {
}); });
assert_eq!( assert_eq!(
sources(&origins), sources(&snapshot.image_origins),
["https://example.test", "https://cdn.example.test"] ["https://example.test", "https://cdn.example.test"]
); );
} }
#[test]
fn discovery_fills_every_head_field_from_the_branding_object() {
let snapshot = snapshot_from_discovery(&serde_json::json!({
"app_public": {
"branding": {
"product_name": " Example Chat ",
"icon_url": "https://example.test/icon.png",
"favicon_url": "https://example.test/fav icon.png",
"theme_color": "#7b2ce4"
}
}
}))
.unwrap();
assert_eq!(snapshot.product_name.as_deref(), Some("Example Chat"));
assert_eq!(
snapshot.icon_url.as_deref(),
Some("https://example.test/icon.png")
);
assert_eq!(
snapshot.favicon_url.as_deref(),
Some("https://example.test/fav%20icon.png")
);
assert_eq!(snapshot.theme_color.as_deref(), Some("#7b2ce4"));
}
#[test]
fn the_environment_fills_every_head_field() {
let snapshot = snapshot_from_environment(|name| match name {
"FLUXER_APP_PRODUCT_NAME" => Some("Example".to_owned()),
"FLUXER_APP_ICON_URL" => Some("https://example.test/icon.png".to_owned()),
"FLUXER_APP_THEME_COLOR" => Some("7B2CE4".to_owned()),
_ => None,
});
assert_eq!(snapshot.product_name.as_deref(), Some("Example"));
assert_eq!(
snapshot.icon_url.as_deref(),
Some("https://example.test/icon.png")
);
assert_eq!(
snapshot.favicon_url.as_deref(),
Some("https://example.test/icon.png")
);
assert_eq!(snapshot.theme_color.as_deref(), Some("#7b2ce4"));
assert_eq!(
snapshot_from_environment(|_| Some(" ".to_owned())),
BrandingSnapshot::default()
);
}
#[test]
fn a_theme_colour_is_normalised_like_the_api() {
assert_eq!(theme_color("#7B2CE4").as_deref(), Some("#7b2ce4"));
assert_eq!(theme_color(" abc ").as_deref(), Some("#aabbcc"));
assert_eq!(theme_color("#000001").as_deref(), Some("#000001"));
for unset in [
"#000000",
"#000",
"",
"#12345",
"##abc",
"rgb(1, 2, 3)",
"rebeccapurple",
"#abcdeg",
"\"><script>",
] {
assert_eq!(theme_color(unset), None, "{unset} is not a theme colour");
}
}
#[test]
fn an_icon_without_a_network_origin_is_not_served() {
let snapshot = snapshot_from_environment(|name| match name {
"FLUXER_APP_ICON_URL" => Some("javascript:alert(1)".to_owned()),
"FLUXER_APP_FAVICON_URL" => Some("/branding/favicon.png".to_owned()),
_ => None,
});
assert_eq!(snapshot.icon_url, None);
assert_eq!(snapshot.favicon_url, None);
}
#[test] #[test]
fn only_a_self_hosted_instance_seeds_origins_from_its_environment() { fn only_a_self_hosted_instance_seeds_origins_from_its_environment() {
let mut config = AppProxyConfig::from_env(); let mut config = AppProxyConfig::from_env();
config.branding_image_origins = vec![endpoint("https://example.test")]; config.branding = seeded_with(&["https://example.test"]);
config.self_hosted = false; config.self_hosted = false;
assert!( assert!(
BrandingImageOrigins::from_config(&config) InstanceBranding::from_config(&config)
.current() .image_origins()
.is_empty() .is_empty()
); );
config.self_hosted = true; config.self_hosted = true;
assert_eq!( assert_eq!(
sources(&BrandingImageOrigins::from_config(&config).current()), sources(&InstanceBranding::from_config(&config).image_origins()),
["https://example.test"] ["https://example.test"]
); );
} }
@@ -310,36 +541,52 @@ mod tests {
})), })),
)]) )])
.await; .await;
let origins = BrandingImageOrigins::new(vec![endpoint("https://env.example.test")]); let origins = InstanceBranding::new(seeded_with(&["https://env.example.test"]));
assert_eq!(sources(&origins.current()), ["https://env.example.test"]); assert_eq!(
sources(&origins.image_origins()),
["https://env.example.test"]
);
origins.refresh(&fast_client(), &upstream).await.unwrap(); origins.refresh(&fast_client(), &upstream).await.unwrap();
assert_eq!( assert_eq!(
sources(&origins.current()), sources(&origins.image_origins()),
["https://dashboard.example.test"] ["https://dashboard.example.test"]
); );
assert_eq!(
origins.snapshot().favicon_url.as_deref(),
Some("https://dashboard.example.test/icon.png")
);
} }
#[tokio::test] #[tokio::test]
async fn a_failed_refresh_keeps_the_previous_origins() { async fn a_failed_refresh_keeps_the_previous_origins() {
let seeded = || BrandingImageOrigins::new(vec![endpoint("https://env.example.test")]); let seeded = || InstanceBranding::new(seeded_with(&["https://env.example.test"]));
let origins = seeded(); let origins = seeded();
let unavailable = let unavailable =
serve_discovery(vec![(StatusCode::SERVICE_UNAVAILABLE, String::new())]).await; serve_discovery(vec![(StatusCode::SERVICE_UNAVAILABLE, String::new())]).await;
assert!(origins.refresh(&fast_client(), &unavailable).await.is_err()); assert!(origins.refresh(&fast_client(), &unavailable).await.is_err());
assert_eq!(sources(&origins.current()), ["https://env.example.test"]); assert_eq!(
sources(&origins.image_origins()),
["https://env.example.test"]
);
let origins = seeded(); let origins = seeded();
let not_json = serve_discovery(vec![(StatusCode::OK, "<html>".to_owned())]).await; let not_json = serve_discovery(vec![(StatusCode::OK, "<html>".to_owned())]).await;
assert!(origins.refresh(&fast_client(), &not_json).await.is_err()); assert!(origins.refresh(&fast_client(), &not_json).await.is_err());
assert_eq!(sources(&origins.current()), ["https://env.example.test"]); assert_eq!(
sources(&origins.image_origins()),
["https://env.example.test"]
);
let origins = seeded(); let origins = seeded();
let no_branding = serve_discovery(vec![(StatusCode::OK, "{}".to_owned())]).await; let no_branding = serve_discovery(vec![(StatusCode::OK, "{}".to_owned())]).await;
assert!(origins.refresh(&fast_client(), &no_branding).await.is_err()); assert!(origins.refresh(&fast_client(), &no_branding).await.is_err());
assert_eq!(sources(&origins.current()), ["https://env.example.test"]); assert_eq!(
sources(&origins.image_origins()),
["https://env.example.test"]
);
let origins = seeded(); let origins = seeded();
let oversized = serve_discovery(vec![( let oversized = serve_discovery(vec![(
@@ -353,7 +600,10 @@ mod tests {
)]) )])
.await; .await;
assert!(origins.refresh(&fast_client(), &oversized).await.is_err()); assert!(origins.refresh(&fast_client(), &oversized).await.is_err());
assert_eq!(sources(&origins.current()), ["https://env.example.test"]); assert_eq!(
sources(&origins.image_origins()),
["https://env.example.test"]
);
let origins = seeded(); let origins = seeded();
assert!( assert!(
@@ -362,7 +612,10 @@ mod tests {
.await .await
.is_err() .is_err()
); );
assert_eq!(sources(&origins.current()), ["https://env.example.test"]); assert_eq!(
sources(&origins.image_origins()),
["https://env.example.test"]
);
} }
#[tokio::test] #[tokio::test]
@@ -383,16 +636,22 @@ mod tests {
), ),
]) ])
.await; .await;
let origins = BrandingImageOrigins::new(Vec::new()); let origins = InstanceBranding::new(BrandingSnapshot::default());
let mut changes = origins.0.subscribe(); let mut changes = origins.0.subscribe();
let refresh = let refresh =
origins.spawn_refresh_loop(fast_client(), upstream, Duration::from_millis(10)); origins.spawn_refresh_loop(fast_client(), upstream, Duration::from_millis(10));
changes.changed().await.unwrap(); changes.changed().await.unwrap();
assert_eq!(sources(&origins.current()), ["https://first.example.test"]); assert_eq!(
sources(&origins.image_origins()),
["https://first.example.test"]
);
changes.changed().await.unwrap(); changes.changed().await.unwrap();
assert_eq!(sources(&origins.current()), ["https://second.example.test"]); assert_eq!(
sources(&origins.image_origins()),
["https://second.example.test"]
);
refresh.abort(); refresh.abort();
} }
} }
+2 -4
View File
@@ -359,7 +359,7 @@ pub struct AppProxyConfig {
pub media_endpoint: Option<HttpEndpoint>, pub media_endpoint: Option<HttpEndpoint>,
pub s3_public_endpoint: Option<HttpEndpoint>, pub s3_public_endpoint: Option<HttpEndpoint>,
pub s3_uploads_endpoint: Option<HttpEndpoint>, pub s3_uploads_endpoint: Option<HttpEndpoint>,
pub branding_image_origins: Vec<HttpEndpoint>, pub branding: crate::branding_images::BrandingSnapshot,
pub discovery_upstream_url: String, pub discovery_upstream_url: String,
pub discovery_refresh_interval_ms: u64, pub discovery_refresh_interval_ms: u64,
pub release_channel: ReleaseChannel, pub release_channel: ReleaseChannel,
@@ -485,9 +485,7 @@ impl AppProxyConfig {
), ),
s3_public_endpoint, s3_public_endpoint,
s3_uploads_endpoint, s3_uploads_endpoint,
branding_image_origins: crate::branding_images::origins_from_environment( branding: crate::branding_images::snapshot_from_environment(cfg::env_value),
cfg::env_value,
),
discovery_upstream_url: cfg::read_env( discovery_upstream_url: cfg::read_env(
"DISCOVERY_UPSTREAM_URL", "DISCOVERY_UPSTREAM_URL",
DEFAULT_DISCOVERY_UPSTREAM_URL, DEFAULT_DISCOVERY_UPSTREAM_URL,
+1 -1
View File
@@ -39,7 +39,7 @@ fn main() -> anyhow::Result<()> {
.context("failed to load the Fluxer app proxy static directory")?; .context("failed to load the Fluxer app proxy static directory")?;
let branding_refresh = state.config.self_hosted.then(|| { let branding_refresh = state.config.self_hosted.then(|| {
state.branding_image_origins.start_background_refresh( state.branding.start_background_refresh(
state.http_client.clone(), state.http_client.clone(),
state.config.discovery_upstream_url.clone(), state.config.discovery_upstream_url.clone(),
Duration::from_millis(state.config.discovery_refresh_interval_ms), Duration::from_millis(state.config.discovery_refresh_interval_ms),
+1 -3
View File
@@ -235,9 +235,7 @@ mod tests {
LocalFileStore::load_blocking(std::path::Path::new(&config.static_dir), &budgets) LocalFileStore::load_blocking(std::path::Path::new(&config.static_dir), &budgets)
.expect("the test static directory must exist"); .expect("the test static directory must exist");
AppState { AppState {
branding_image_origins: crate::branding_images::BrandingImageOrigins::from_config( branding: crate::branding_images::InstanceBranding::from_config(&config),
&config,
),
config: Arc::new(config), config: Arc::new(config),
csp, csp,
http_client: build_http_client().unwrap(), http_client: build_http_client().unwrap(),
+1 -3
View File
@@ -28,9 +28,7 @@ mod tests {
crate::local_files::LocalFileStore::load_blocking(std::path::Path::new("."), &budgets) crate::local_files::LocalFileStore::load_blocking(std::path::Path::new("."), &budgets)
.expect("the test static directory must exist"); .expect("the test static directory must exist");
AppState { AppState {
branding_image_origins: crate::branding_images::BrandingImageOrigins::from_config( branding: crate::branding_images::InstanceBranding::from_config(&config),
&config,
),
config: Arc::new(config), config: Arc::new(config),
csp, csp,
http_client: build_http_client().unwrap(), http_client: build_http_client().unwrap(),
+1
View File
@@ -53,6 +53,7 @@ pub fn build_router(state: AppState) -> Router {
.route("/version.json", get(spa_static::version_json)) .route("/version.json", get(spa_static::version_json))
.route("/manifest.json", get(spa_static::manifest_json)) .route("/manifest.json", get(spa_static::manifest_json))
.route("/browserconfig.xml", get(spa_static::browserconfig_xml)) .route("/browserconfig.xml", get(spa_static::browserconfig_xml))
.route("/favicon.ico", get(spa_static::favicon_ico))
.route("/sw.js", get(spa_static::service_worker)) .route("/sw.js", get(spa_static::service_worker))
.route("/sw.js.map", get(spa_static::service_worker_map)) .route("/sw.js.map", get(spa_static::service_worker_map))
.fallback(get(spa_index::spa_catch_all)) .fallback(get(spa_index::spa_catch_all))
+217 -18
View File
@@ -1,5 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-or-later // SPDX-License-Identifier: AGPL-3.0-or-later
use crate::branding_images::BrandingSnapshot;
use crate::config::HttpEndpoint; use crate::config::HttpEndpoint;
use crate::csp::{CspHeaderValues, inline_script_hashes}; use crate::csp::{CspHeaderValues, inline_script_hashes};
use crate::state::{ use crate::state::{
@@ -27,6 +28,16 @@ const CSP_NONCE_PLACEHOLDER_ATTRIBUTE: &str = r#" nonce="{{CSP_NONCE_PLACEHOLDER
const MEDIA_PRECONNECT_TAG: &str = r#"<link rel="preconnect" href="{{MEDIA_ENDPOINT}}">"#; const MEDIA_PRECONNECT_TAG: &str = r#"<link rel="preconnect" href="{{MEDIA_ENDPOINT}}">"#;
const SAME_ORIGIN_API_META_TAG: &str = r#"<meta name="fluxer-api-origin" content="self">"#; const SAME_ORIGIN_API_META_TAG: &str = r#"<meta name="fluxer-api-origin" content="self">"#;
const HEAD_CLOSE_TAG: &str = "</head>"; const HEAD_CLOSE_TAG: &str = "</head>";
const TITLE_START: &str = "<title";
const TITLE_END: &str = "</title>";
const FAVICON_LINK_START: &str = r#"<link rel="icon""#;
const APPLE_TOUCH_ICON_LINK_START: &str = r#"<link rel="apple-touch-icon""#;
const APPLE_TITLE_META_START: &str = r#"<meta name="apple-mobile-web-app-title""#;
const THEME_COLOR_META_STARTS: [&str; 3] = [
r#"<meta name="theme-color""#,
r#"<meta name="msapplication-navbutton-color""#,
r#"<meta name="msapplication-TileColor""#,
];
const STATIC_PRECONNECT_TAGS: [&str; 2] = [ const STATIC_PRECONNECT_TAGS: [&str; 2] = [
r#"<link rel="preconnect" href="{{STATIC_CDN_ENDPOINT}}">"#, r#"<link rel="preconnect" href="{{STATIC_CDN_ENDPOINT}}">"#,
r#"<link rel="preconnect" href="{{STATIC_CDN_ENDPOINT}}" crossorigin>"#, r#"<link rel="preconnect" href="{{STATIC_CDN_ENDPOINT}}" crossorigin>"#,
@@ -181,7 +192,10 @@ async fn serve_spa_index(state: &AppState, headers: &HeaderMap, request_path: &s
Err(response) => return response, Err(response) => return response,
}; };
let raw_html = if state.config.self_hosted { let raw_html = if state.config.self_hosted {
strip_link_preview_metadata(&raw_html) apply_instance_branding(
strip_link_preview_metadata(&raw_html),
&state.branding.snapshot(),
)
} else { } else {
raw_html raw_html
}; };
@@ -206,7 +220,7 @@ async fn serve_spa_index(state: &AppState, headers: &HeaderMap, request_path: &s
}; };
let csp = state.csp.spa_headers( let csp = state.csp.spa_headers(
&inline_script_hashes(&html), &inline_script_hashes(&html),
&state.branding_image_origins.current(), &state.branding.image_origins(),
); );
build_spa_response(html.into_boxed_str(), &csp, should_bust_dev_assets) build_spa_response(html.into_boxed_str(), &csp, should_bust_dev_assets)
} }
@@ -317,20 +331,83 @@ fn apply_media_preconnect(html: &str, media: &str, static_cdn: &str) -> String {
} }
fn strip_link_preview_metadata(html: &str) -> String { fn strip_link_preview_metadata(html: &str) -> String {
let html = remove_elements(html, "<title", "</title>"); replace_elements(html, r#"<meta name="description""#, ">", "")
remove_elements(&html, r#"<meta name="description""#, ">")
} }
fn remove_elements(html: &str, start: &str, end: &str) -> String { fn apply_instance_branding(mut html: String, branding: &BrandingSnapshot) -> String {
if let Some(product_name) = &branding.product_name {
let product_name = escape_html(product_name);
html = replace_elements(
&html,
TITLE_START,
TITLE_END,
&format!("{TITLE_START}>{product_name}{TITLE_END}"),
);
html = replace_elements(
&html,
APPLE_TITLE_META_START,
">",
&format!(r#"{APPLE_TITLE_META_START} content="{product_name}">"#),
);
}
if let Some(favicon_url) = &branding.favicon_url {
html = replace_elements(
&html,
FAVICON_LINK_START,
">",
&format!(
r#"{FAVICON_LINK_START} href="{}">"#,
escape_html(favicon_url)
),
);
}
if let Some(touch_icon_url) = branding.icon_url.as_ref().or(branding.favicon_url.as_ref()) {
html = replace_elements(
&html,
APPLE_TOUCH_ICON_LINK_START,
">",
&format!(
r#"{APPLE_TOUCH_ICON_LINK_START} href="{}">"#,
escape_html(touch_icon_url)
),
);
}
if let Some(theme_color) = &branding.theme_color {
let theme_color = escape_html(theme_color);
for start in THEME_COLOR_META_STARTS {
html = replace_elements(
&html,
start,
">",
&format!(r#"{start} content="{theme_color}">"#),
);
}
}
html
}
fn escape_html(value: &str) -> String {
value
.replace('&', "&amp;")
.replace('"', "&quot;")
.replace('<', "&lt;")
.replace('>', "&gt;")
}
fn replace_elements(html: &str, start: &str, end: &str, replacement: &str) -> String {
let mut rest = html; let mut rest = html;
let mut output = String::with_capacity(html.len()); let mut output = String::with_capacity(html.len() + replacement.len());
while let Some(index) = rest.find(start) { while let Some(index) = rest.find(start) {
let Some(length) = rest[index..].find(end) else { let Some(length) = rest[index..].find(end) else {
break; break;
}; };
output.push_str(&rest[..index]); output.push_str(&rest[..index]);
rest = rest[index + length + end.len()..].trim_start_matches('\n'); output.push_str(replacement);
rest = &rest[index + length + end.len()..];
if replacement.is_empty() {
rest = rest.trim_start_matches('\n');
}
} }
output.push_str(rest); output.push_str(rest);
@@ -622,6 +699,7 @@ mod tests {
static_root_file_cache_control(request_path).is_some() static_root_file_cache_control(request_path).is_some()
} }
use crate::branding_images::InstanceBranding;
use crate::config::{AppProxyConfig, ReleaseChannel}; use crate::config::{AppProxyConfig, ReleaseChannel};
use crate::state::LOCAL_FILE_READS_IN_FLIGHT_MAX; use crate::state::LOCAL_FILE_READS_IN_FLIGHT_MAX;
use axum::Router; use axum::Router;
@@ -780,12 +858,10 @@ mod tests {
#[test] #[test]
fn the_shipped_shell_loses_every_link_preview_field_when_stripped() { fn the_shipped_shell_loses_every_link_preview_field_when_stripped() {
assert!(SHIPPED_APP_SHELL.contains("<title>"));
assert!(SHIPPED_APP_SHELL.contains(r#"<meta name="description""#)); assert!(SHIPPED_APP_SHELL.contains(r#"<meta name="description""#));
let stripped = strip_link_preview_metadata(SHIPPED_APP_SHELL); let stripped = strip_link_preview_metadata(SHIPPED_APP_SHELL);
assert!(!stripped.contains("<title"));
assert!(!stripped.contains(r#"name="description""#)); assert!(!stripped.contains(r#"name="description""#));
assert!(!stripped.contains("og:")); assert!(!stripped.contains("og:"));
assert!(!stripped.contains("twitter:")); assert!(!stripped.contains("twitter:"));
@@ -887,9 +963,10 @@ mod tests {
assert_eq!(response.status(), StatusCode::OK); assert_eq!(response.status(), StatusCode::OK);
let served = read_document(response).await; let served = read_document(response).await;
assert!(!served.contains("<title"));
assert!(!served.contains(r#"name="description""#)); assert!(!served.contains(r#"name="description""#));
assert!(served.contains(r#"<meta name="viewport""#)); assert!(served.contains(r#"<meta name="viewport""#));
assert_eq!(served.matches("<title").count(), 1);
assert!(served.contains("<title>Fluxer</title>"));
} }
fn image_sources_of(policy: &str) -> Vec<String> { fn image_sources_of(policy: &str) -> Vec<String> {
@@ -916,8 +993,10 @@ mod tests {
let plain_document = read_document(plain).await; let plain_document = read_document(plain).await;
assert!(!plain_images.contains(&"https://example.test".to_owned())); assert!(!plain_images.contains(&"https://example.test".to_owned()));
state.branding_image_origins = state.branding = InstanceBranding::new(BrandingSnapshot {
crate::branding_images::BrandingImageOrigins::new(vec![branding_origin()]); image_origins: Arc::from([branding_origin()]),
..BrandingSnapshot::default()
});
let branded = serve_spa_index(&state, &HeaderMap::new(), "/login").await; let branded = serve_spa_index(&state, &HeaderMap::new(), "/login").await;
let branded_policy = policy_of(&branded); let branded_policy = policy_of(&branded);
let branded_document = read_document(branded).await; let branded_document = read_document(branded).await;
@@ -934,10 +1013,12 @@ mod tests {
async fn the_official_instance_allows_no_branding_image_origins() { async fn the_official_instance_allows_no_branding_image_origins() {
let mut config = AppProxyConfig::from_env(); let mut config = AppProxyConfig::from_env();
config.self_hosted = false; config.self_hosted = false;
config.branding_image_origins = vec![branding_origin()]; config.branding = BrandingSnapshot {
image_origins: Arc::from([branding_origin()]),
..BrandingSnapshot::default()
};
let mut state = spa_state_serving(SHIPPED_APP_SHELL); let mut state = spa_state_serving(SHIPPED_APP_SHELL);
state.branding_image_origins = state.branding = InstanceBranding::from_config(&config);
crate::branding_images::BrandingImageOrigins::from_config(&config);
let response = serve_spa_index(&state, &HeaderMap::new(), "/login").await; let response = serve_spa_index(&state, &HeaderMap::new(), "/login").await;
@@ -946,6 +1027,126 @@ mod tests {
); );
} }
fn full_branding() -> BrandingSnapshot {
BrandingSnapshot {
image_origins: Arc::from([branding_origin()]),
product_name: Some(Arc::from("Example")),
icon_url: Some(Arc::from("https://example.test/icon.png")),
favicon_url: Some(Arc::from("https://example.test/favicon.png")),
theme_color: Some(Arc::from("#7b2ce4")),
}
}
async fn served_self_hosted_head(branding: BrandingSnapshot) -> (String, String) {
let mut state = assemble_spa_state(SpaIndexSource::bundled(SHIPPED_APP_SHELL), None, true);
state.branding = InstanceBranding::new(branding);
let response = serve_spa_index(&state, &HeaderMap::new(), "/").await;
assert_eq!(response.status(), StatusCode::OK);
let policy = policy_of(&response);
(read_document(response).await, policy)
}
#[tokio::test]
async fn a_self_hosted_instance_serves_its_branded_favicon() {
let (served, _) = served_self_hosted_head(BrandingSnapshot {
image_origins: Arc::from([branding_origin()]),
favicon_url: Some(Arc::from("https://example.test/favicon.png?v=1&size=32")),
..BrandingSnapshot::default()
})
.await;
assert_eq!(served.matches(r#"<link rel="icon""#).count(), 1);
assert!(served.contains(
r#"<link rel="icon" href="https://example.test/favicon.png?v=1&amp;size=32">"#
));
assert!(!served.contains("/web/favicon-32x32.png"));
assert_eq!(served.matches(r#"<link rel="apple-touch-icon""#).count(), 1);
assert!(served.contains(
r#"<link rel="apple-touch-icon" href="https://example.test/favicon.png?v=1&amp;size=32">"#
));
assert!(!served.contains("/web/apple-touch-icon.png"));
}
#[tokio::test]
async fn a_self_hosted_instance_serves_its_branded_head() {
let (served, policy) = served_self_hosted_head(full_branding()).await;
assert_eq!(served.matches("<title").count(), 1);
assert!(served.contains("<title>Example</title>"));
assert!(served.contains(r#"<meta name="apple-mobile-web-app-title" content="Example">"#));
assert!(served.contains(r#"<link rel="icon" href="https://example.test/favicon.png">"#));
assert!(
served
.contains(r#"<link rel="apple-touch-icon" href="https://example.test/icon.png">"#)
);
for name in [
"theme-color",
"msapplication-navbutton-color",
"msapplication-TileColor",
] {
assert_eq!(
served.matches(&format!(r#"<meta name="{name}""#)).count(),
1
);
assert!(served.contains(&format!(r##"<meta name="{name}" content="#7b2ce4">"##)));
}
assert!(!served.contains("4641D9"));
assert!(!served.contains("Fluxer</title>"));
assert!(!served.contains(r#"name="description""#));
assert_every_inline_script_is_granted(&served, &policy);
}
#[tokio::test]
async fn a_self_hosted_instance_without_branding_keeps_the_default_head() {
let (served, _) = served_self_hosted_head(BrandingSnapshot::default()).await;
assert_eq!(
served,
strip_link_preview_metadata(
&render_spa_document(SHIPPED_APP_SHELL, "", "", None).unwrap()
)
);
}
#[test]
fn branded_head_values_cannot_break_out_of_their_markup() {
let html = r#"<head><title>Fluxer</title><meta name="apple-mobile-web-app-title" content="Fluxer"><link rel="icon" type="image/png" href="/web/favicon-32x32.png"><link rel="apple-touch-icon" href="/web/apple-touch-icon.png"></head>"#;
let branding = BrandingSnapshot {
product_name: Some(Arc::from(r#"A & B</title><script>x()</script>""#)),
favicon_url: Some(Arc::from(r#"https://example.test/"><script>x()</script>"#)),
..BrandingSnapshot::default()
};
assert_eq!(
apply_instance_branding(html.to_owned(), &branding),
r#"<head><title>A &amp; B&lt;/title&gt;&lt;script&gt;x()&lt;/script&gt;&quot;</title><meta name="apple-mobile-web-app-title" content="A &amp; B&lt;/title&gt;&lt;script&gt;x()&lt;/script&gt;&quot;"><link rel="icon" href="https://example.test/&quot;&gt;&lt;script&gt;x()&lt;/script&gt;"><link rel="apple-touch-icon" href="https://example.test/&quot;&gt;&lt;script&gt;x()&lt;/script&gt;"></head>"#
);
}
#[tokio::test]
async fn the_official_instance_ignores_every_branding_setting() {
let plain = read_document(
serve_spa_index(
&spa_state_serving(SHIPPED_APP_SHELL),
&HeaderMap::new(),
"/",
)
.await,
)
.await;
let mut config = AppProxyConfig::from_env();
config.self_hosted = false;
config.branding = full_branding();
let mut state = spa_state_serving(SHIPPED_APP_SHELL);
state.branding = InstanceBranding::from_config(&config);
let served = read_document(serve_spa_index(&state, &HeaderMap::new(), "/").await).await;
assert_eq!(served, plain);
assert!(served.contains("/web/favicon-32x32.png"));
assert!(!served.contains("https://example.test/"));
}
#[tokio::test] #[tokio::test]
async fn the_official_instance_keeps_its_link_preview_metadata() { async fn the_official_instance_keeps_its_link_preview_metadata() {
let state = spa_state_serving(SHIPPED_APP_SHELL); let state = spa_state_serving(SHIPPED_APP_SHELL);
@@ -1137,9 +1338,7 @@ mod tests {
crate::local_files::LocalFileStore::load_blocking(std::path::Path::new("."), &budgets) crate::local_files::LocalFileStore::load_blocking(std::path::Path::new("."), &budgets)
.expect("the test static directory must exist"); .expect("the test static directory must exist");
AppState { AppState {
branding_image_origins: crate::branding_images::BrandingImageOrigins::from_config( branding: InstanceBranding::from_config(&config),
&config,
),
config: Arc::new(config), config: Arc::new(config),
csp, csp,
http_client: reqwest::Client::new(), http_client: reqwest::Client::new(),
+342 -11
View File
@@ -1,5 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-or-later // SPDX-License-Identifier: AGPL-3.0-or-later
use crate::branding_images::BrandingSnapshot;
use crate::config::HttpEndpoint; use crate::config::HttpEndpoint;
use crate::local_files::{LocalFileResponseOptions, serve_text_file}; use crate::local_files::{LocalFileResponseOptions, serve_text_file};
use crate::state::{AppState, MAX_STATIC_TEXT_FILE_BYTES}; use crate::state::{AppState, MAX_STATIC_TEXT_FILE_BYTES};
@@ -28,25 +29,37 @@ pub async fn version_json(State(state): State<AppState>, headers: HeaderMap) ->
} }
pub async fn manifest_json(State(state): State<AppState>, headers: HeaderMap) -> Response { pub async fn manifest_json(State(state): State<AppState>, headers: HeaderMap) -> Response {
let branding = state.config.self_hosted.then(|| state.branding.snapshot());
serve_static_text_file_with_substitutions( serve_static_text_file_with_substitutions(
&state, &state,
&headers, &headers,
"manifest.json", "manifest.json",
"application/manifest+json", "application/manifest+json",
state.config.static_cdn_endpoint.as_ref(), state.config.static_cdn_endpoint.as_ref(),
Some(&state.config.manifest_scope_extensions), |text| {
finish_manifest(
text,
&state.config.manifest_scope_extensions,
branding.as_deref(),
)
},
) )
.await .await
} }
fn with_scope_extensions(text: String, origins: &[String]) -> String { fn finish_manifest(
if origins.is_empty() { text: String,
scope_extension_origins: &[String],
branding: Option<&BrandingSnapshot>,
) -> String {
if scope_extension_origins.is_empty() && branding.is_none() {
return text; return text;
} }
let Ok(serde_json::Value::Object(mut manifest)) = serde_json::from_str(&text) else { let Ok(serde_json::Value::Object(mut manifest)) = serde_json::from_str(&text) else {
return text; return text;
}; };
let scope_extensions = origins if !scope_extension_origins.is_empty() {
let scope_extensions = scope_extension_origins
.iter() .iter()
.map(|origin| serde_json::json!({ "type": "origin", "origin": origin })) .map(|origin| serde_json::json!({ "type": "origin", "origin": origin }))
.collect(); .collect();
@@ -54,10 +67,38 @@ fn with_scope_extensions(text: String, origins: &[String]) -> String {
"scope_extensions".to_owned(), "scope_extensions".to_owned(),
serde_json::Value::Array(scope_extensions), serde_json::Value::Array(scope_extensions),
); );
}
if let Some(branding) = branding {
apply_manifest_branding(&mut manifest, branding);
}
serde_json::to_string_pretty(&manifest).unwrap_or(text) serde_json::to_string_pretty(&manifest).unwrap_or(text)
} }
fn apply_manifest_branding(
manifest: &mut serde_json::Map<String, serde_json::Value>,
branding: &BrandingSnapshot,
) {
manifest.remove("description");
if let Some(product_name) = &branding.product_name {
for key in ["name", "short_name"] {
manifest.insert(key.to_owned(), serde_json::json!(&**product_name));
}
}
if let Some(theme_color) = &branding.theme_color {
manifest.insert("theme_color".to_owned(), serde_json::json!(&**theme_color));
}
if let Some(icon_url) = &branding.icon_url {
manifest.insert(
"icons".to_owned(),
serde_json::json!([{ "src": &**icon_url, "sizes": "any", "purpose": "any" }]),
);
}
}
pub async fn browserconfig_xml(State(state): State<AppState>, headers: HeaderMap) -> Response { pub async fn browserconfig_xml(State(state): State<AppState>, headers: HeaderMap) -> Response {
if state.config.self_hosted {
return StatusCode::NOT_FOUND.into_response();
}
serve_static_text_file_with_cdn( serve_static_text_file_with_cdn(
&state, &state,
&headers, &headers,
@@ -68,6 +109,25 @@ pub async fn browserconfig_xml(State(state): State<AppState>, headers: HeaderMap
.await .await
} }
pub async fn favicon_ico(State(state): State<AppState>) -> Response {
let location = match state.branding.snapshot().favicon_url.as_deref() {
Some(favicon_url) => favicon_url.to_owned(),
None => format!(
"{}/web/favicon.ico",
state
.config
.static_cdn_endpoint
.as_ref()
.map_or("", HttpEndpoint::as_str)
.trim_end_matches('/')
),
};
match HeaderValue::from_str(&location) {
Ok(location) => (StatusCode::FOUND, [(header::LOCATION, location)]).into_response(),
Err(_) => StatusCode::NOT_FOUND.into_response(),
}
}
pub async fn service_worker(State(state): State<AppState>, headers: HeaderMap) -> Response { pub async fn service_worker(State(state): State<AppState>, headers: HeaderMap) -> Response {
serve_static_text_file( serve_static_text_file(
&state, &state,
@@ -104,7 +164,7 @@ async fn serve_static_text_file_with_cdn(
filename, filename,
content_type, content_type,
static_cdn_endpoint, static_cdn_endpoint,
None, |text| text,
) )
.await .await
} }
@@ -115,7 +175,7 @@ async fn serve_static_text_file_with_substitutions(
filename: &str, filename: &str,
content_type: &'static str, content_type: &'static str,
static_cdn_endpoint: Option<&HttpEndpoint>, static_cdn_endpoint: Option<&HttpEndpoint>,
scope_extensions: Option<&[String]>, finish: impl FnOnce(String) -> String,
) -> Response { ) -> Response {
let replacement = static_cdn_endpoint.map_or("", HttpEndpoint::as_str); let replacement = static_cdn_endpoint.map_or("", HttpEndpoint::as_str);
let mut response = serve_text_file( let mut response = serve_text_file(
@@ -129,10 +189,7 @@ async fn serve_static_text_file_with_substitutions(
max_bytes: MAX_STATIC_TEXT_FILE_BYTES, max_bytes: MAX_STATIC_TEXT_FILE_BYTES,
}, },
&[("{{STATIC_CDN_ENDPOINT}}", replacement)], &[("{{STATIC_CDN_ENDPOINT}}", replacement)],
|text| match scope_extensions { finish,
Some(origins) => with_scope_extensions(text, origins),
None => text,
},
) )
.await; .await;
apply_static_resource_security_policy(response.headers_mut(), state.csp.asset_header()); apply_static_resource_security_policy(response.headers_mut(), state.csp.asset_header());
@@ -150,7 +207,7 @@ mod tests {
) -> String { ) -> String {
let text = text.replace("{{STATIC_CDN_ENDPOINT}}", static_cdn_endpoint); let text = text.replace("{{STATIC_CDN_ENDPOINT}}", static_cdn_endpoint);
match scope_extensions { match scope_extensions {
Some(origins) => with_scope_extensions(text, origins), Some(origins) => finish_manifest(text, origins, None),
None => text, None => text,
} }
} }
@@ -228,4 +285,278 @@ mod tests {
BUILT_MANIFEST.replace("{{STATIC_CDN_ENDPOINT}}", "") BUILT_MANIFEST.replace("{{STATIC_CDN_ENDPOINT}}", "")
); );
} }
const GENERATED_MANIFEST: &str = r##"{
"name": "Fluxer",
"short_name": "Fluxer",
"description": "Fluxer is a free and open source instant messaging and VoIP platform built for friends, groups, and communities.",
"id": "/",
"start_url": "/app",
"display": "standalone",
"orientation": "portrait-primary",
"theme_color": "#4641D9",
"background_color": "#2b2d31",
"categories": ["social", "communication"],
"lang": "en",
"scope": "/",
"scope_extensions": [],
"icons": [
{
"src": "{{STATIC_CDN_ENDPOINT}}/web/android-chrome-192x192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "maskable any"
},
{
"src": "{{STATIC_CDN_ENDPOINT}}/web/android-chrome-512x512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable any"
}
]
}"##;
fn branded_manifest(branding: &BrandingSnapshot) -> serde_json::Value {
let text = finish_manifest(
GENERATED_MANIFEST.replace("{{STATIC_CDN_ENDPOINT}}", ""),
&[],
Some(branding),
);
serde_json::from_str(&text).expect("a branded manifest must stay valid JSON")
}
fn assert_installable_shape(manifest: &serde_json::Value) {
for key in ["name", "short_name", "id", "start_url", "scope", "display"] {
assert!(
manifest[key]
.as_str()
.is_some_and(|value| !value.is_empty()),
"the manifest lost its {key}"
);
}
let icons = manifest["icons"]
.as_array()
.expect("the manifest lost its icons");
assert!(icons.iter().any(|icon| {
icon["src"].as_str().is_some_and(|src| !src.is_empty())
&& icon["purpose"]
.as_str()
.is_some_and(|purpose| purpose.split(' ').any(|purpose| purpose == "any"))
}));
}
#[test]
fn a_self_hosted_manifest_takes_the_instance_branding() {
let manifest = branded_manifest(&BrandingSnapshot {
product_name: Some("Example Chat".into()),
icon_url: Some("https://example.test/icon.png".into()),
favicon_url: Some("https://example.test/favicon.png".into()),
theme_color: Some("#7b2ce4".into()),
..BrandingSnapshot::default()
});
assert_eq!(manifest["name"], "Example Chat");
assert_eq!(manifest["short_name"], "Example Chat");
assert_eq!(manifest["theme_color"], "#7b2ce4");
assert_eq!(
manifest["icons"],
serde_json::json!([{
"src": "https://example.test/icon.png",
"sizes": "any",
"purpose": "any"
}])
);
assert!(manifest.get("description").is_none());
assert_eq!(manifest["background_color"], "#2b2d31");
assert_eq!(manifest["start_url"], "/app");
assert_installable_shape(&manifest);
}
#[test]
fn an_unbranded_self_hosted_manifest_only_loses_its_description() {
let manifest = branded_manifest(&BrandingSnapshot::default());
let mut expected: serde_json::Value =
serde_json::from_str(&GENERATED_MANIFEST.replace("{{STATIC_CDN_ENDPOINT}}", ""))
.unwrap();
expected.as_object_mut().unwrap().remove("description");
assert_eq!(manifest, expected);
assert_installable_shape(&manifest);
}
#[test]
fn a_branded_product_name_stays_a_json_string() {
let product_name = "A \"quoted\" \\ name</script>\n\u{2028}";
let manifest = branded_manifest(&BrandingSnapshot {
product_name: Some(product_name.into()),
..BrandingSnapshot::default()
});
assert_eq!(manifest["name"], product_name);
assert_eq!(manifest["short_name"], product_name);
}
#[test]
fn the_official_manifest_is_left_byte_identical() {
let text =
GENERATED_MANIFEST.replace("{{STATIC_CDN_ENDPOINT}}", "https://fluxerstatic.com");
assert_eq!(finish_manifest(text.clone(), &[], None), text);
}
fn static_dir_with_generated_files() -> std::path::PathBuf {
static NEXT_DIR: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
let unique = NEXT_DIR.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
let root =
std::env::temp_dir().join(format!("fluxer-spa-static-{}-{unique}", std::process::id()));
std::fs::create_dir_all(&root).unwrap();
std::fs::write(root.join("manifest.json"), GENERATED_MANIFEST).unwrap();
std::fs::write(root.join("browserconfig.xml"), GENERATED_BROWSERCONFIG).unwrap();
root
}
fn state_serving(root: &std::path::Path, self_hosted: bool) -> AppState {
let mut config = crate::config::AppProxyConfig::from_env();
config.static_dir = root.to_str().unwrap().to_owned();
config.static_cdn_endpoint = None;
config.manifest_scope_extensions = Vec::new();
config.self_hosted = self_hosted;
config.branding = BrandingSnapshot {
product_name: Some("Example".into()),
icon_url: Some("https://example.test/icon.png".into()),
favicon_url: Some("https://example.test/favicon.png".into()),
theme_color: Some("#7b2ce4".into()),
..BrandingSnapshot::default()
};
let csp = std::sync::Arc::new(
crate::csp::CompiledCspPolicy::from_config(&config)
.expect("the test configuration must compile to a valid CSP"),
);
let budgets = crate::state::AppProxyBudgets::default();
let local_files = crate::local_files::LocalFileStore::load_blocking(root, &budgets)
.expect("the test static directory must load");
AppState {
branding: crate::branding_images::InstanceBranding::from_config(&config),
config: std::sync::Arc::new(config),
csp,
http_client: reqwest::Client::new(),
spa_index_source: crate::state::SpaIndexSource::bundled(""),
local_asset_prefixes: None,
budgets,
local_files,
}
}
async fn fetch(state: AppState, path: &str) -> Response {
use tower::ServiceExt as _;
crate::routes::build_router(state)
.oneshot(
axum::http::Request::builder()
.uri(path)
.body(axum::body::Body::empty())
.unwrap(),
)
.await
.unwrap()
}
async fn body_of(response: Response) -> String {
let bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.unwrap();
String::from_utf8(bytes.to_vec()).unwrap()
}
#[tokio::test]
async fn the_served_manifest_is_branded_only_on_a_self_hosted_instance() {
let root = static_dir_with_generated_files();
let official = fetch(state_serving(&root, false), "/manifest.json").await;
assert_eq!(official.status(), StatusCode::OK);
assert_eq!(
body_of(official).await,
GENERATED_MANIFEST.replace("{{STATIC_CDN_ENDPOINT}}", "")
);
let self_hosted = fetch(state_serving(&root, true), "/manifest.json").await;
assert_eq!(self_hosted.status(), StatusCode::OK);
let manifest: serde_json::Value =
serde_json::from_str(&body_of(self_hosted).await).unwrap();
assert_eq!(manifest["name"], "Example");
assert_eq!(manifest["icons"][0]["src"], "https://example.test/icon.png");
assert_installable_shape(&manifest);
std::fs::remove_dir_all(root).unwrap();
}
fn location_of(response: &Response) -> &str {
assert_eq!(response.status(), StatusCode::FOUND);
response
.headers()
.get(header::LOCATION)
.expect("the favicon redirect has no location")
.to_str()
.unwrap()
}
#[tokio::test]
async fn the_favicon_ico_redirects_to_the_bundled_icon_on_the_official_instance() {
let root = static_dir_with_generated_files();
let mut state = state_serving(&root, false);
let response = fetch(state.clone(), "/favicon.ico").await;
assert_eq!(location_of(&response), "/web/favicon.ico");
std::sync::Arc::make_mut(&mut state.config).static_cdn_endpoint = Some(
HttpEndpoint::parse("TEST_STATIC_CDN_ENDPOINT", "https://fluxerstatic.com").unwrap(),
);
let response = fetch(state, "/favicon.ico").await;
assert_eq!(
location_of(&response),
"https://fluxerstatic.com/web/favicon.ico"
);
std::fs::remove_dir_all(root).unwrap();
}
#[tokio::test]
async fn the_favicon_ico_redirects_to_the_instance_favicon_when_self_hosted() {
let root = static_dir_with_generated_files();
let mut state = state_serving(&root, true);
let response = fetch(state.clone(), "/favicon.ico").await;
assert_eq!(location_of(&response), "https://example.test/favicon.png");
state.branding = crate::branding_images::InstanceBranding::new(BrandingSnapshot::default());
let response = fetch(state, "/favicon.ico").await;
assert_eq!(location_of(&response), "/web/favicon.ico");
std::fs::remove_dir_all(root).unwrap();
}
const GENERATED_BROWSERCONFIG: &str = r#"<?xml version="1.0" encoding="utf-8"?>
<browserconfig>
<msapplication>
<tile>
<square150x150logo src="{{STATIC_CDN_ENDPOINT}}/web/mstile-150x150.png"/>
<TileColor>#4641D9</TileColor>
</tile>
</msapplication>
</browserconfig>"#;
#[tokio::test]
async fn the_fluxer_tile_is_served_only_on_the_official_instance() {
let root = static_dir_with_generated_files();
let official = fetch(state_serving(&root, false), "/browserconfig.xml").await;
assert_eq!(official.status(), StatusCode::OK);
assert_eq!(
body_of(official).await,
GENERATED_BROWSERCONFIG.replace("{{STATIC_CDN_ENDPOINT}}", "")
);
let self_hosted = fetch(state_serving(&root, true), "/browserconfig.xml").await;
assert_eq!(self_hosted.status(), StatusCode::NOT_FOUND);
std::fs::remove_dir_all(root).unwrap();
}
} }
+3 -3
View File
@@ -1,6 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-or-later // SPDX-License-Identifier: AGPL-3.0-or-later
use crate::branding_images::BrandingImageOrigins; use crate::branding_images::InstanceBranding;
use crate::config::{AppProxyConfig, HttpUrl}; use crate::config::{AppProxyConfig, HttpUrl};
use crate::csp::CompiledCspPolicy; use crate::csp::CompiledCspPolicy;
use crate::local_files::LocalFileStore; use crate::local_files::LocalFileStore;
@@ -83,7 +83,7 @@ impl SpaIndexSource {
pub struct AppState { pub struct AppState {
pub config: Arc<AppProxyConfig>, pub config: Arc<AppProxyConfig>,
pub csp: Arc<CompiledCspPolicy>, pub csp: Arc<CompiledCspPolicy>,
pub branding_image_origins: BrandingImageOrigins, pub branding: InstanceBranding,
pub http_client: reqwest::Client, pub http_client: reqwest::Client,
pub spa_index_source: SpaIndexSource, pub spa_index_source: SpaIndexSource,
pub local_asset_prefixes: Option<Arc<[&'static str]>>, pub local_asset_prefixes: Option<Arc<[&'static str]>>,
@@ -104,7 +104,7 @@ impl AppState {
let local_asset_prefixes = (!spa_index_source.is_upstream()) let local_asset_prefixes = (!spa_index_source.is_upstream())
.then(|| present_local_asset_prefixes(&config.static_dir)); .then(|| present_local_asset_prefixes(&config.static_dir));
Ok(Self { Ok(Self {
branding_image_origins: BrandingImageOrigins::from_config(&config), branding: InstanceBranding::from_config(&config),
config, config,
csp, csp,
http_client, http_client,
@@ -1236,6 +1236,8 @@ Compose forwards every name below from `.env`. The admin dashboard also sets the
A brand asset on another origin than the instance needs no CSP setting. `app-proxy` adds the origin of each of the five image URLs below to `img-src`, whether the URL is set here or in the dashboard. [Content Security Policy](#content-security-policy) has the timing. A brand asset on another origin than the instance needs no CSP setting. `app-proxy` adds the origin of each of the five image URLs below to `img-src`, whether the URL is set here or in the dashboard. [Content Security Policy](#content-security-policy) has the timing.
`app-proxy` on a self-hosted instance also writes the product name, the icon, the favicon and the theme colour into the page it serves, so a browser shows them before the web app starts. The page title and the home screen name use the product name. The home screen icon uses the icon, or the favicon when no icon is set. The browser favicon and the `/favicon.ico` redirect use the favicon, or the icon when no favicon is set. The install manifest at `/manifest.json` takes the product name, the theme colour and the icon, and leaves out the Fluxer description. `/browserconfig.xml`, the Fluxer tile for legacy Edge, returns 404. `app-proxy` takes these values the same way as the image origins, from this environment at start and then from the dashboard.
#### `FLUXER_APP_PRODUCT_NAME` #### `FLUXER_APP_PRODUCT_NAME`
Default `Fluxer`. The product name clients display and the API writes in its emails and report flows. Also settable in the admin dashboard, which wins. Compose forwards it from `.env`. Default `Fluxer`. The product name clients display and the API writes in its emails and report flows. Also settable in the admin dashboard, which wins. Compose forwards it from `.env`.