From 50c28aa8fa0c40c434370d12df2f7d30968081cb Mon Sep 17 00:00:00 2001 From: Hampus Date: Sun, 11 Oct 2026 15:57:12 +0200 Subject: [PATCH] fix(admin): use instance branding on self-hosted (#3401) --- fluxer_admin/src/api/client.rs | 9 ++ .../src/api/types/instance_billing.rs | 93 +++++++++++++++---- fluxer_admin/src/middleware/auth.rs | 29 +++--- fluxer_admin/src/routes/auth.rs | 3 +- fluxer_admin/src/routes/users.rs | 3 + fluxer_admin/src/state.rs | 11 +++ fluxer_admin/src/templates/layout.rs | 83 ++++++++++++++++- fluxer_admin/src/templates/pages/login.rs | 16 +++- fluxer_admin/src/templates/pages/system_dm.rs | 8 +- 9 files changed, 206 insertions(+), 49 deletions(-) diff --git a/fluxer_admin/src/api/client.rs b/fluxer_admin/src/api/client.rs index c0e21c258..995fd61a8 100644 --- a/fluxer_admin/src/api/client.rs +++ b/fluxer_admin/src/api/client.rs @@ -49,6 +49,15 @@ impl AdminApiClient { Self { generated } } + pub fn anonymous(http_client: &reqwest::Client, config: &AdminConfig) -> Self { + let generated = GeneratedClient::new_with_client( + &config.api_endpoint, + http_client.clone(), + build_proxy_client_ip_headers(config), + ); + Self { generated } + } + fn build_url(&self, path: &str, query_params: Option<&[(&str, &str)]>) -> String { let mut url = format!("{}{}", self.generated.baseurl(), path); let query = query_params diff --git a/fluxer_admin/src/api/types/instance_billing.rs b/fluxer_admin/src/api/types/instance_billing.rs index 8de9f49e5..5fd63522f 100644 --- a/fluxer_admin/src/api/types/instance_billing.rs +++ b/fluxer_admin/src/api/types/instance_billing.rs @@ -119,6 +119,9 @@ pub struct InstancePremiumDiscoveryAppPublic { #[derive(Clone, Debug, Default, Deserialize)] pub struct InstancePremiumDiscoveryBranding { + pub product_name: Option, + pub icon_url: Option, + pub favicon_url: Option, pub premium_product_name: Option, } @@ -130,46 +133,67 @@ pub struct InstancePremiumDiscoveryFeatures { impl InstancePremiumDiscovery { pub fn premium_product_name(&self) -> Option<&str> { - self.app_public - .branding - .premium_product_name - .as_deref() - .map(str::trim) - .filter(|name| !name.is_empty()) + non_blank(self.app_public.branding.premium_product_name.as_deref()) } } -#[derive(Clone, Debug, Eq, PartialEq)] +#[derive(Clone, Debug, Default, Eq, PartialEq)] pub struct PremiumBranding { pub name: Option, pub premium_enabled: bool, + pub product_name: Option, + pub favicon_url: Option, } impl PremiumBranding { pub fn from_discovery(discovery: &InstancePremiumDiscovery) -> Self { + let branding = &discovery.app_public.branding; Self { name: discovery.premium_product_name().map(str::to_owned), premium_enabled: discovery.features.premium_enabled, + product_name: non_blank(branding.product_name.as_deref()).map(str::to_owned), + favicon_url: favicon_url( + branding.favicon_url.as_deref(), + branding.icon_url.as_deref(), + ), } } pub fn from_instance_config(config: &InstanceConfigResponse) -> Self { - Self::from_config_parts( - config.self_hosted, - &config.app_public.branding.premium_product_name, - config.policy.premium_mode, - ) + let branding = &config.app_public.branding; + Self { + product_name: non_blank(Some(&branding.product_name)).map(str::to_owned), + favicon_url: favicon_url( + branding.favicon_url.as_deref(), + branding.icon_url.as_deref(), + ), + ..Self::from_config_parts( + config.self_hosted, + &branding.premium_product_name, + config.policy.premium_mode, + ) + } } fn from_config_parts(self_hosted: bool, name: &str, premium_mode: PremiumMode) -> Self { - let name = name.trim(); Self { - name: (!name.is_empty()).then(|| name.to_owned()), + name: non_blank(Some(name)).map(str::to_owned), premium_enabled: !self_hosted || matches!(premium_mode, PremiumMode::Mirror), + ..Self::default() } } } +fn non_blank(value: Option<&str>) -> Option<&str> { + value.map(str::trim).filter(|value| !value.is_empty()) +} + +fn favicon_url(favicon_url: Option<&str>, icon_url: Option<&str>) -> Option { + non_blank(favicon_url) + .or_else(|| non_blank(icon_url)) + .map(str::to_owned) +} + #[cfg(test)] mod tests { use super::*; @@ -304,18 +328,52 @@ mod tests { PremiumBranding::from_discovery(&discovery), PremiumBranding { name: Some("Gold".to_owned()), - premium_enabled: true + premium_enabled: true, + product_name: Some("Example".to_owned()), + favicon_url: None, } ); } + #[test] + fn instance_branding_prefers_the_favicon_and_falls_back_to_the_icon() { + let discovery = |branding| -> InstancePremiumDiscovery { + serde_json::from_value(json!({"app_public": {"branding": branding}})) + .expect("discovery") + }; + let both = discovery(json!({ + "favicon_url": "https://media.example/favicon.png", + "icon_url": "https://media.example/icon.png" + })); + assert_eq!( + PremiumBranding::from_discovery(&both) + .favicon_url + .as_deref(), + Some("https://media.example/favicon.png") + ); + let icon_only = + discovery(json!({"favicon_url": " ", "icon_url": "https://media.example/icon.png"})); + assert_eq!( + PremiumBranding::from_discovery(&icon_only) + .favicon_url + .as_deref(), + Some("https://media.example/icon.png") + ); + let neither = discovery(json!({"product_name": " "})); + assert_eq!( + PremiumBranding::from_discovery(&neither), + PremiumBranding::default() + ); + } + #[test] fn premium_branding_from_instance_config_matches_discovery_rules() { assert_eq!( PremiumBranding::from_config_parts(true, " Gold ", PremiumMode::Everyone), PremiumBranding { name: Some("Gold".to_owned()), - premium_enabled: false + premium_enabled: false, + ..PremiumBranding::default() } ); assert!( @@ -325,7 +383,8 @@ mod tests { PremiumBranding::from_config_parts(false, " ", PremiumMode::Everyone), PremiumBranding { name: None, - premium_enabled: true + premium_enabled: true, + ..PremiumBranding::default() } ); } diff --git a/fluxer_admin/src/middleware/auth.rs b/fluxer_admin/src/middleware/auth.rs index 57db8d025..ab72a0295 100644 --- a/fluxer_admin/src/middleware/auth.rs +++ b/fluxer_admin/src/middleware/auth.rs @@ -1,7 +1,7 @@ // SPDX-License-Identifier: AGPL-3.0-or-later use crate::{ - api::types::AdminUser, + api::types::{AdminUser, PremiumBranding}, middleware::flash, session::{self, Session}, state::AppState, @@ -16,6 +16,7 @@ use axum::{ pub struct AuthContext { pub session: Session, pub admin_user: Option, + pub branding: Option, } pub async fn require_auth( @@ -28,27 +29,19 @@ pub async fn require_auth( let Some(session) = session else { return Redirect::to(&format!("{}/auth/start", config.base_path)).into_response(); }; - let admin_result = fetch_admin_user(state.http_client(), config, &session).await; - match admin_result { + let admin_user = match fetch_admin_user(state.http_client(), config, &session).await { AdminFetchResult::Unauthorized => { let login_url = format!("{}/login", config.base_path); return clear_session_and_redirect(&login_url, config); } - AdminFetchResult::Ok(admin_user) => { - let auth_context = AuthContext { - session, - admin_user: Some(*admin_user), - }; - request.extensions_mut().insert(auth_context); - } - AdminFetchResult::None => { - let auth_context = AuthContext { - session, - admin_user: None, - }; - request.extensions_mut().insert(auth_context); - } - } + AdminFetchResult::Ok(admin_user) => Some(*admin_user), + AdminFetchResult::None => None, + }; + request.extensions_mut().insert(AuthContext { + session, + admin_user, + branding: state.self_hosted_branding().await, + }); let had_flash = flash::extract_flash(&request); if let Some(ref fd) = had_flash { diff --git a/fluxer_admin/src/routes/auth.rs b/fluxer_admin/src/routes/auth.rs index bcd6c5a16..12db99bc5 100644 --- a/fluxer_admin/src/routes/auth.rs +++ b/fluxer_admin/src/routes/auth.rs @@ -87,7 +87,8 @@ async fn login_page( Some(_) => Some("Login error. Please try again."), None => None, }; - Html(login::login_page(config, error_msg).into_string()).into_response() + let branding = state.self_hosted_branding().await; + Html(login::login_page(config, branding.as_ref(), error_msg).into_string()).into_response() } async fn auth_start(State(state): State) -> Response { diff --git a/fluxer_admin/src/routes/users.rs b/fluxer_admin/src/routes/users.rs index 857f26cdf..7d4a02a59 100644 --- a/fluxer_admin/src/routes/users.rs +++ b/fluxer_admin/src/routes/users.rs @@ -480,11 +480,13 @@ mod tests { let gold = PremiumBranding { name: Some("Gold".to_owned()), premium_enabled: true, + ..PremiumBranding::default() }; assert_eq!(premium_badge_name(Some(&gold)).as_deref(), Some("Gold")); let unnamed = PremiumBranding { name: None, premium_enabled: true, + ..PremiumBranding::default() }; assert_eq!( premium_badge_name(Some(&unnamed)).as_deref(), @@ -493,6 +495,7 @@ mod tests { let everyone = PremiumBranding { name: Some("Gold".to_owned()), premium_enabled: false, + ..PremiumBranding::default() }; assert_eq!(premium_badge_name(Some(&everyone)), None); assert_eq!(premium_badge_name(None).as_deref(), Some("Premium")); diff --git a/fluxer_admin/src/state.rs b/fluxer_admin/src/state.rs index cbdc82df4..c3b25da63 100644 --- a/fluxer_admin/src/state.rs +++ b/fluxer_admin/src/state.rs @@ -88,6 +88,17 @@ impl AppState { Some(branding) } + pub async fn self_hosted_branding(&self) -> Option { + if !self.config().self_hosted { + return None; + } + self.premium_branding(&AdminApiClient::anonymous( + self.http_client(), + self.config(), + )) + .await + } + pub async fn account_identity(&self, client: &AdminApiClient) -> AccountIdentityMode { self.account_identity_settings(client).await.mode } diff --git a/fluxer_admin/src/templates/layout.rs b/fluxer_admin/src/templates/layout.rs index 1f4d512f8..b881286bc 100644 --- a/fluxer_admin/src/templates/layout.rs +++ b/fluxer_admin/src/templates/layout.rs @@ -1,6 +1,10 @@ // SPDX-License-Identifier: AGPL-3.0-or-later -use crate::{api::types::FlashLevel, config::AdminConfig, middleware::auth::AuthContext}; +use crate::{ + api::types::{FlashLevel, PremiumBranding}, + config::AdminConfig, + middleware::auth::AuthContext, +}; use maud::{DOCTYPE, Markup, PreEscaped, html}; use super::components::drawer::drawer_controller_script; @@ -11,6 +15,28 @@ use super::layout_scripts::{ }; use super::layout_sidebar::render_sidebar; +const DEFAULT_PRODUCT_NAME: &str = "Fluxer"; + +pub fn product_name(branding: Option<&PremiumBranding>) -> &str { + branding + .and_then(|branding| branding.product_name.as_deref()) + .unwrap_or(DEFAULT_PRODUCT_NAME) +} + +pub fn favicon_links(config: &AdminConfig, branding: Option<&PremiumBranding>) -> Markup { + let cdn = &config.static_cdn_endpoint; + html! { + @if let Some(favicon_url) = branding.and_then(|branding| branding.favicon_url.as_deref()) { + link rel="icon" href=(favicon_url); + } @else { + link rel="icon" type="image/x-icon" href={(cdn) "/web/favicon.ico"}; + link rel="apple-touch-icon" href={(cdn) "/web/apple-touch-icon.png"}; + link rel="icon" type="image/png" sizes="32x32" href={(cdn) "/web/favicon-32x32.png"}; + link rel="icon" type="image/png" sizes="16x16" href={(cdn) "/web/favicon-16x16.png"}; + } + } +} + fn cache_busted_asset(base_path: &str, asset_version: &str, path: &str) -> String { format!("{base_path}{path}?t={asset_version}") } @@ -72,10 +98,7 @@ pub fn admin_layout_ext( title { (title) " ~ Fluxer Admin" } link rel="stylesheet" href={(base) "/static/fonts/" (crate::fonts::STYLESHEET_FILE_NAME)}; link rel="stylesheet" href=(cache_busted_asset(base, asset_version, "/static/app.css")); - link rel="icon" type="image/x-icon" href={(config.static_cdn_endpoint) "/web/favicon.ico"}; - link rel="apple-touch-icon" href={(config.static_cdn_endpoint) "/web/apple-touch-icon.png"}; - link rel="icon" type="image/png" sizes="32x32" href={(config.static_cdn_endpoint) "/web/favicon-32x32.png"}; - link rel="icon" type="image/png" sizes="16x16" href={(config.static_cdn_endpoint) "/web/favicon-16x16.png"}; + (favicon_links(config, auth.branding.as_ref())) script src=(cache_busted_asset(base, asset_version, "/static/htmx.min.js")) defer {} } body class="min-h-screen overflow-hidden bg-neutral-50" hx-boost="true" { @@ -126,3 +149,53 @@ fn render_flash(flash: &crate::api::types::FlashMessage) -> Markup { } } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::ProxyConfig; + + fn config() -> AdminConfig { + AdminConfig { + host: String::new(), + port: 3020, + secret_key_base: "test-secret".to_owned(), + base_path: String::new(), + api_endpoint: String::new(), + media_endpoint: String::new(), + static_cdn_endpoint: "https://static.example".to_owned(), + reports_bucket_origin: String::new(), + admin_endpoint: String::new(), + web_app_endpoint: String::new(), + oauth_client_id: String::new(), + oauth_client_secret: String::new(), + oauth_redirect_uri: String::new(), + build_version: "test".to_owned(), + self_hosted: true, + proxy: ProxyConfig { + trust_client_ip_header: false, + client_ip_header_name: String::new(), + }, + } + } + + #[test] + fn instance_branding_replaces_the_fluxer_name_and_favicons() { + let branding = PremiumBranding { + product_name: Some("Example".to_owned()), + favicon_url: Some("https://media.example/favicon.png".to_owned()), + ..PremiumBranding::default() + }; + let links = favicon_links(&config(), Some(&branding)).into_string(); + assert!(links.contains("https://media.example/favicon.png")); + assert!(!links.contains("static.example")); + assert_eq!(product_name(Some(&branding)), "Example"); + } + + #[test] + fn missing_branding_keeps_the_fluxer_name_and_favicons() { + let links = favicon_links(&config(), Some(&PremiumBranding::default())).into_string(); + assert!(links.contains("https://static.example/web/favicon.ico")); + assert_eq!(product_name(None), "Fluxer"); + } +} diff --git a/fluxer_admin/src/templates/pages/login.rs b/fluxer_admin/src/templates/pages/login.rs index 79697fad0..6b470ce98 100644 --- a/fluxer_admin/src/templates/pages/login.rs +++ b/fluxer_admin/src/templates/pages/login.rs @@ -1,9 +1,17 @@ // SPDX-License-Identifier: AGPL-3.0-or-later -use crate::config::AdminConfig; +use crate::{ + api::types::PremiumBranding, + config::AdminConfig, + templates::layout::{favicon_links, product_name}, +}; use maud::{DOCTYPE, Markup, html}; -pub fn login_page(config: &AdminConfig, error_message: Option<&str>) -> Markup { +pub fn login_page( + config: &AdminConfig, + branding: Option<&PremiumBranding>, + error_message: Option<&str>, +) -> Markup { let base = &config.base_path; html! { (DOCTYPE) @@ -14,7 +22,7 @@ pub fn login_page(config: &AdminConfig, error_message: Option<&str>) -> Markup { title { "Login ~ Fluxer Admin" } link rel="stylesheet" href={(base) "/static/fonts/" (crate::fonts::STYLESHEET_FILE_NAME)}; link rel="stylesheet" href={(base) "/static/app.css"}; - link rel="icon" type="image/x-icon" href={(config.static_cdn_endpoint) "/web/favicon.ico"}; + (favicon_links(config, branding)) } body class="flex min-h-[100dvh] items-center justify-center bg-neutral-50 p-4" { main class="w-full max-w-sm" { @@ -38,7 +46,7 @@ pub fn login_page(config: &AdminConfig, error_message: Option<&str>) -> Markup { bg-neutral-900 text-white hover:bg-neutral-800 \ px-4 py-2 text-base w-full sm:w-fit \ focus:ring-offset-white" { - span { "Sign in with Fluxer" } + span { "Sign in with " (product_name(branding)) } } } } diff --git a/fluxer_admin/src/templates/pages/system_dm.rs b/fluxer_admin/src/templates/pages/system_dm.rs index 39dec86c1..1fa697a1b 100644 --- a/fluxer_admin/src/templates/pages/system_dm.rs +++ b/fluxer_admin/src/templates/pages/system_dm.rs @@ -11,7 +11,7 @@ use crate::{ }, page_container::{card, page_header}, }, - layout::admin_layout, + layout::{admin_layout, product_name}, }, }; use maud::{Markup, html}; @@ -37,9 +37,9 @@ pub fn system_dm_page( "Send a system DM" } p class="text-sm text-neutral-500" { - "Sent from the official Fluxer system account. Each recipient \ - will receive the same content as a DM. Progress is observable \ - on the " + "Sent from the official " (product_name(auth.branding.as_ref())) + " system account. Each recipient will receive the same content \ + as a DM. Progress is observable on the " a href=(jobs_url) class="font-medium text-neutral-900 hover:underline" { "Jobs page" }