diff --git a/deploy/self-hosting/.env.example b/deploy/self-hosting/.env.example index 7e0138780..fa4c88785 100644 --- a/deploy/self-hosting/.env.example +++ b/deploy/self-hosting/.env.example @@ -636,7 +636,8 @@ FLUXER_DISCOVERY_ENABLED=true #FLUXER_MEDIA_PROXY_UPLOAD_RELAY_SPOOL_CHUNK_BYTES=1048576 #FLUXER_MEDIA_PROXY_UPLOAD_RELAY_SPOOL_MAX_TOTAL_BYTES=8589934592 -# app-proxy index upstream and manifest scope. +# app-proxy discovery refresh, index upstream and manifest scope. +#DISCOVERY_REFRESH_INTERVAL_MS=60000 #FLUXER_APP_PROXY_INDEX_UPSTREAM_URL= #FLUXER_APP_PROXY_SAME_ORIGIN_HOSTS= #FLUXER_APP_PROXY_MANIFEST_SCOPE_EXTENSIONS= diff --git a/deploy/self-hosting/docker-compose.yml b/deploy/self-hosting/docker-compose.yml index f52de43fe..a022e0a77 100644 --- a/deploy/self-hosting/docker-compose.yml +++ b/deploy/self-hosting/docker-compose.yml @@ -661,6 +661,13 @@ services: FLUXER_S3_BUCKET_UPLOADS: ${FLUXER_S3_BUCKET_UPLOADS:-} FLUXER_STATIC_CDN_ENDPOINT: ${FLUXER_STATIC_CDN_ENDPOINT:-} FLUXER_MEDIA_ENDPOINT: ${FLUXER_MEDIA_ENDPOINT:-} + FLUXER_APP_ICON_URL: ${FLUXER_APP_ICON_URL:-} + FLUXER_APP_SYMBOL_URL: ${FLUXER_APP_SYMBOL_URL:-} + FLUXER_APP_LOGO_URL: ${FLUXER_APP_LOGO_URL:-} + FLUXER_APP_WORDMARK_URL: ${FLUXER_APP_WORDMARK_URL:-} + FLUXER_APP_FAVICON_URL: ${FLUXER_APP_FAVICON_URL:-} + DISCOVERY_UPSTREAM_URL: http://edge:8088/.well-known/fluxer + DISCOVERY_REFRESH_INTERVAL_MS: ${DISCOVERY_REFRESH_INTERVAL_MS:-} FLUXER_APP_PROXY_INDEX_UPSTREAM_URL: ${FLUXER_APP_PROXY_INDEX_UPSTREAM_URL:-} FLUXER_APP_PROXY_SAME_ORIGIN_HOSTS: ${FLUXER_APP_PROXY_SAME_ORIGIN_HOSTS:-} FLUXER_APP_PROXY_MANIFEST_SCOPE_EXTENSIONS: ${FLUXER_APP_PROXY_MANIFEST_SCOPE_EXTENSIONS:-} diff --git a/fluxer_admin/openapi-admin.json b/fluxer_admin/openapi-admin.json index 2dc4452f2..2aa79f80b 100644 --- a/fluxer_admin/openapi-admin.json +++ b/fluxer_admin/openapi-admin.json @@ -11905,7 +11905,11 @@ "logo_url": {"nullable": true, "type": "string", "maxLength": 2048}, "wordmark_url": {"nullable": true, "type": "string", "maxLength": 2048}, "favicon_url": {"nullable": true, "type": "string", "maxLength": 2048}, - "theme_color": {"nullable": true, "type": "string", "maxLength": 64}, + "theme_color": { + "nullable": true, + "type": "string", + "pattern": "^(?:#?(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6}))?$" + }, "status_page_url": {"nullable": true, "type": "string", "maxLength": 2048}, "status_page_incident_history_url": {"nullable": true, "type": "string", "maxLength": 2048}, "premium_product_name": {"nullable": true, "type": "string", "minLength": 1, "maxLength": 40}, diff --git a/fluxer_admin/src/templates/pages/instance_config.rs b/fluxer_admin/src/templates/pages/instance_config.rs index 8c7ee0a93..cedaf1fc5 100644 --- a/fluxer_admin/src/templates/pages/instance_config.rs +++ b/fluxer_admin/src/templates/pages/instance_config.rs @@ -950,9 +950,9 @@ fn app_public_config_section( )) (text_input( "app_theme_color", - "Theme Color", + "Theme Color (hex)", app_public.branding.theme_color.as_deref().unwrap_or(""), - "#5865f2", + "#4641d9", )) } div class="grid grid-cols-1 gap-4 sm:grid-cols-2" { diff --git a/fluxer_api/src/api/admin/tests/InstanceConfigBilling.test.ts b/fluxer_api/src/api/admin/tests/InstanceConfigBilling.test.ts index 2baae4885..369acbbd6 100644 --- a/fluxer_api/src/api/admin/tests/InstanceConfigBilling.test.ts +++ b/fluxer_api/src/api/admin/tests/InstanceConfigBilling.test.ts @@ -362,4 +362,17 @@ describe('instance config billing on a hosted instance', () => { }).execute(); expect(updated.app_public.branding.theme_color).toBe('#123456'); }); + + it('rejects a theme colour that is not hex', async () => { + const admin = await createAdmin(context.harness); + for (const theme_color of ['rgb(123, 44, 228)', 'rebeccapurple', '#12345']) { + await patchConfig(context.harness, admin, {app_public: {branding: {theme_color}}}) + .expect(HTTP_STATUS.BAD_REQUEST, APIErrorCodes.INVALID_FORM_BODY) + .execute(); + } + const cleared = await patchConfig(context.harness, admin, { + app_public: {branding: {theme_color: null}}, + }).execute(); + expect(cleared.app_public.branding.theme_color).toBeNull(); + }); }); diff --git a/fluxer_api/src/api/instance/InstanceConfigRepository.test.ts b/fluxer_api/src/api/instance/InstanceConfigRepository.test.ts index d8ad5e474..33b9555eb 100644 --- a/fluxer_api/src/api/instance/InstanceConfigRepository.test.ts +++ b/fluxer_api/src/api/instance/InstanceConfigRepository.test.ts @@ -219,6 +219,33 @@ describe('InstanceConfigRepository', () => { expect(config.branding.product_name).toBe('Kept'); }); + it('serves the theme colour as six-digit hex and drops a stored value that is not hex', async () => { + setCassandraQueryExecutorForTesting(new InMemoryCassandraQueryExecutor()); + const repository = createRepository(new MockKVProvider()); + + expect((await repository.setAppPublicConfig({branding: {theme_color: '7B2CE4'}})).branding.theme_color).toBe( + '#7b2ce4', + ); + expect((await repository.setAppPublicConfig({branding: {theme_color: '#AbC'}})).branding.theme_color).toBe( + '#aabbcc', + ); + + await repository.setConfig(APP_PUBLIC_CONFIG_KEY, JSON.stringify({branding: {theme_color: 'rgb(123, 44, 228)'}})); + expect((await repository.getAppPublicConfig()).branding.theme_color).toBeNull(); + }); + + it('treats the black theme colour the setup wizard used to store as unset', async () => { + setCassandraQueryExecutorForTesting(new InMemoryCassandraQueryExecutor()); + const repository = createRepository(new MockKVProvider()); + + await repository.setConfig(APP_PUBLIC_CONFIG_KEY, JSON.stringify({branding: {theme_color: '#000000'}})); + expect((await repository.getAppPublicConfig()).branding.theme_color).toBeNull(); + expect((await repository.setAppPublicConfig({branding: {theme_color: '#000'}})).branding.theme_color).toBeNull(); + expect((await repository.setAppPublicConfig({branding: {theme_color: '#000001'}})).branding.theme_color).toBe( + '#000001', + ); + }); + it('stores uploaded branding assets as references and resolves them against the current media endpoint', async () => { setCassandraQueryExecutorForTesting(new CountingInMemoryCassandraQueryExecutor()); const repository = createRepository(new MockKVProvider()); diff --git a/fluxer_api/src/api/instance/InstanceConfigRepository.ts b/fluxer_api/src/api/instance/InstanceConfigRepository.ts index ccc1b8313..818af1239 100644 --- a/fluxer_api/src/api/instance/InstanceConfigRepository.ts +++ b/fluxer_api/src/api/instance/InstanceConfigRepository.ts @@ -410,6 +410,14 @@ function normalizeOptionalPublicString(value: string | null | undefined, fallbac return value === undefined ? fallback : normalizeOptionalString(value); } +function normalizeThemeColor(value: unknown): string | null { + const match = /^#?([0-9a-f]{3}|[0-9a-f]{6})$/i.exec(normalizeOptionalString(value) ?? ''); + if (match === null) return null; + const digits = match[1].toLowerCase(); + const color = `#${digits.length === 3 ? digits.replace(/./g, '$&$&') : digits}`; + return color === '#000000' ? null : color; +} + function getDefaultPremiumProductName(): string { return Config.instance.selfHosted ? 'Premium' : 'Plutonium'; } @@ -423,7 +431,7 @@ function getDefaultAppPublicConfig(): InstanceAppPublicConfig { logo_url: normalizeOptionalString(Config.instance.branding.logoUrl), wordmark_url: normalizeOptionalString(Config.instance.branding.wordmarkUrl), favicon_url: normalizeOptionalString(Config.instance.branding.faviconUrl), - theme_color: normalizeOptionalString(Config.instance.branding.themeColor), + theme_color: normalizeThemeColor(Config.instance.branding.themeColor), status_page_url: normalizeOptionalString(Config.instance.branding.statusPageUrl), status_page_incident_history_url: normalizeOptionalString(Config.instance.branding.statusPageIncidentHistoryUrl), premium_product_name: getDefaultPremiumProductName(), @@ -740,7 +748,8 @@ function buildAppPublicConfig(config: z.infer = ['icon', 'symbol', 'logo', 'wordmark', 'favicon']; -function hexToNumber(value: string | null): number { - if (!value) return 0; +function hexToNumber(value: string | null): number | null { + if (!value) return null; const match = /^#?([0-9a-fA-F]{6})$/.exec(value.trim()); - if (!match) return 0; + if (!match) return null; return parseInt(match[1], 16) >>> 0; } @@ -463,7 +463,7 @@ export const SelfHostedSetupWizardGate = observer(() => { const [creatingRecoveryKit, setCreatingRecoveryKit] = useState(false); const [productName, setProductName] = useState(''); - const [themeColor, setThemeColor] = useState(0); + const [themeColor, setThemeColor] = useState(null); const [registrationMode, setRegistrationMode] = useState('open'); const [singleCommunityEnabled, setSingleCommunityEnabled] = useState(false); const [singleCommunityName, setSingleCommunityName] = useState(''); @@ -902,7 +902,7 @@ export const SelfHostedSetupWizardGate = observer(() => { app_public: { branding: { product_name: productNameTrimmed, - theme_color: numberToHexColor(themeColor), + theme_color: themeColor === null ? null : numberToHexColor(themeColor), }, setup: {configured: true}, }, diff --git a/fluxer_app/src/features/app/components/setup/SetupWizardSteps.tsx b/fluxer_app/src/features/app/components/setup/SetupWizardSteps.tsx index 49357d5b0..8767ccca4 100644 --- a/fluxer_app/src/features/app/components/setup/SetupWizardSteps.tsx +++ b/fluxer_app/src/features/app/components/setup/SetupWizardSteps.tsx @@ -34,6 +34,7 @@ import type React from 'react'; import {useCallback} from 'react'; const PUSH_RELAY_NOTICE_URL = 'https://fluxer.com/push-relay'; +const DEFAULT_THEME_COLOR = 0x4641d9; export type RegistrationMode = 'open' | 'approval' | 'closed'; export type PremiumMode = 'mirror' | 'everyone'; @@ -844,11 +845,11 @@ export const BrandingStep = observer( }: { productName: string; productNameError: boolean; - themeColor: number; + themeColor: number | null; assets: ReadonlyArray; disabled: boolean; onProductNameChange: (value: string) => void; - onThemeColorChange: (value: number) => void; + onThemeColorChange: (value: number | null) => void; onUploadAsset: (kind: SetupBrandingAssetKind) => void; onClearAsset: (kind: SetupBrandingAssetKind) => void; }) => { @@ -873,8 +874,11 @@ export const BrandingStep = observer( /> onThemeColorChange(null)} disabled={disabled} data-flx="app.self-hosted-setup-wizard-gate.theme-color-field" /> diff --git a/fluxer_app/src/features/notification/utils/NotificationIconURL.ts b/fluxer_app/src/features/notification/utils/NotificationIconURL.ts index edba752af..9acf24b17 100644 --- a/fluxer_app/src/features/notification/utils/NotificationIconURL.ts +++ b/fluxer_app/src/features/notification/utils/NotificationIconURL.ts @@ -11,6 +11,10 @@ export const NATIVE_NOTIFICATION_ICON_CSS_SIZE: MediaProxyImageSize = 128; export function getNotificationIconURL(user: Pick, guildId?: string | null): string { const member = guildId ? GuildMembers.getMember(guildId, user.id) : null; const nativeIcon = isDesktop(); + const systemAvatarUrl = AvatarUtils.getSystemUserBrandingAvatarURL(user.id); + if (systemAvatarUrl !== null) { + return systemAvatarUrl; + } if (guildId && member) { return nativeIcon ? AvatarUtils.getGuildMemberNotificationAvatarURL({ diff --git a/fluxer_app/src/features/theme/utils/AccentColorUtils.ts b/fluxer_app/src/features/theme/utils/AccentColorUtils.ts index ac32a9689..26622f0ee 100644 --- a/fluxer_app/src/features/theme/utils/AccentColorUtils.ts +++ b/fluxer_app/src/features/theme/utils/AccentColorUtils.ts @@ -2,7 +2,7 @@ import * as ColorUtils from '@app/features/theme/utils/ColorUtils'; import type {User} from '@app/features/user/models/User'; -import {getDefaultAvatarPrimaryColor} from '@app/features/user/utils/AvatarUtils'; +import {getDefaultAvatarPrimaryColor, getSystemUserBrandingColor} from '@app/features/user/utils/AvatarUtils'; import {DEFAULT_ACCENT_COLOR} from '@fluxer/constants/src/AppConstants'; type RawAccentColor = number | null | undefined; @@ -19,6 +19,10 @@ export function getUserAccentColor( profileAccentColor?: RawAccentColor, fallback = DEFAULT_ACCENT_COLOR, ): string { + const systemColor = user ? getSystemUserBrandingColor(user.id) : null; + if (systemColor) { + return systemColor; + } const profileColor = getAccentColorHex(profileAccentColor); if (profileColor) { return profileColor; diff --git a/fluxer_app/src/features/user/components/modals/user_profile_modal/ProfileModalContent.tsx b/fluxer_app/src/features/user/components/modals/user_profile_modal/ProfileModalContent.tsx index f27bb2d3e..93a14b2c1 100644 --- a/fluxer_app/src/features/user/components/modals/user_profile_modal/ProfileModalContent.tsx +++ b/fluxer_app/src/features/user/components/modals/user_profile_modal/ProfileModalContent.tsx @@ -1,11 +1,9 @@ // SPDX-License-Identifier: AGPL-3.0-or-later -import RuntimeConfig from '@app/features/app/state/RuntimeConfig'; import {getUserAccentColor} from '@app/features/theme/utils/AccentColorUtils'; import {ProfileBody} from '@app/features/user/components/modals/user_profile_modal/ProfileBody'; import {ProfileMediaHeader} from '@app/features/user/components/modals/user_profile_modal/ProfileMediaHeader'; import type {ProfileModalContentProps} from '@app/features/user/components/modals/user_profile_modal/UserProfileModalShared'; -import * as AvatarUtils from '@app/features/user/utils/AvatarUtils'; import * as ProfileDisplayUtils from '@app/features/user/utils/ProfileDisplayUtils'; import {resolveProfileGuildMembership, toProfileDisplayContext} from '@app/features/user/utils/ProfileGuildMembership'; import {FLUXERBOT_ID} from '@fluxer/constants/src/AppConstants'; @@ -30,11 +28,7 @@ export const ProfileModalContent: React.FC = observer( }) => { const effectiveProfile = profile?.getEffectiveProfile() ?? null; const isSystemUser = user.id === FLUXERBOT_ID; - const systemThemeColor = - isSystemUser && RuntimeConfig.isSelfHosted() - ? RuntimeConfig.getSnapshotOrNull()?.appPublic.branding.theme_color - : null; - const bannerColor = systemThemeColor ?? getUserAccentColor(user, effectiveProfile?.accent_color); + const bannerColor = getUserAccentColor(user, effectiveProfile?.accent_color); const membership = resolveProfileGuildMembership(profile); const profileContext = useMemo( () => @@ -46,13 +40,10 @@ export const ProfileModalContent: React.FC = observer( }), [user, profile, membership], ); - const {avatarUrl: profileAvatarUrl, hoverAvatarUrl: profileHoverAvatarUrl} = useMemo( + const {avatarUrl, hoverAvatarUrl} = useMemo( () => ProfileDisplayUtils.getProfileAvatarUrls(profileContext, previewOverrides, MEDIA_PROXY_AVATAR_SIZE_PROFILE), [profileContext, previewOverrides], ); - const systemAvatarUrl = AvatarUtils.getSystemUserBrandingAvatarURL(user.id); - const avatarUrl = systemAvatarUrl ?? profileAvatarUrl; - const hoverAvatarUrl = systemAvatarUrl ?? profileHoverAvatarUrl; const {bannerUrl, hoverBannerUrl} = useMemo( () => ProfileDisplayUtils.getProfileBannerUrls( diff --git a/fluxer_app/src/features/user/utils/AvatarUtils.ts b/fluxer_app/src/features/user/utils/AvatarUtils.ts index e5a46044a..18c4115a4 100644 --- a/fluxer_app/src/features/user/utils/AvatarUtils.ts +++ b/fluxer_app/src/features/user/utils/AvatarUtils.ts @@ -82,6 +82,11 @@ export function getSystemUserBrandingAvatarURL(userId: string): string | null { return branding?.icon_url ?? branding?.logo_url ?? null; } +export function getSystemUserBrandingColor(userId: string): string | null { + if (userId !== FLUXERBOT_ID || !RuntimeConfig.isSelfHosted()) return null; + return RuntimeConfig.getSnapshotOrNull()?.appPublic.branding.theme_color ?? null; +} + export function getDefaultAvatarPrimaryColor(id: string) { return getSharedDefaultAvatarPrimaryColor(id); } diff --git a/fluxer_app/src/features/user/utils/ProfileDisplayUtils.ts b/fluxer_app/src/features/user/utils/ProfileDisplayUtils.ts index f75df228c..b861992c1 100644 --- a/fluxer_app/src/features/user/utils/ProfileDisplayUtils.ts +++ b/fluxer_app/src/features/user/utils/ProfileDisplayUtils.ts @@ -43,6 +43,10 @@ function getProfileAvatarUrl( if (previewAvatarUrl) { return previewAvatarUrl; } + const systemAvatarUrl = AvatarUtils.getSystemUserBrandingAvatarURL(user.id); + if (systemAvatarUrl !== null) { + return systemAvatarUrl; + } if (!ignoreGuildAvatar && guildId && guildMember) { return AvatarUtils.getGuildMemberDisplayAvatarURL({ guildId, diff --git a/fluxer_app_proxy/src/branding_images.rs b/fluxer_app_proxy/src/branding_images.rs new file mode 100644 index 000000000..693d8b712 --- /dev/null +++ b/fluxer_app_proxy/src/branding_images.rs @@ -0,0 +1,398 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +use crate::config::{AppProxyConfig, HttpEndpoint}; +use axum::http::header; +use std::sync::Arc; +use std::time::Duration; +use tokio::sync::watch; +use tokio::task::JoinHandle; + +const BRANDING_IMAGES: [(&str, &str); 5] = [ + ("icon_url", "FLUXER_APP_ICON_URL"), + ("symbol_url", "FLUXER_APP_SYMBOL_URL"), + ("logo_url", "FLUXER_APP_LOGO_URL"), + ("wordmark_url", "FLUXER_APP_WORDMARK_URL"), + ("favicon_url", "FLUXER_APP_FAVICON_URL"), +]; +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(mut read_var: F) -> Vec +where + F: FnMut(&str) -> Option, +{ + distinct_origins( + BRANDING_IMAGES + .iter() + .filter_map(|(_, name)| Some((*name, read_var(name)?))), + ) +} + +pub fn origins_from_discovery(discovery: &serde_json::Value) -> Option> { + 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())), + ))) +} + +fn distinct_origins(urls: impl Iterator) -> Vec { + let mut origins: Vec = Vec::new(); + for (name, url) in urls { + if url.trim().is_empty() { + continue; + } + match HttpEndpoint::origin_of(name, &url) { + Ok(origin) => { + if !origins.contains(&origin) { + origins.push(origin); + } + } + Err(error) => { + tracing::debug!(%error, "branding image has no origin to allow"); + } + } + } + origins +} + +async fn fetch_discovery( + client: &reqwest::Client, + upstream_url: &str, +) -> anyhow::Result { + let mut response = client + .get(upstream_url) + .header(header::ACCEPT, "application/json") + .timeout(DISCOVERY_FETCH_TIMEOUT) + .send() + .await?; + if !response.status().is_success() { + anyhow::bail!("upstream discovery returned {}", response.status()); + } + let mut body: Vec = Vec::new(); + while let Some(chunk) = response.chunk().await? { + if body.len() + chunk.len() > DISCOVERY_BODY_BYTES_MAX { + anyhow::bail!("upstream discovery exceeds {DISCOVERY_BODY_BYTES_MAX} bytes"); + } + body.extend_from_slice(&chunk); + } + Ok(serde_json::from_slice(&body)?) +} + +#[derive(Clone, Debug)] +pub struct BrandingImageOrigins(watch::Sender>); + +impl BrandingImageOrigins { + pub fn new(origins: Vec) -> Self { + Self(watch::Sender::new(origins.into())) + } + + pub fn from_config(config: &AppProxyConfig) -> Self { + if config.self_hosted { + Self::new(config.branding_image_origins.clone()) + } else { + Self::new(Vec::new()) + } + } + + pub fn current(&self) -> Arc<[HttpEndpoint]> { + self.0.borrow().clone() + } + + pub async fn refresh( + &self, + client: &reqwest::Client, + upstream_url: &str, + ) -> anyhow::Result<()> { + let discovery = fetch_discovery(client, upstream_url).await?; + let Some(origins) = origins_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 { + return false; + } + *current = origins.as_slice().into(); + true + }); + if changed { + let origins: Vec<&str> = origins.iter().map(HttpEndpoint::csp_origin).collect(); + tracing::info!(?origins, "branding image origins updated"); + } + Ok(()) + } + + pub fn start_background_refresh( + &self, + client: reqwest::Client, + upstream_url: String, + interval: Duration, + ) -> JoinHandle<()> { + self.spawn_refresh_loop(client, upstream_url, interval.max(REFRESH_INTERVAL_MIN)) + } + + fn spawn_refresh_loop( + &self, + client: reqwest::Client, + upstream_url: String, + interval: Duration, + ) -> JoinHandle<()> { + let origins = self.clone(); + tokio::spawn(async move { + let mut consecutive_failures: u32 = 0; + loop { + let wait = match origins.refresh(&client, &upstream_url).await { + Ok(()) => { + consecutive_failures = 0; + interval + } + Err(error) => { + let backoff = interval + .saturating_mul(1 << consecutive_failures.min(5)) + .min(REFRESH_BACKOFF_MAX); + consecutive_failures = consecutive_failures.saturating_add(1); + tracing::warn!( + %error, + url = %upstream_url, + consecutive_failures, + backoff_ms = backoff.as_millis() as u64, + "branding image origin refresh failed, keeping the previous origins" + ); + backoff + } + }; + tokio::time::sleep(wait).await; + } + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use axum::http::StatusCode; + use std::sync::atomic::{AtomicUsize, Ordering}; + + fn sources(origins: &[HttpEndpoint]) -> Vec<&str> { + origins.iter().map(HttpEndpoint::csp_origin).collect() + } + + fn endpoint(value: &str) -> HttpEndpoint { + HttpEndpoint::parse("TEST_ENDPOINT", value).unwrap() + } + + fn fast_client() -> reqwest::Client { + reqwest::Client::builder() + .timeout(Duration::from_secs(2)) + .build() + .unwrap() + } + + async fn serve_discovery(responses: Vec<(StatusCode, String)>) -> String { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let served = Arc::new(AtomicUsize::new(0)); + let responses = Arc::new(responses); + let router = axum::Router::new().fallback(move || { + let index = served.fetch_add(1, Ordering::SeqCst); + let responses = Arc::clone(&responses); + async move { responses[index.min(responses.len() - 1)].clone() } + }); + tokio::spawn(async move { + axum::serve(listener, router).await.unwrap(); + }); + format!("http://{addr}/.well-known/fluxer") + } + + fn discovery_with_branding(branding: serde_json::Value) -> String { + serde_json::json!({"api_code_version": "v1", "app_public": {"branding": branding}}) + .to_string() + } + + #[test] + fn discovery_branding_images_yield_their_distinct_origins() { + let discovery = serde_json::json!({ + "app_public": { + "branding": { + "product_name": "Example", + "icon_url": "https://example.test/logo.png", + "symbol_url": " https://example.test/symbol.svg?v=2#dark ", + "logo_url": "http://assets.example.test:8443/brand/logo.png", + "wordmark_url": null, + "favicon_url": "https://EXAMPLE.test:443/favicon.png", + "status_page_url": "https://status.example.test" + } + } + }); + + assert_eq!( + sources(&origins_from_discovery(&discovery).unwrap()), + ["https://example.test", "http://assets.example.test:8443"] + ); + } + + #[test] + fn branding_images_without_a_network_origin_are_skipped() { + let discovery = serde_json::json!({ + "app_public": { + "branding": { + "icon_url": "/branding/icon.png", + "symbol_url": "data:image/png;base64,AAAA", + "logo_url": " ", + "wordmark_url": 7, + "favicon_url": "ftp://example.test/favicon.png" + } + } + }); + + 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": {}}})) + .unwrap() + .is_empty() + ); + } + + #[test] + fn environment_branding_images_yield_their_distinct_origins() { + let origins = origins_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()), + "FLUXER_APP_LOGO_URL" => Some("not a url".to_owned()), + "FLUXER_APP_STATUS_PAGE_URL" => Some("https://status.example.test".to_owned()), + _ => None, + }); + + assert_eq!( + sources(&origins), + ["https://example.test", "https://cdn.example.test"] + ); + } + + #[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.self_hosted = false; + assert!( + BrandingImageOrigins::from_config(&config) + .current() + .is_empty() + ); + + config.self_hosted = true; + assert_eq!( + sources(&BrandingImageOrigins::from_config(&config).current()), + ["https://example.test"] + ); + } + + #[tokio::test] + async fn a_discovery_snapshot_replaces_the_environment_seed() { + let upstream = serve_discovery(vec![( + StatusCode::OK, + discovery_with_branding(serde_json::json!({ + "icon_url": "https://dashboard.example.test/icon.png", + "favicon_url": null + })), + )]) + .await; + let origins = BrandingImageOrigins::new(vec![endpoint("https://env.example.test")]); + assert_eq!(sources(&origins.current()), ["https://env.example.test"]); + + origins.refresh(&fast_client(), &upstream).await.unwrap(); + + assert_eq!( + sources(&origins.current()), + ["https://dashboard.example.test"] + ); + } + + #[tokio::test] + async fn a_failed_refresh_keeps_the_previous_origins() { + let seeded = || BrandingImageOrigins::new(vec![endpoint("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"]); + + let origins = seeded(); + let not_json = serve_discovery(vec![(StatusCode::OK, "".to_owned())]).await; + assert!(origins.refresh(&fast_client(), ¬_json).await.is_err()); + assert_eq!(sources(&origins.current()), ["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"]); + + let origins = seeded(); + let oversized = serve_discovery(vec![( + StatusCode::OK, + discovery_with_branding(serde_json::json!({ + "icon_url": format!( + "https://dashboard.example.test/{}", + "a".repeat(DISCOVERY_BODY_BYTES_MAX) + ) + })), + )]) + .await; + assert!(origins.refresh(&fast_client(), &oversized).await.is_err()); + assert_eq!(sources(&origins.current()), ["https://env.example.test"]); + + let origins = seeded(); + assert!( + origins + .refresh(&fast_client(), "http://127.0.0.1:1/.well-known/fluxer") + .await + .is_err() + ); + assert_eq!(sources(&origins.current()), ["https://env.example.test"]); + } + + #[tokio::test] + async fn the_background_refresh_follows_discovery_and_survives_a_failure() { + let upstream = serve_discovery(vec![ + ( + StatusCode::OK, + discovery_with_branding(serde_json::json!({ + "icon_url": "https://first.example.test/icon.png" + })), + ), + (StatusCode::BAD_GATEWAY, String::new()), + ( + StatusCode::OK, + discovery_with_branding(serde_json::json!({ + "icon_url": "https://second.example.test/icon.png" + })), + ), + ]) + .await; + let origins = BrandingImageOrigins::new(Vec::new()); + 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"]); + changes.changed().await.unwrap(); + assert_eq!(sources(&origins.current()), ["https://second.example.test"]); + refresh.abort(); + } +} diff --git a/fluxer_app_proxy/src/config.rs b/fluxer_app_proxy/src/config.rs index 3b20c79c5..b38172a17 100644 --- a/fluxer_app_proxy/src/config.rs +++ b/fluxer_app_proxy/src/config.rs @@ -5,6 +5,8 @@ use reqwest::Url; use std::env; use std::fmt; +const DEFAULT_DISCOVERY_UPSTREAM_URL: &str = "http://localhost:8088/api/.well-known/fluxer"; + #[derive(Clone, Debug, Eq, PartialEq)] pub enum InvalidAppProxyEnvironmentError { InvalidValue { @@ -104,6 +106,19 @@ impl HttpEndpoint { Ok(Self { url, csp_origin }) } + pub fn origin_of( + name: &'static str, + value: &str, + ) -> Result { + let url = Url::parse(value.trim()) + .ok() + .filter(|url| matches!(url.scheme(), "http" | "https") && url.host_str().is_some()) + .ok_or_else(|| { + InvalidAppProxyEnvironmentError::new(name, value, "an absolute HTTP or HTTPS URL") + })?; + Self::parse(name, &url.origin().ascii_serialization()) + } + pub fn with_host_prefix( &self, name: &'static str, @@ -344,6 +359,9 @@ pub struct AppProxyConfig { pub media_endpoint: Option, pub s3_public_endpoint: Option, pub s3_uploads_endpoint: Option, + pub branding_image_origins: Vec, + pub discovery_upstream_url: String, + pub discovery_refresh_interval_ms: u64, pub release_channel: ReleaseChannel, pub build_version: String, pub csp: CspConfig, @@ -467,6 +485,18 @@ impl AppProxyConfig { ), s3_public_endpoint, s3_uploads_endpoint, + branding_image_origins: crate::branding_images::origins_from_environment( + cfg::env_value, + ), + discovery_upstream_url: cfg::read_env( + "DISCOVERY_UPSTREAM_URL", + DEFAULT_DISCOVERY_UPSTREAM_URL, + ), + discovery_refresh_interval_ms: parse_env_or_warn( + "DISCOVERY_REFRESH_INTERVAL_MS", + &cfg::read_env("DISCOVERY_REFRESH_INTERVAL_MS", "60000"), + 60_000u64, + ), release_channel, build_version: cfg::read_first_env( &["BUILD_VERSION", "FLUXER_BUILD_VERSION"], diff --git a/fluxer_app_proxy/src/csp.rs b/fluxer_app_proxy/src/csp.rs index 6ff290162..fdfbf5f01 100644 --- a/fluxer_app_proxy/src/csp.rs +++ b/fluxer_app_proxy/src/csp.rs @@ -205,6 +205,7 @@ impl CompiledCspPolicy { &config, &[InlineScriptHash::of("")], configured_sources, + &[], )) .map_err(CspCompileError::InvalidSpaPolicy)?; Ok(Self { @@ -218,11 +219,16 @@ impl CompiledCspPolicy { self.asset.clone() } - pub fn spa_headers(&self, script_hashes: &[InlineScriptHash]) -> CspHeaderValues { + pub fn spa_headers( + &self, + script_hashes: &[InlineScriptHash], + branding_image_origins: &[HttpEndpoint], + ) -> CspHeaderValues { let enforced = HeaderValue::from_str(&build_csp( &self.config, script_hashes, &self.runtime_sources, + branding_image_origins, )) .expect( "every CSP source is a validated keyword, scheme, ASCII origin, or base64 hash, so a \ @@ -239,18 +245,26 @@ fn build_csp( config: &CspConfig, script_hashes: &[InlineScriptHash], runtime_sources: &RuntimeCspSources, + branding_image_origins: &[HttpEndpoint], ) -> String { - build_csp_directives(config, Some(script_hashes), runtime_sources).join("; ") + build_csp_directives( + config, + Some(script_hashes), + runtime_sources, + branding_image_origins, + ) + .join("; ") } fn build_asset_csp(config: &CspConfig, runtime_sources: &RuntimeCspSources) -> String { - build_csp_directives(config, None, runtime_sources).join("; ") + build_csp_directives(config, None, runtime_sources, &[]).join("; ") } fn build_csp_directives( config: &CspConfig, script_hashes: Option<&[InlineScriptHash]>, runtime_sources: &RuntimeCspSources, + branding_image_origins: &[HttpEndpoint], ) -> Vec { let mut directives = Vec::with_capacity(14); @@ -278,6 +292,9 @@ fn build_csp_directives( let mut img = vec!["'self'".to_owned(), "blob:".to_owned(), "data:".to_owned()]; extend_from(&mut img, &config.extra_img_src, IMAGE_SOURCES); extend_runtime_sources(&mut img, runtime_sources, true, true); + for origin in branding_image_origins { + push_endpoint_source(&mut img, Some(origin)); + } directives.push(format!("img-src {}", img.join(" "))); let mut media = vec!["'self'".to_owned(), "blob:".to_owned()]; @@ -431,7 +448,7 @@ mod tests { #[test] fn build_csp_includes_required_directives() { let config = default_csp_config(); - let csp = build_csp(&config, &[hash_of("boot()")], &runtime_sources()); + let csp = build_csp(&config, &[hash_of("boot()")], &runtime_sources(), &[]); assert!(csp.contains("default-src")); assert!(csp.contains("script-src")); assert!(csp.contains("style-src")); @@ -450,7 +467,7 @@ mod tests { #[test] fn build_csp_allows_the_apex_domain_for_images_and_media() { let config = default_csp_config(); - let csp = build_csp(&config, &[hash_of("boot()")], &runtime_sources()); + let csp = build_csp(&config, &[hash_of("boot()")], &runtime_sources(), &[]); for name in ["img-src ", "media-src "] { let directive = csp .split("; ") @@ -468,7 +485,7 @@ mod tests { #[test] fn build_csp_allows_blob_connections_for_camera_background_media() { let config = default_csp_config(); - let csp = build_csp(&config, &[hash_of("boot()")], &runtime_sources()); + let csp = build_csp(&config, &[hash_of("boot()")], &runtime_sources(), &[]); let connect = csp .split("; ") .find(|directive| directive.starts_with("connect-src ")) @@ -502,6 +519,7 @@ mod tests { &config, &[hash_of("first()"), hash_of("second()")], &runtime_sources(), + &[], ); let script = csp .split("; ") @@ -529,6 +547,7 @@ mod tests { &default_csp_config(), &[hash_of("boot()")], &runtime_sources(), + &[], ); assert!(!csp.contains("hcaptcha")); assert!(!csp.contains("challenges.cloudflare.com")); @@ -537,7 +556,7 @@ mod tests { #[test] fn csp_no_double_spaces_or_trailing_semicolons() { let config = default_csp_config(); - let csp = build_csp(&config, &[hash_of("boot()")], &runtime_sources()); + let csp = build_csp(&config, &[hash_of("boot()")], &runtime_sources(), &[]); assert!(!csp.contains(" "), "CSP contains double spaces"); assert!(!csp.ends_with(';'), "CSP ends with semicolon"); assert!(!csp.ends_with("; "), "CSP ends with semicolon+space"); @@ -555,14 +574,14 @@ mod tests { ), ..Default::default() }; - let csp = build_csp(&config, &[hash_of("boot()")], &runtime_sources()); + let csp = build_csp(&config, &[hash_of("boot()")], &runtime_sources(), &[]); assert!(csp.contains("report-uri https://example.com/csp-report")); } #[test] fn build_csp_excludes_report_uri_when_none() { let config = default_csp_config(); - let csp = build_csp(&config, &[hash_of("boot()")], &runtime_sources()); + let csp = build_csp(&config, &[hash_of("boot()")], &runtime_sources(), &[]); assert!(!csp.contains("report-uri")); } @@ -574,7 +593,7 @@ mod tests { media_endpoint: Some(endpoint("https://media.example.test")), ..Default::default() }; - let csp = build_csp(&config, &[hash_of("boot()")], &runtime_sources); + let csp = build_csp(&config, &[hash_of("boot()")], &runtime_sources, &[]); assert!(csp.contains("style-src 'self' 'unsafe-inline'")); assert!(csp.contains("https://static.example.test")); assert!(csp.contains("https://media.example.test")); @@ -616,7 +635,7 @@ mod tests { ..Default::default() }; - let csp = build_csp(&config, &[hash_of("boot()")], &runtime_sources); + let csp = build_csp(&config, &[hash_of("boot()")], &runtime_sources, &[]); assert!(csp.contains("http://localhost:3900")); assert!(csp.contains("http://fluxer-uploads.localhost:3900")); @@ -654,7 +673,7 @@ mod tests { }; let policy = CompiledCspPolicy::compile(default_csp_config(), &configured).unwrap(); - let headers = policy.spa_headers(&[hash_of("boot()")]); + let headers = policy.spa_headers(&[hash_of("boot()")], &[]); let header = headers.enforced.to_str().unwrap(); assert!(header.contains(hash_of("boot()").as_source())); @@ -670,7 +689,7 @@ mod tests { config.media_endpoint = Some(endpoint("https://media.configured.test/")); let policy = CompiledCspPolicy::from_config(&config).unwrap(); - let headers = policy.spa_headers(&[hash_of("boot()")]); + let headers = policy.spa_headers(&[hash_of("boot()")], &[]); let header = headers.enforced.to_str().unwrap(); for directive in [ "img-src", @@ -721,8 +740,69 @@ mod tests { ); let hashes = [hash_of("boot()")]; assert_eq!( - policy.spa_headers(&hashes).enforced.to_str().unwrap(), - build_csp(&config, &hashes, &sources) + policy.spa_headers(&hashes, &[]).enforced.to_str().unwrap(), + build_csp(&config, &hashes, &sources, &[]) + ); + } + + #[test] + fn branding_image_origins_are_granted_to_images_and_nothing_else() { + let policy = CompiledCspPolicy::compile(default_csp_config(), &runtime_sources()).unwrap(); + let branding = [ + endpoint("https://example.test"), + endpoint("http://assets.example.test:8443"), + ]; + + let headers = policy.spa_headers(&[hash_of("boot()")], &branding); + let header = headers.enforced.to_str().unwrap(); + + for directive in header.split("; ") { + let grants_branding = directive + .split(' ') + .any(|source| branding.iter().any(|origin| origin.csp_origin() == source)); + assert_eq!( + grants_branding, + directive.starts_with("img-src "), + "unexpected branding grant in: {directive}" + ); + } + let img = header + .split("; ") + .find(|directive| directive.starts_with("img-src ")) + .unwrap(); + assert!(img.ends_with(" https://example.test http://assets.example.test:8443")); + assert!( + !policy + .asset_header() + .to_str() + .unwrap() + .contains("example.test") + ); + } + + #[test] + fn a_branding_image_origin_the_policy_already_grants_is_not_repeated() { + let configured = RuntimeCspSources { + media_endpoint: Some(endpoint("https://media.example.test/media")), + ..Default::default() + }; + let policy = CompiledCspPolicy::compile(default_csp_config(), &configured).unwrap(); + + let headers = policy.spa_headers( + &[hash_of("boot()")], + &[endpoint("https://media.example.test")], + ); + let header = headers.enforced.to_str().unwrap(); + let img = header + .split("; ") + .find(|directive| directive.starts_with("img-src ")) + .unwrap(); + + assert_eq!( + img.split(' ') + .filter(|source| *source == "https://media.example.test") + .count(), + 1 ); } } diff --git a/fluxer_app_proxy/src/lib.rs b/fluxer_app_proxy/src/lib.rs index d60750dc4..c0b1bab00 100644 --- a/fluxer_app_proxy/src/lib.rs +++ b/fluxer_app_proxy/src/lib.rs @@ -1,5 +1,6 @@ // SPDX-License-Identifier: AGPL-3.0-or-later +pub mod branding_images; pub mod config; pub mod csp; mod local_files; diff --git a/fluxer_app_proxy/src/main.rs b/fluxer_app_proxy/src/main.rs index d5a9e340c..fcd827793 100644 --- a/fluxer_app_proxy/src/main.rs +++ b/fluxer_app_proxy/src/main.rs @@ -8,6 +8,7 @@ use fluxer_app_proxy::{ state::{AppState, build_http_client}, }; use std::sync::Arc; +use std::time::Duration; use tokio::{net::TcpListener, runtime::Builder}; use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; @@ -37,6 +38,14 @@ fn main() -> anyhow::Result<()> { .await .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.http_client.clone(), + state.config.discovery_upstream_url.clone(), + Duration::from_millis(state.config.discovery_refresh_interval_ms), + ) + }); + let router = build_router(state); let listener = TcpListener::bind(&addr) .await @@ -48,6 +57,9 @@ fn main() -> anyhow::Result<()> { .await .context("app proxy server exited unexpectedly")?; + if let Some(branding_refresh) = branding_refresh { + branding_refresh.abort(); + } Ok(()) }) } diff --git a/fluxer_app_proxy/src/routes/assets_proxy.rs b/fluxer_app_proxy/src/routes/assets_proxy.rs index b0ca7af39..3ea641082 100644 --- a/fluxer_app_proxy/src/routes/assets_proxy.rs +++ b/fluxer_app_proxy/src/routes/assets_proxy.rs @@ -235,6 +235,9 @@ 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, + ), config: Arc::new(config), csp, http_client: build_http_client().unwrap(), diff --git a/fluxer_app_proxy/src/routes/health.rs b/fluxer_app_proxy/src/routes/health.rs index 4638175ed..c5b0174ba 100644 --- a/fluxer_app_proxy/src/routes/health.rs +++ b/fluxer_app_proxy/src/routes/health.rs @@ -28,6 +28,9 @@ 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, + ), config: Arc::new(config), csp, http_client: build_http_client().unwrap(), diff --git a/fluxer_app_proxy/src/routes/spa_index.rs b/fluxer_app_proxy/src/routes/spa_index.rs index 47ef88a6d..ca5114593 100644 --- a/fluxer_app_proxy/src/routes/spa_index.rs +++ b/fluxer_app_proxy/src/routes/spa_index.rs @@ -204,7 +204,10 @@ async fn serve_spa_index(state: &AppState, headers: &HeaderMap, request_path: &s return StatusCode::INTERNAL_SERVER_ERROR.into_response(); } }; - let csp = state.csp.spa_headers(&inline_script_hashes(&html)); + let csp = state.csp.spa_headers( + &inline_script_hashes(&html), + &state.branding_image_origins.current(), + ); build_spa_response(html.into_boxed_str(), &csp, should_bust_dev_assets) } @@ -889,6 +892,60 @@ mod tests { assert!(served.contains(r#" Vec { + policy + .split("; ") + .find(|directive| directive.starts_with("img-src ")) + .expect("the content security policy has no img-src directive") + .split(' ') + .skip(1) + .map(str::to_owned) + .collect() + } + + fn branding_origin() -> HttpEndpoint { + HttpEndpoint::origin_of("TEST_BRANDING_IMAGE", "https://example.test/logo.png") + .expect("the test branding image must have an origin") + } + + #[tokio::test] + async fn a_self_hosted_instance_allows_its_branding_image_origins() { + let mut state = assemble_spa_state(SpaIndexSource::bundled(SHIPPED_APP_SHELL), None, true); + let plain = serve_spa_index(&state, &HeaderMap::new(), "/login").await; + let plain_images = image_sources_of(&policy_of(&plain)); + 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()]); + let branded = serve_spa_index(&state, &HeaderMap::new(), "/login").await; + let branded_policy = policy_of(&branded); + let branded_document = read_document(branded).await; + + let mut expected_images = plain_images; + expected_images.push("https://example.test".to_owned()); + assert_eq!(image_sources_of(&branded_policy), expected_images); + assert_eq!(branded_policy.matches("https://example.test").count(), 1); + assert_eq!(branded_document, plain_document); + assert!(!branded_document.contains("example.test")); + } + + #[tokio::test] + 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()]; + let mut state = spa_state_serving(SHIPPED_APP_SHELL); + state.branding_image_origins = + crate::branding_images::BrandingImageOrigins::from_config(&config); + + let response = serve_spa_index(&state, &HeaderMap::new(), "/login").await; + + assert!( + !image_sources_of(&policy_of(&response)).contains(&"https://example.test".to_owned()) + ); + } + #[tokio::test] async fn the_official_instance_keeps_its_link_preview_metadata() { let state = spa_state_serving(SHIPPED_APP_SHELL); @@ -1080,6 +1137,9 @@ 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, + ), config: Arc::new(config), csp, http_client: reqwest::Client::new(), diff --git a/fluxer_app_proxy/src/state.rs b/fluxer_app_proxy/src/state.rs index ac0a1c450..0c6bcc63c 100644 --- a/fluxer_app_proxy/src/state.rs +++ b/fluxer_app_proxy/src/state.rs @@ -1,5 +1,6 @@ // SPDX-License-Identifier: AGPL-3.0-or-later +use crate::branding_images::BrandingImageOrigins; use crate::config::{AppProxyConfig, HttpUrl}; use crate::csp::CompiledCspPolicy; use crate::local_files::LocalFileStore; @@ -82,6 +83,7 @@ impl SpaIndexSource { pub struct AppState { pub config: Arc, pub csp: Arc, + pub branding_image_origins: BrandingImageOrigins, pub http_client: reqwest::Client, pub spa_index_source: SpaIndexSource, pub local_asset_prefixes: Option>, @@ -102,6 +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), config, csp, http_client, diff --git a/fluxer_docs/src/content/docs/admin-api/instance.mdx b/fluxer_docs/src/content/docs/admin-api/instance.mdx index cdb06ad6c..51db61604 100644 --- a/fluxer_docs/src/content/docs/admin-api/instance.mdx +++ b/fluxer_docs/src/content/docs/admin-api/instance.mdx @@ -673,7 +673,7 @@ The body has one optional object for each section. Fluxer leaves an absent secti 1 The supplied fields are merged over the stored configuration and the result is validated as a whole. Every supplied endpoint URL uses `https`, has no credentials and no fragment, and resolves to a publicly routable address, otherwise the request returns 400 `INVALID_FORM_BODY` with `INVALID_URL_FORMAT` or `URL_NOT_PUBLICLY_ROUTABLE` -2 `branding.product_name` is 1 to 80 characters, every branding and legal URL is at most 2048 characters and nullable, `branding.theme_color` is at most 64 characters and nullable, and `setup.configured` and `registration.collect_date_of_birth` are booleans. `branding.premium_product_name` is 1 to 40 characters, and null restores the default. `branding.premium_info_url` and `legal.guidelines_url` are absolute `http` or `https` URLs. Every string is trimmed before it is stored, and null or an empty string clears a legal URL +2 `branding.product_name` is 1 to 80 characters, every branding and legal URL is at most 2048 characters and nullable, `branding.theme_color` is a hex colour (`#rgb` or `#rrggbb`, the `#` is optional) and nullable, and `setup.configured` and `registration.collect_date_of_birth` are booleans. The theme colour is returned as lowercase `#rrggbb`, and pure black is treated as unset and returned as null. `branding.premium_product_name` is 1 to 40 characters, and null restores the default. `branding.premium_info_url` and `legal.guidelines_url` are absolute `http` or `https` URLs. Every string is trimmed before it is stored, and null or an empty string clears a legal URL `push_relay` takes only `relay_consent_accepted`. Fluxer sets `relay_consent_accepted_at` and `relay_consent_accepted_by` itself on the request that changes that flag, and accepts neither from a request. Turning the flag on stamps the current time and the acting account, and turning it off clears both to null. A request that repeats the flag it already holds leaves the stamp alone. diff --git a/fluxer_docs/src/content/docs/http-api/instance.mdx b/fluxer_docs/src/content/docs/http-api/instance.mdx index d9546cf16..dcbd961cf 100644 --- a/fluxer_docs/src/content/docs/http-api/instance.mdx +++ b/fluxer_docs/src/content/docs/http-api/instance.mdx @@ -426,7 +426,7 @@ The instance identity a client renders: its name, its images, its theme colour, | logo_url | ?string | Application logo URL, or null | | wordmark_url | ?string | Application wordmark URL, or null | | favicon_url | ?string | Browser favicon URL, or null | -| theme_color | ?string | Browser theme colour, or null | +| theme_color | ?string | Brand and browser theme colour as lowercase `#rrggbb`, or null | | status_page_url | ?string | Public status page URL, or null | | status_page_incident_history_url | ?string | Status page incident history URL, or null | | premium_product_name | string | Name of the premium tier a client displays. A self-hosted deployment reports `Premium` until the operator sets a name | diff --git a/fluxer_docs/src/content/docs/operator/configuration.mdx b/fluxer_docs/src/content/docs/operator/configuration.mdx index e8876740d..91f2e9afe 100644 --- a/fluxer_docs/src/content/docs/operator/configuration.mdx +++ b/fluxer_docs/src/content/docs/operator/configuration.mdx @@ -1234,6 +1234,8 @@ Default empty. The GeoIP database. A filesystem path, or an `s3://bucket/key` UR Compose forwards every name below from `.env`. The admin dashboard also sets the product name and the brand assets, and a value saved there wins. All are optional. +A brand asset on another origin than the instance needs no CSP setting. `app-proxy` adds the origin of each of the five image URLs below to `img-src`, whether the URL is set here or in the dashboard. [Content Security Policy](#content-security-policy) has the timing. + #### `FLUXER_APP_PRODUCT_NAME` Default `Fluxer`. The product name clients display and the API writes in its emails and report flows. Also settable in the admin dashboard, which wins. Compose forwards it from `.env`. @@ -1260,7 +1262,7 @@ Default empty. The favicon. Its origin is added to the CSP by `app-proxy`. Compo #### `FLUXER_APP_THEME_COLOR` -Default empty. The theme colour. Compose forwards it from `.env`. +Default empty. The theme colour, as a hex value such as `#4641d9`. The web app uses it as its brand colour and as the browser theme colour. A value that is not `#rgb` or `#rrggbb` is ignored, and so is pure black. Compose forwards it from `.env`. #### `FLUXER_APP_STATUS_PAGE_URL` @@ -1592,6 +1594,14 @@ Default `./static`. Where the client's SPA bundle lives, unrelated to `static-pr No default. An upstream to fetch `index.html` from. Leave unset for the shipped image. Compose forwards it from `.env`. +#### `DISCOVERY_UPSTREAM_URL` + +Default `http://localhost:8088/api/.well-known/fluxer`. Where the self-hosted image fetches the discovery document, which it reads only for the branding image URLs. Compose sets `http://edge:8088/.well-known/fluxer`. That internal listener supplies the client-IP header the API requires, so leave it pointed at the edge. + +#### `DISCOVERY_REFRESH_INTERVAL_MS` + +Default `60000`. How often discovery is refetched, in milliseconds, and so the longest a branding image saved in the admin dashboard waits for its origin to reach the CSP. A value under `1000` counts as `1000`. A failed fetch keeps the previous origins and is retried with a backoff of up to five minutes. Unprefixed name. Compose forwards it from `.env`. + #### `FLUXER_APP_PROXY_SAME_ORIGIN_HOSTS` Default empty. Hostnames, comma separated, without a scheme, port or path. When a request's `Host` is in the list, the page bootstrap points the web app and its API at that host, as `https://` and `https:///api`. Every other request gets the discovery values unchanged. Set it when the web app is served on more than one hostname and each hostname routes `/api` to the API. Invalid entries are logged and dropped. Compose forwards it from `.env`. @@ -1636,7 +1646,7 @@ Every name below goes in `.env`. `app-proxy` reads its environment at container `FLUXER_CSP_EXTRA_DEFAULT_SRC`, `FLUXER_CSP_EXTRA_CONNECT_SRC`, `FLUXER_CSP_EXTRA_IMG_SRC`, `FLUXER_CSP_EXTRA_MEDIA_SRC`, `FLUXER_CSP_EXTRA_FONT_SRC`, `FLUXER_CSP_EXTRA_SCRIPT_SRC`, `FLUXER_CSP_EXTRA_STYLE_SRC`, `FLUXER_CSP_EXTRA_FRAME_SRC`, `FLUXER_CSP_EXTRA_WORKER_SRC`, and `FLUXER_CSP_EXTRA_MANIFEST_SRC` take one or more sources separated by commas, spaces, tabs, or newlines. Blank entries and sources the directive already lists are dropped. `FLUXER_CSP_REPORT_URI` sets a single `report-uri` value. -`object-src`, `base-uri`, and `frame-ancestors` are fixed and have no override. `app-proxy` reads the discovery document and adds the static CDN endpoint, the media endpoint, and the origins of the configured branding images. A stack on one hostname therefore needs no extra sources. The usual reason to set one is a voice server on another hostname, which needs its WebSocket origin in `FLUXER_CSP_EXTRA_CONNECT_SRC`. [Voice media does not use the proxy](/operator/reverse-proxy/#voice-media-does-not-use-the-proxy) has that line in place. +`object-src`, `base-uri`, and `frame-ancestors` are fixed and have no override. `app-proxy` adds the static CDN endpoint and the media endpoint from its environment. The self-hosted image also adds the origin of each branding image to `img-src`. At start it takes them from `FLUXER_APP_ICON_URL`, `FLUXER_APP_SYMBOL_URL`, `FLUXER_APP_LOGO_URL`, `FLUXER_APP_WORDMARK_URL` and `FLUXER_APP_FAVICON_URL`. It then polls the discovery document every `DISCOVERY_REFRESH_INTERVAL_MS`, where a value saved in the admin dashboard wins, so a dashboard change reaches the header within a minute by default and needs no restart. The header is the same for every visitor. A stack on one hostname therefore needs no extra sources, and neither does a logo or favicon on another domain. The usual reason to set one is a voice server on another hostname, which needs its WebSocket origin in `FLUXER_CSP_EXTRA_CONNECT_SRC`. [Voice media does not use the proxy](/operator/reverse-proxy/#voice-media-does-not-use-the-proxy) has that line in place. A front proxy must not add a [Content-Security-Policy of its own](/operator/reverse-proxy/#send-no-content-security-policy-of-its-own). @@ -1846,7 +1856,7 @@ The third-party images above are the defaults of the names under [Images](#image The edge and LiveKit are the only services that publish ports. The edge publishes 80/tcp, 443/tcp, 443/udp, or one plain-HTTP port under the overlay. LiveKit publishes 7881/tcp and 7882/udp. Everything else is reachable only over the bridge network. -Most settings reach `api`, `worker`, `gateway`, `media-proxy`, `push`, `admin` and the internal services through one shared environment block, so one line in `.env` reaches every one of them that reads it. `app-proxy` takes its own list, since it reads the static, media and S3 upload origins and its own names. The edge takes the `FLUXER_EDGE_` variables and `FLUXER_ADMIN_BASE_PATH`. The third-party services take their image name, their memory limit, and their own tuning: `postgres` the password, database, role and `FLUXER_POSTGRES_` server settings, `meilisearch` the master key and `FLUXER_MEILISEARCH_` values, `valkey` the `FLUXER_VALKEY_` values, `seaweedfs` its heap ceiling and telemetry switch, and `livekit` the key pair, the port, STUN and log level variables. `static-proxy` reads no environment variables. +Most settings reach `api`, `worker`, `gateway`, `media-proxy`, `push`, `admin` and the internal services through one shared environment block, so one line in `.env` reaches every one of them that reads it. `app-proxy` takes its own list, since it reads the static, media and S3 upload origins, the branding image URLs and its own names. The edge takes the `FLUXER_EDGE_` variables and `FLUXER_ADMIN_BASE_PATH`. The third-party services take their image name, their memory limit, and their own tuning: `postgres` the password, database, role and `FLUXER_POSTGRES_` server settings, `meilisearch` the master key and `FLUXER_MEILISEARCH_` values, `valkey` the `FLUXER_VALKEY_` values, `seaweedfs` its heap ceiling and telemetry switch, and `livekit` the key pair, the port, STUN and log level variables. `static-proxy` reads no environment variables. The internal services each run a router, which takes requests and holds no state, and one shard, which holds the caches and the database connections. The shipped stack fixes `FLUXER_SVC_SHARD_COUNT` at `1`. @@ -1946,7 +1956,7 @@ Default `256mb`. The ceiling for `static-proxy`. The service reads no environmen #### `FLUXER_APP_PROXY_MEMORY_LIMIT` -Default `256mb`. The ceiling for `app-proxy`. It reads no database. +Default `256mb`. The ceiling for `app-proxy`. It keeps the branding image origins from discovery in memory and reads no database. #### `FLUXER_SNOWFLAKES_MEMORY_LIMIT` diff --git a/packages/schema/src/domains/admin/AdminSchemas.ts b/packages/schema/src/domains/admin/AdminSchemas.ts index 9fd37bff8..849be041e 100644 --- a/packages/schema/src/domains/admin/AdminSchemas.ts +++ b/packages/schema/src/domains/admin/AdminSchemas.ts @@ -565,6 +565,8 @@ function isAbsoluteHttpUrl(value: string): boolean { } } +const HEX_THEME_COLOR_PATTERN = /^(?:#?(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6}))?$/; + const AppPublicConfigUpdateRequest = z.object({ branding: z .object({ @@ -574,7 +576,7 @@ const AppPublicConfigUpdateRequest = z.object({ logo_url: z.string().trim().max(2048).nullish(), wordmark_url: z.string().trim().max(2048).nullish(), favicon_url: z.string().trim().max(2048).nullish(), - theme_color: z.string().trim().max(64).nullish(), + theme_color: z.string().trim().regex(HEX_THEME_COLOR_PATTERN).nullish(), status_page_url: z.string().trim().max(2048).nullish(), status_page_incident_history_url: z.string().trim().max(2048).nullish(), premium_product_name: z.string().trim().min(1).max(40).nullable().optional(),