mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-18 05:25:40 +09:00
fix(app-proxy): serve instance branding before boot (#3396)
This commit is contained in:
1 parent
3694300d86
commit
20e3474393
11 files changed
+895
-107
No files matched your search
@@ -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(), ¬_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();
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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('&', "&")
|
||||
.replace('"', """)
|
||||
.replace('<', "<")
|
||||
.replace('>', ">")
|
||||
}
|
||||
|
||||
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&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&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 & B</title><script>x()</script>"</title><meta name="apple-mobile-web-app-title" content="A & B</title><script>x()</script>""><link rel="icon" href="https://example.test/"><script>x()</script>"><link rel="apple-touch-icon" href="https://example.test/"><script>x()</script>"></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(),
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user