mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-17 13:05:32 +09:00
fix(admin): use instance branding on self-hosted (#3401)
This commit is contained in:
1 parent
af7724dbf6
commit
50c28aa8fa
9 files changed
+206
-49
No files matched your search
@@ -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
|
||||
|
||||
@@ -119,6 +119,9 @@ pub struct InstancePremiumDiscoveryAppPublic {
|
||||
|
||||
#[derive(Clone, Debug, Default, Deserialize)]
|
||||
pub struct InstancePremiumDiscoveryBranding {
|
||||
pub product_name: Option<String>,
|
||||
pub icon_url: Option<String>,
|
||||
pub favicon_url: Option<String>,
|
||||
pub premium_product_name: Option<String>,
|
||||
}
|
||||
|
||||
@@ -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<String>,
|
||||
pub premium_enabled: bool,
|
||||
pub product_name: Option<String>,
|
||||
pub favicon_url: Option<String>,
|
||||
}
|
||||
|
||||
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<String> {
|
||||
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()
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<AdminUser>,
|
||||
pub branding: Option<PremiumBranding>,
|
||||
}
|
||||
|
||||
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 {
|
||||
|
||||
@@ -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<AppState>) -> Response {
|
||||
|
||||
@@ -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"));
|
||||
|
||||
@@ -88,6 +88,17 @@ impl AppState {
|
||||
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 {
|
||||
self.account_identity_settings(client).await.mode
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user