fix(app-proxy): tell the web client its release channel (#3448)

This commit is contained in:
Hampus authored and GitHub committed 2026-10-13 15:15:11 +02:00
1 parent 83bb9d89c8
commit d514501d57
3 files changed
+81 -1

No files matched your search

+11 -1
View File
@@ -24,6 +24,16 @@ if (!parsedBuildInfo.success) {
} }
const buildInfo = parsedBuildInfo.output; const buildInfo = parsedBuildInfo.output;
const SERVED_RELEASE_CHANNEL_META_SELECTOR = 'meta[name="fluxer-release-channel"]';
function servedReleaseChannel(): 'stable' | 'canary' | null {
if (typeof document === 'undefined') {
return null;
}
const value = document.querySelector<HTMLMetaElement>(SERVED_RELEASE_CHANNEL_META_SELECTOR)?.content;
return value === 'stable' || value === 'canary' ? value : null;
}
function clientReleaseChannel(value: (typeof buildInfo)['PUBLIC_RELEASE_CHANNEL']): 'stable' | 'canary' { function clientReleaseChannel(value: (typeof buildInfo)['PUBLIC_RELEASE_CHANNEL']): 'stable' | 'canary' {
switch (value) { switch (value) {
case 'stable': case 'stable':
@@ -36,5 +46,5 @@ function clientReleaseChannel(value: (typeof buildInfo)['PUBLIC_RELEASE_CHANNEL'
export default { export default {
PUBLIC_BUILD_VERSION: buildInfo.PUBLIC_BUILD_VERSION, PUBLIC_BUILD_VERSION: buildInfo.PUBLIC_BUILD_VERSION,
PUBLIC_RELEASE_CHANNEL: clientReleaseChannel(buildInfo.PUBLIC_RELEASE_CHANNEL), PUBLIC_RELEASE_CHANNEL: servedReleaseChannel() ?? clientReleaseChannel(buildInfo.PUBLIC_RELEASE_CHANNEL),
} as const; } as const;
+36
View File
@@ -362,6 +362,7 @@ pub struct AppProxyConfig {
pub csp: CspConfig, pub csp: CspConfig,
pub same_origin_hosts: Vec<String>, pub same_origin_hosts: Vec<String>,
pub manifest_scope_extensions: Vec<String>, pub manifest_scope_extensions: Vec<String>,
pub release_channel: Option<&'static str>,
pub self_hosted: bool, pub self_hosted: bool,
} }
@@ -477,11 +478,31 @@ impl AppProxyConfig {
"FLUXER_APP_PROXY_MANIFEST_SCOPE_EXTENSIONS", "FLUXER_APP_PROXY_MANIFEST_SCOPE_EXTENSIONS",
&cfg::read_env("FLUXER_APP_PROXY_MANIFEST_SCOPE_EXTENSIONS", ""), &cfg::read_env("FLUXER_APP_PROXY_MANIFEST_SCOPE_EXTENSIONS", ""),
), ),
release_channel: parse_release_channel(
"RELEASE_CHANNEL",
&cfg::read_env("RELEASE_CHANNEL", ""),
),
self_hosted: cfg::read_bool_env("FLUXER_SELF_HOSTED", false), self_hosted: cfg::read_bool_env("FLUXER_SELF_HOSTED", false),
} }
} }
} }
fn parse_release_channel(name: &'static str, raw: &str) -> Option<&'static str> {
match raw.trim().to_ascii_lowercase().as_str() {
"" => None,
"stable" => Some("stable"),
"canary" => Some("canary"),
_ => {
warn_invalid(&InvalidAppProxyEnvironmentError::new(
name,
raw,
"stable or canary",
));
None
}
}
}
fn parse_manifest_scope_extensions(name: &'static str, raw: &str) -> Vec<String> { fn parse_manifest_scope_extensions(name: &'static str, raw: &str) -> Vec<String> {
let mut origins: Vec<String> = Vec::new(); let mut origins: Vec<String> = Vec::new();
for value in raw for value in raw
@@ -591,6 +612,21 @@ mod tests {
); );
} }
#[test]
fn release_channel_accepts_only_stable_or_canary() {
assert_eq!(
parse_release_channel("TEST_CHANNEL", " Stable "),
Some("stable")
);
assert_eq!(
parse_release_channel("TEST_CHANNEL", "canary"),
Some("canary")
);
assert_eq!(parse_release_channel("TEST_CHANNEL", ""), None);
assert_eq!(parse_release_channel("TEST_CHANNEL", "development"), None);
assert_eq!(parse_release_channel("TEST_CHANNEL", "\"><script>"), None);
}
#[test] #[test]
fn manifest_scope_extensions_are_normalised_and_deduplicated() { fn manifest_scope_extensions_are_normalised_and_deduplicated() {
assert_eq!( assert_eq!(
+34
View File
@@ -27,6 +27,7 @@ const SHARED_SHELL_CACHE_CONTROL: &str = "public, max-age=0, s-maxage=1";
const CSP_NONCE_PLACEHOLDER_ATTRIBUTE: &str = r#" nonce="{{CSP_NONCE_PLACEHOLDER}}""#; const CSP_NONCE_PLACEHOLDER_ATTRIBUTE: &str = r#" nonce="{{CSP_NONCE_PLACEHOLDER}}""#;
const MEDIA_PRECONNECT_TAG: &str = r#"<link rel="preconnect" href="{{MEDIA_ENDPOINT}}">"#; const MEDIA_PRECONNECT_TAG: &str = r#"<link rel="preconnect" href="{{MEDIA_ENDPOINT}}">"#;
const SAME_ORIGIN_API_META_TAG: &str = r#"<meta name="fluxer-api-origin" content="self">"#; const SAME_ORIGIN_API_META_TAG: &str = r#"<meta name="fluxer-api-origin" content="self">"#;
const RELEASE_CHANNEL_META_NAME: &str = "fluxer-release-channel";
const HEAD_CLOSE_TAG: &str = "</head>"; const HEAD_CLOSE_TAG: &str = "</head>";
const TITLE_START: &str = "<title"; const TITLE_START: &str = "<title";
const TITLE_END: &str = "</title>"; const TITLE_END: &str = "</title>";
@@ -204,6 +205,10 @@ async fn serve_spa_index(state: &AppState, headers: &HeaderMap, request_path: &s
} else { } else {
raw_html raw_html
}; };
let raw_html = match state.config.release_channel {
Some(channel) => mark_release_channel(&raw_html, channel),
None => raw_html,
};
let dev_buster = should_bust_dev_assets.then(current_dev_asset_cache_buster); let dev_buster = should_bust_dev_assets.then(current_dev_asset_cache_buster);
let html = match render_spa_document( let html = match render_spa_document(
@@ -260,6 +265,17 @@ fn mark_same_origin_api(html: &str) -> String {
) )
} }
fn mark_release_channel(html: &str, channel: &str) -> String {
html.replacen(
HEAD_CLOSE_TAG,
&format!(
r#"<meta name="{RELEASE_CHANNEL_META_NAME}" content="{channel}">
{HEAD_CLOSE_TAG}"#
),
1,
)
}
#[derive(Debug)] #[derive(Debug)]
struct SpaDocumentSizeLimitError { struct SpaDocumentSizeLimitError {
attempted_bytes: usize, attempted_bytes: usize,
@@ -1166,6 +1182,24 @@ mod tests {
assert!(!served.contains(SAME_ORIGIN_API_META_TAG)); assert!(!served.contains(SAME_ORIGIN_API_META_TAG));
} }
#[tokio::test]
async fn the_configured_release_channel_is_marked_in_the_document() {
let mut state = spa_state_serving(SHIPPED_APP_SHELL);
let served = read_document(serve_spa_index(&state, &HeaderMap::new(), "/").await).await;
assert!(!served.contains(RELEASE_CHANNEL_META_NAME));
for channel in ["stable", "canary"] {
Arc::make_mut(&mut state.config).release_channel = Some(channel);
let response = serve_spa_index(&state, &HeaderMap::new(), "/").await;
let policy = policy_of(&response);
let served = read_document(response).await;
let tag = format!(r#"<meta name="fluxer-release-channel" content="{channel}">"#);
assert_eq!(served.matches(&tag).count(), 1);
assert!(served.find(&tag).unwrap() < served.find("</head>").unwrap());
assert_every_inline_script_is_granted(&served, &policy);
}
}
#[tokio::test] #[tokio::test]
async fn the_configured_media_endpoint_is_preconnected() { async fn the_configured_media_endpoint_is_preconnected() {
let mut state = assemble_spa_state( let mut state = assemble_spa_state(