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
+895 -107

No files matched your search

+316 -57
View File
@@ -2,6 +2,7 @@
use crate::config::{AppProxyConfig, HttpEndpoint};
use axum::http::header;
use reqwest::Url;
use std::sync::Arc;
use std::time::Duration;
use tokio::sync::watch;
@@ -14,27 +15,80 @@ const BRANDING_IMAGES: [(&str, &str); 5] = [
("wordmark_url", "FLUXER_APP_WORDMARK_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_BODY_BYTES_MAX: usize = 1024 * 1024;
const REFRESH_INTERVAL_MIN: Duration = Duration::from_secs(1);
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
F: FnMut(&str) -> Option<String>,
{
distinct_origins(
BRANDING_IMAGES
.iter()
.filter_map(|(_, name)| Some((*name, read_var(name)?))),
)
snapshot_from_fields(|(_, 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()?;
Some(distinct_origins(BRANDING_IMAGES.iter().filter_map(
|(key, _)| Some((*key, branding.get(*key)?.as_str()?.to_owned())),
)))
Some(snapshot_from_fields(|(key, _)| {
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> {
@@ -80,23 +134,36 @@ async fn fetch_discovery(
Ok(serde_json::from_slice(&body)?)
}
#[derive(Clone, Debug)]
pub struct BrandingImageOrigins(watch::Sender<Arc<[HttpEndpoint]>>);
#[derive(Clone, Debug, Default, PartialEq, Eq)]
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 {
pub fn new(origins: Vec<HttpEndpoint>) -> Self {
Self(watch::Sender::new(origins.into()))
#[derive(Clone, Debug)]
pub struct InstanceBranding(watch::Sender<Arc<BrandingSnapshot>>);
impl InstanceBranding {
pub fn new(snapshot: BrandingSnapshot) -> Self {
Self(watch::Sender::new(Arc::new(snapshot)))
}
pub fn from_config(config: &AppProxyConfig) -> Self {
if config.self_hosted {
Self::new(config.branding_image_origins.clone())
Self::new(config.branding.clone())
} 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()
}
@@ -106,19 +173,30 @@ impl BrandingImageOrigins {
upstream_url: &str,
) -> anyhow::Result<()> {
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");
};
let changed = self.0.send_if_modified(|current| {
if **current == *origins {
if **current == snapshot {
return false;
}
*current = origins.as_slice().into();
*current = Arc::new(snapshot.clone());
true
});
if changed {
let origins: Vec<&str> = origins.iter().map(HttpEndpoint::csp_origin).collect();
tracing::info!(?origins, "branding image origins updated");
let origins: Vec<&str> = snapshot
.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(())
}
@@ -138,11 +216,11 @@ impl BrandingImageOrigins {
upstream_url: String,
interval: Duration,
) -> JoinHandle<()> {
let origins = self.clone();
let branding = self.clone();
tokio::spawn(async move {
let mut consecutive_failures: u32 = 0;
loop {
let wait = match origins.refresh(&client, &upstream_url).await {
let wait = match branding.refresh(&client, &upstream_url).await {
Ok(()) => {
consecutive_failures = 0;
interval
@@ -157,7 +235,7 @@ impl BrandingImageOrigins {
url = %upstream_url,
consecutive_failures,
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
}
@@ -182,6 +260,13 @@ mod tests {
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 {
reqwest::Client::builder()
.timeout(Duration::from_secs(2))
@@ -227,7 +312,7 @@ mod tests {
});
assert_eq!(
sources(&origins_from_discovery(&discovery).unwrap()),
sources(&snapshot_from_discovery(&discovery).unwrap().image_origins),
["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!(
origins_from_discovery(&serde_json::json!({"app_public": {"branding": null}}))
.is_none()
);
assert!(
origins_from_discovery(&serde_json::json!({"app_public": {"branding": {}}}))
snapshot_from_discovery(&discovery)
.unwrap()
.image_origins
.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]
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_FAVICON_URL" => Some("https://example.test/favicon.png".to_owned()),
"FLUXER_APP_WORDMARK_URL" => Some("https://cdn.example.test/wordmark.svg".to_owned()),
@@ -276,26 +424,109 @@ mod tests {
});
assert_eq!(
sources(&origins),
sources(&snapshot.image_origins),
["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]
fn only_a_self_hosted_instance_seeds_origins_from_its_environment() {
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;
assert!(
BrandingImageOrigins::from_config(&config)
.current()
InstanceBranding::from_config(&config)
.image_origins()
.is_empty()
);
config.self_hosted = true;
assert_eq!(
sources(&BrandingImageOrigins::from_config(&config).current()),
sources(&InstanceBranding::from_config(&config).image_origins()),
["https://example.test"]
);
}
@@ -310,36 +541,52 @@ mod tests {
})),
)])
.await;
let origins = BrandingImageOrigins::new(vec![endpoint("https://env.example.test")]);
assert_eq!(sources(&origins.current()), ["https://env.example.test"]);
let origins = InstanceBranding::new(seeded_with(&["https://env.example.test"]));
assert_eq!(
sources(&origins.image_origins()),
["https://env.example.test"]
);
origins.refresh(&fast_client(), &upstream).await.unwrap();
assert_eq!(
sources(&origins.current()),
sources(&origins.image_origins()),
["https://dashboard.example.test"]
);
assert_eq!(
origins.snapshot().favicon_url.as_deref(),
Some("https://dashboard.example.test/icon.png")
);
}
#[tokio::test]
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 unavailable =
serve_discovery(vec![(StatusCode::SERVICE_UNAVAILABLE, String::new())]).await;
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 not_json = serve_discovery(vec![(StatusCode::OK, "<html>".to_owned())]).await;
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 no_branding = serve_discovery(vec![(StatusCode::OK, "{}".to_owned())]).await;
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 oversized = serve_discovery(vec![(
@@ -353,7 +600,10 @@ mod tests {
)])
.await;
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();
assert!(
@@ -362,7 +612,10 @@ mod tests {
.await
.is_err()
);
assert_eq!(sources(&origins.current()), ["https://env.example.test"]);
assert_eq!(
sources(&origins.image_origins()),
["https://env.example.test"]
);
}
#[tokio::test]
@@ -383,16 +636,22 @@ mod tests {
),
])
.await;
let origins = BrandingImageOrigins::new(Vec::new());
let origins = InstanceBranding::new(BrandingSnapshot::default());
let mut changes = origins.0.subscribe();
let refresh =
origins.spawn_refresh_loop(fast_client(), upstream, Duration::from_millis(10));
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();
assert_eq!(sources(&origins.current()), ["https://second.example.test"]);
assert_eq!(
sources(&origins.image_origins()),
["https://second.example.test"]
);
refresh.abort();
}
}
+2 -4
View File
@@ -359,7 +359,7 @@ pub struct AppProxyConfig {
pub media_endpoint: Option<HttpEndpoint>,
pub s3_public_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_refresh_interval_ms: u64,
pub release_channel: ReleaseChannel,
@@ -485,9 +485,7 @@ impl AppProxyConfig {
),
s3_public_endpoint,
s3_uploads_endpoint,
branding_image_origins: crate::branding_images::origins_from_environment(
cfg::env_value,
),
branding: crate::branding_images::snapshot_from_environment(cfg::env_value),
discovery_upstream_url: cfg::read_env(
"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")?;
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.config.discovery_upstream_url.clone(),
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)
.expect("the test static directory must exist");
AppState {
branding_image_origins: crate::branding_images::BrandingImageOrigins::from_config(
&config,
),
branding: crate::branding_images::InstanceBranding::from_config(&config),
config: Arc::new(config),
csp,
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)
.expect("the test static directory must exist");
AppState {
branding_image_origins: crate::branding_images::BrandingImageOrigins::from_config(
&config,
),
branding: crate::branding_images::InstanceBranding::from_config(&config),
config: Arc::new(config),
csp,
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("/manifest.json", get(spa_static::manifest_json))
.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.map", get(spa_static::service_worker_map))
.fallback(get(spa_index::spa_catch_all))
+217 -18
View File
@@ -1,5 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use crate::branding_images::BrandingSnapshot;
use crate::config::HttpEndpoint;
use crate::csp::{CspHeaderValues, inline_script_hashes};
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 SAME_ORIGIN_API_META_TAG: &str = r#"<meta name="fluxer-api-origin" content="self">"#;
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] = [
r#"<link rel="preconnect" href="{{STATIC_CDN_ENDPOINT}}">"#,
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,
};
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 {
raw_html
};
@@ -206,7 +220,7 @@ async fn serve_spa_index(state: &AppState, headers: &HeaderMap, request_path: &s
};
let csp = state.csp.spa_headers(
&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)
}
@@ -317,20 +331,83 @@ fn apply_media_preconnect(html: &str, media: &str, static_cdn: &str) -> String {
}
fn strip_link_preview_metadata(html: &str) -> String {
let html = remove_elements(html, "<title", "</title>");
remove_elements(&html, r#"<meta name="description""#, ">")
replace_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 output = String::with_capacity(html.len());
let mut output = String::with_capacity(html.len() + replacement.len());
while let Some(index) = rest.find(start) {
let Some(length) = rest[index..].find(end) else {
break;
};
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);
@@ -622,6 +699,7 @@ mod tests {
static_root_file_cache_control(request_path).is_some()
}
use crate::branding_images::InstanceBranding;
use crate::config::{AppProxyConfig, ReleaseChannel};
use crate::state::LOCAL_FILE_READS_IN_FLIGHT_MAX;
use axum::Router;
@@ -780,12 +858,10 @@ mod tests {
#[test]
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""#));
let stripped = strip_link_preview_metadata(SHIPPED_APP_SHELL);
assert!(!stripped.contains("<title"));
assert!(!stripped.contains(r#"name="description""#));
assert!(!stripped.contains("og:"));
assert!(!stripped.contains("twitter:"));
@@ -887,9 +963,10 @@ mod tests {
assert_eq!(response.status(), StatusCode::OK);
let served = read_document(response).await;
assert!(!served.contains("<title"));
assert!(!served.contains(r#"name="description""#));
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> {
@@ -916,8 +993,10 @@ mod tests {
let plain_document = read_document(plain).await;
assert!(!plain_images.contains(&"https://example.test".to_owned()));
state.branding_image_origins =
crate::branding_images::BrandingImageOrigins::new(vec![branding_origin()]);
state.branding = InstanceBranding::new(BrandingSnapshot {
image_origins: Arc::from([branding_origin()]),
..BrandingSnapshot::default()
});
let branded = serve_spa_index(&state, &HeaderMap::new(), "/login").await;
let branded_policy = policy_of(&branded);
let branded_document = read_document(branded).await;
@@ -934,10 +1013,12 @@ mod tests {
async fn the_official_instance_allows_no_branding_image_origins() {
let mut config = AppProxyConfig::from_env();
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);
state.branding_image_origins =
crate::branding_images::BrandingImageOrigins::from_config(&config);
state.branding = InstanceBranding::from_config(&config);
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]
async fn the_official_instance_keeps_its_link_preview_metadata() {
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)
.expect("the test static directory must exist");
AppState {
branding_image_origins: crate::branding_images::BrandingImageOrigins::from_config(
&config,
),
branding: InstanceBranding::from_config(&config),
config: Arc::new(config),
csp,
http_client: reqwest::Client::new(),
+349 -18
View File
@@ -1,5 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use crate::branding_images::BrandingSnapshot;
use crate::config::HttpEndpoint;
use crate::local_files::{LocalFileResponseOptions, serve_text_file};
use crate::state::{AppState, MAX_STATIC_TEXT_FILE_BYTES};
@@ -28,36 +29,76 @@ pub async fn version_json(State(state): State<AppState>, headers: HeaderMap) ->
}
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(
&state,
&headers,
"manifest.json",
"application/manifest+json",
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
}
fn with_scope_extensions(text: String, origins: &[String]) -> String {
if origins.is_empty() {
fn finish_manifest(
text: String,
scope_extension_origins: &[String],
branding: Option<&BrandingSnapshot>,
) -> String {
if scope_extension_origins.is_empty() && branding.is_none() {
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),
);
if !scope_extension_origins.is_empty() {
let scope_extensions = scope_extension_origins
.iter()
.map(|origin| serde_json::json!({ "type": "origin", "origin": origin }))
.collect();
manifest.insert(
"scope_extensions".to_owned(),
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)
}
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 {
if state.config.self_hosted {
return StatusCode::NOT_FOUND.into_response();
}
serve_static_text_file_with_cdn(
&state,
&headers,
@@ -68,6 +109,25 @@ pub async fn browserconfig_xml(State(state): State<AppState>, headers: HeaderMap
.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 {
serve_static_text_file(
&state,
@@ -104,7 +164,7 @@ async fn serve_static_text_file_with_cdn(
filename,
content_type,
static_cdn_endpoint,
None,
|text| text,
)
.await
}
@@ -115,7 +175,7 @@ async fn serve_static_text_file_with_substitutions(
filename: &str,
content_type: &'static str,
static_cdn_endpoint: Option<&HttpEndpoint>,
scope_extensions: Option<&[String]>,
finish: impl FnOnce(String) -> String,
) -> Response {
let replacement = static_cdn_endpoint.map_or("", HttpEndpoint::as_str);
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,
},
&[("{{STATIC_CDN_ENDPOINT}}", replacement)],
|text| match scope_extensions {
Some(origins) => with_scope_extensions(text, origins),
None => text,
},
finish,
)
.await;
apply_static_resource_security_policy(response.headers_mut(), state.csp.asset_header());
@@ -150,7 +207,7 @@ mod tests {
) -> String {
let text = text.replace("{{STATIC_CDN_ENDPOINT}}", static_cdn_endpoint);
match scope_extensions {
Some(origins) => with_scope_extensions(text, origins),
Some(origins) => finish_manifest(text, origins, None),
None => text,
}
}
@@ -228,4 +285,278 @@ mod tests {
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
use crate::branding_images::BrandingImageOrigins;
use crate::branding_images::InstanceBranding;
use crate::config::{AppProxyConfig, HttpUrl};
use crate::csp::CompiledCspPolicy;
use crate::local_files::LocalFileStore;
@@ -83,7 +83,7 @@ impl SpaIndexSource {
pub struct AppState {
pub config: Arc<AppProxyConfig>,
pub csp: Arc<CompiledCspPolicy>,
pub branding_image_origins: BrandingImageOrigins,
pub branding: InstanceBranding,
pub http_client: reqwest::Client,
pub spa_index_source: SpaIndexSource,
pub local_asset_prefixes: Option<Arc<[&'static str]>>,
@@ -104,7 +104,7 @@ impl AppState {
let local_asset_prefixes = (!spa_index_source.is_upstream())
.then(|| present_local_asset_prefixes(&config.static_dir));
Ok(Self {
branding_image_origins: BrandingImageOrigins::from_config(&config),
branding: InstanceBranding::from_config(&config),
config,
csp,
http_client,