fix(admin): use instance branding on self-hosted (#3401)

This commit is contained in:
Hampus authored and GitHub committed 2026-10-11 15:57:12 +02:00
1 parent af7724dbf6
commit 50c28aa8fa
9 files changed
+201 -44

No files matched your search

+9
View File
@@ -49,6 +49,15 @@ impl AdminApiClient {
Self { generated } 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 { fn build_url(&self, path: &str, query_params: Option<&[(&str, &str)]>) -> String {
let mut url = format!("{}{}", self.generated.baseurl(), path); let mut url = format!("{}{}", self.generated.baseurl(), path);
let query = query_params let query = query_params
+73 -14
View File
@@ -119,6 +119,9 @@ pub struct InstancePremiumDiscoveryAppPublic {
#[derive(Clone, Debug, Default, Deserialize)] #[derive(Clone, Debug, Default, Deserialize)]
pub struct InstancePremiumDiscoveryBranding { pub struct InstancePremiumDiscoveryBranding {
pub product_name: Option<String>,
pub icon_url: Option<String>,
pub favicon_url: Option<String>,
pub premium_product_name: Option<String>, pub premium_product_name: Option<String>,
} }
@@ -130,46 +133,67 @@ pub struct InstancePremiumDiscoveryFeatures {
impl InstancePremiumDiscovery { impl InstancePremiumDiscovery {
pub fn premium_product_name(&self) -> Option<&str> { pub fn premium_product_name(&self) -> Option<&str> {
self.app_public non_blank(self.app_public.branding.premium_product_name.as_deref())
.branding
.premium_product_name
.as_deref()
.map(str::trim)
.filter(|name| !name.is_empty())
} }
} }
#[derive(Clone, Debug, Eq, PartialEq)] #[derive(Clone, Debug, Default, Eq, PartialEq)]
pub struct PremiumBranding { pub struct PremiumBranding {
pub name: Option<String>, pub name: Option<String>,
pub premium_enabled: bool, pub premium_enabled: bool,
pub product_name: Option<String>,
pub favicon_url: Option<String>,
} }
impl PremiumBranding { impl PremiumBranding {
pub fn from_discovery(discovery: &InstancePremiumDiscovery) -> Self { pub fn from_discovery(discovery: &InstancePremiumDiscovery) -> Self {
let branding = &discovery.app_public.branding;
Self { Self {
name: discovery.premium_product_name().map(str::to_owned), name: discovery.premium_product_name().map(str::to_owned),
premium_enabled: discovery.features.premium_enabled, 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 { pub fn from_instance_config(config: &InstanceConfigResponse) -> Self {
Self::from_config_parts( 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, config.self_hosted,
&config.app_public.branding.premium_product_name, &branding.premium_product_name,
config.policy.premium_mode, config.policy.premium_mode,
) )
} }
}
fn from_config_parts(self_hosted: bool, name: &str, premium_mode: PremiumMode) -> Self { fn from_config_parts(self_hosted: bool, name: &str, premium_mode: PremiumMode) -> Self {
let name = name.trim();
Self { 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), 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<String> {
non_blank(favicon_url)
.or_else(|| non_blank(icon_url))
.map(str::to_owned)
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@@ -304,18 +328,52 @@ mod tests {
PremiumBranding::from_discovery(&discovery), PremiumBranding::from_discovery(&discovery),
PremiumBranding { PremiumBranding {
name: Some("Gold".to_owned()), 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] #[test]
fn premium_branding_from_instance_config_matches_discovery_rules() { fn premium_branding_from_instance_config_matches_discovery_rules() {
assert_eq!( assert_eq!(
PremiumBranding::from_config_parts(true, " Gold ", PremiumMode::Everyone), PremiumBranding::from_config_parts(true, " Gold ", PremiumMode::Everyone),
PremiumBranding { PremiumBranding {
name: Some("Gold".to_owned()), name: Some("Gold".to_owned()),
premium_enabled: false premium_enabled: false,
..PremiumBranding::default()
} }
); );
assert!( assert!(
@@ -325,7 +383,8 @@ mod tests {
PremiumBranding::from_config_parts(false, " ", PremiumMode::Everyone), PremiumBranding::from_config_parts(false, " ", PremiumMode::Everyone),
PremiumBranding { PremiumBranding {
name: None, name: None,
premium_enabled: true premium_enabled: true,
..PremiumBranding::default()
} }
); );
} }
+9 -16
View File
@@ -1,7 +1,7 @@
// SPDX-License-Identifier: AGPL-3.0-or-later // SPDX-License-Identifier: AGPL-3.0-or-later
use crate::{ use crate::{
api::types::AdminUser, api::types::{AdminUser, PremiumBranding},
middleware::flash, middleware::flash,
session::{self, Session}, session::{self, Session},
state::AppState, state::AppState,
@@ -16,6 +16,7 @@ use axum::{
pub struct AuthContext { pub struct AuthContext {
pub session: Session, pub session: Session,
pub admin_user: Option<AdminUser>, pub admin_user: Option<AdminUser>,
pub branding: Option<PremiumBranding>,
} }
pub async fn require_auth( pub async fn require_auth(
@@ -28,27 +29,19 @@ pub async fn require_auth(
let Some(session) = session else { let Some(session) = session else {
return Redirect::to(&format!("{}/auth/start", config.base_path)).into_response(); return Redirect::to(&format!("{}/auth/start", config.base_path)).into_response();
}; };
let admin_result = fetch_admin_user(state.http_client(), config, &session).await; let admin_user = match fetch_admin_user(state.http_client(), config, &session).await {
match admin_result {
AdminFetchResult::Unauthorized => { AdminFetchResult::Unauthorized => {
let login_url = format!("{}/login", config.base_path); let login_url = format!("{}/login", config.base_path);
return clear_session_and_redirect(&login_url, config); return clear_session_and_redirect(&login_url, config);
} }
AdminFetchResult::Ok(admin_user) => { AdminFetchResult::Ok(admin_user) => Some(*admin_user),
let auth_context = AuthContext { AdminFetchResult::None => None,
session,
admin_user: Some(*admin_user),
}; };
request.extensions_mut().insert(auth_context); request.extensions_mut().insert(AuthContext {
}
AdminFetchResult::None => {
let auth_context = AuthContext {
session, session,
admin_user: None, admin_user,
}; branding: state.self_hosted_branding().await,
request.extensions_mut().insert(auth_context); });
}
}
let had_flash = flash::extract_flash(&request); let had_flash = flash::extract_flash(&request);
if let Some(ref fd) = had_flash { if let Some(ref fd) = had_flash {
+2 -1
View File
@@ -87,7 +87,8 @@ async fn login_page(
Some(_) => Some("Login error. Please try again."), Some(_) => Some("Login error. Please try again."),
None => None, 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<AppState>) -> Response { async fn auth_start(State(state): State<AppState>) -> Response {
+3
View File
@@ -480,11 +480,13 @@ mod tests {
let gold = PremiumBranding { let gold = PremiumBranding {
name: Some("Gold".to_owned()), name: Some("Gold".to_owned()),
premium_enabled: true, premium_enabled: true,
..PremiumBranding::default()
}; };
assert_eq!(premium_badge_name(Some(&gold)).as_deref(), Some("Gold")); assert_eq!(premium_badge_name(Some(&gold)).as_deref(), Some("Gold"));
let unnamed = PremiumBranding { let unnamed = PremiumBranding {
name: None, name: None,
premium_enabled: true, premium_enabled: true,
..PremiumBranding::default()
}; };
assert_eq!( assert_eq!(
premium_badge_name(Some(&unnamed)).as_deref(), premium_badge_name(Some(&unnamed)).as_deref(),
@@ -493,6 +495,7 @@ mod tests {
let everyone = PremiumBranding { let everyone = PremiumBranding {
name: Some("Gold".to_owned()), name: Some("Gold".to_owned()),
premium_enabled: false, premium_enabled: false,
..PremiumBranding::default()
}; };
assert_eq!(premium_badge_name(Some(&everyone)), None); assert_eq!(premium_badge_name(Some(&everyone)), None);
assert_eq!(premium_badge_name(None).as_deref(), Some("Premium")); assert_eq!(premium_badge_name(None).as_deref(), Some("Premium"));
+11
View File
@@ -88,6 +88,17 @@ impl AppState {
Some(branding) Some(branding)
} }
pub async fn self_hosted_branding(&self) -> Option<PremiumBranding> {
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 { pub async fn account_identity(&self, client: &AdminApiClient) -> AccountIdentityMode {
self.account_identity_settings(client).await.mode self.account_identity_settings(client).await.mode
} }
+78 -5
View File
@@ -1,6 +1,10 @@
// SPDX-License-Identifier: AGPL-3.0-or-later // 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 maud::{DOCTYPE, Markup, PreEscaped, html};
use super::components::drawer::drawer_controller_script; use super::components::drawer::drawer_controller_script;
@@ -11,6 +15,28 @@ use super::layout_scripts::{
}; };
use super::layout_sidebar::render_sidebar; 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 { fn cache_busted_asset(base_path: &str, asset_version: &str, path: &str) -> String {
format!("{base_path}{path}?t={asset_version}") format!("{base_path}{path}?t={asset_version}")
} }
@@ -72,10 +98,7 @@ pub fn admin_layout_ext(
title { (title) " ~ Fluxer Admin" } title { (title) " ~ Fluxer Admin" }
link rel="stylesheet" href={(base) "/static/fonts/" (crate::fonts::STYLESHEET_FILE_NAME)}; 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="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"}; (favicon_links(config, auth.branding.as_ref()))
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"};
script src=(cache_busted_asset(base, asset_version, "/static/htmx.min.js")) defer {} 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" { 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");
}
}
+12 -4
View File
@@ -1,9 +1,17 @@
// SPDX-License-Identifier: AGPL-3.0-or-later // 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}; 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; let base = &config.base_path;
html! { html! {
(DOCTYPE) (DOCTYPE)
@@ -14,7 +22,7 @@ pub fn login_page(config: &AdminConfig, error_message: Option<&str>) -> Markup {
title { "Login ~ Fluxer Admin" } title { "Login ~ Fluxer Admin" }
link rel="stylesheet" href={(base) "/static/fonts/" (crate::fonts::STYLESHEET_FILE_NAME)}; link rel="stylesheet" href={(base) "/static/fonts/" (crate::fonts::STYLESHEET_FILE_NAME)};
link rel="stylesheet" href={(base) "/static/app.css"}; 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" { body class="flex min-h-[100dvh] items-center justify-center bg-neutral-50 p-4" {
main class="w-full max-w-sm" { 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 \ bg-neutral-900 text-white hover:bg-neutral-800 \
px-4 py-2 text-base w-full sm:w-fit \ px-4 py-2 text-base w-full sm:w-fit \
focus:ring-offset-white" { focus:ring-offset-white" {
span { "Sign in with Fluxer" } span { "Sign in with " (product_name(branding)) }
} }
} }
} }
@@ -11,7 +11,7 @@ use crate::{
}, },
page_container::{card, page_header}, page_container::{card, page_header},
}, },
layout::admin_layout, layout::{admin_layout, product_name},
}, },
}; };
use maud::{Markup, html}; use maud::{Markup, html};
@@ -37,9 +37,9 @@ pub fn system_dm_page(
"Send a system DM" "Send a system DM"
} }
p class="text-sm text-neutral-500" { p class="text-sm text-neutral-500" {
"Sent from the official Fluxer system account. Each recipient \ "Sent from the official " (product_name(auth.branding.as_ref()))
will receive the same content as a DM. Progress is observable \ " system account. Each recipient will receive the same content \
on the " as a DM. Progress is observable on the "
a href=(jobs_url) class="font-medium text-neutral-900 hover:underline" { a href=(jobs_url) class="font-medium text-neutral-900 hover:underline" {
"Jobs page" "Jobs page"
} }