botserver/src/core/product.rs

495 lines
16 KiB
Rust

//! Product Configuration Module
//!
//! This module handles white-label settings loaded from the `.product` file.
//! It provides a global configuration that can be used throughout the application
//! to customize branding, enabled apps, and default theme.
use once_cell::sync::Lazy;
use std::collections::HashSet;
use std::fs;
use std::path::Path;
use std::sync::RwLock;
use tracing::{info, warn};
/// Global product configuration instance
pub static PRODUCT_CONFIG: Lazy<RwLock<ProductConfig>> = Lazy::new(|| {
RwLock::new(ProductConfig::load().unwrap_or_default())
});
/// Product configuration structure
#[derive(Debug, Clone)]
pub struct ProductConfig {
/// Product name (replaces "General Bots" throughout the application)
pub name: String,
/// Set of active apps
pub apps: HashSet<String>,
/// Default theme
pub theme: String,
/// Logo URL (optional)
pub logo: Option<String>,
/// Favicon URL (optional)
pub favicon: Option<String>,
/// Primary color override (optional)
pub primary_color: Option<String>,
/// Support email (optional)
pub support_email: Option<String>,
/// Documentation URL (optional)
pub docs_url: Option<String>,
/// Copyright text (optional)
pub copyright: Option<String>,
}
impl Default for ProductConfig {
fn default() -> Self {
let mut apps = HashSet::new();
// All apps enabled by default
for app in &[
"chat", "mail", "calendar", "drive", "tasks", "docs", "paper",
"sheet", "slides", "meet", "research", "sources", "analytics",
"admin", "monitoring", "settings",
] {
apps.insert(app.to_string());
}
Self {
name: "General Bots".to_string(),
apps,
theme: "sentient".to_string(),
logo: None,
favicon: None,
primary_color: None,
support_email: None,
docs_url: Some("https://docs.pragmatismo.com.br".to_string()),
copyright: None,
}
}
}
impl ProductConfig {
/// Load configuration from .product file
pub fn load() -> Result<Self, ProductConfigError> {
let paths = [
".product",
"./botserver/.product",
"../.product",
];
let mut content = None;
for path in &paths {
if Path::new(path).exists() {
content = Some(fs::read_to_string(path).map_err(ProductConfigError::IoError)?);
info!("Loaded product configuration from: {}", path);
break;
}
}
let content = match content {
Some(c) => c,
None => {
warn!("No .product file found, using default configuration");
return Ok(Self::default());
}
};
Self::parse(&content)
}
/// Parse configuration from string content
pub fn parse(content: &str) -> Result<Self, ProductConfigError> {
let mut config = Self::default();
let mut apps_specified = false;
for line in content.lines() {
let line = line.trim();
// Skip empty lines and comments
if line.is_empty() || line.starts_with('#') {
continue;
}
// Parse key=value pairs
if let Some((key, value)) = line.split_once('=') {
let key = key.trim().to_lowercase();
let value = value.trim();
match key.as_str() {
"name" => {
if !value.is_empty() {
config.name = value.to_string();
}
}
"apps" => {
apps_specified = true;
config.apps.clear();
for app in value.split(',') {
let app = app.trim().to_lowercase();
if !app.is_empty() {
config.apps.insert(app);
}
}
}
"theme" => {
if !value.is_empty() {
config.theme = value.to_string();
}
}
"logo" => {
if !value.is_empty() {
config.logo = Some(value.to_string());
}
}
"favicon" => {
if !value.is_empty() {
config.favicon = Some(value.to_string());
}
}
"primary_color" => {
if !value.is_empty() {
config.primary_color = Some(value.to_string());
}
}
"support_email" => {
if !value.is_empty() {
config.support_email = Some(value.to_string());
}
}
"docs_url" => {
if !value.is_empty() {
config.docs_url = Some(value.to_string());
}
}
"copyright" => {
if !value.is_empty() {
config.copyright = Some(value.to_string());
}
}
_ => {
warn!("Unknown product configuration key: {}", key);
}
}
}
}
if !apps_specified {
info!("No apps specified in .product, all apps enabled by default");
}
info!(
"Product config loaded: name='{}', apps={:?}, theme='{}'",
config.name, config.apps, config.theme
);
Ok(config)
}
/// Check if an app is enabled
pub fn is_app_enabled(&self, app: &str) -> bool {
self.apps.contains(&app.to_lowercase())
}
/// Get the product name
pub fn get_name(&self) -> &str {
&self.name
}
/// Get the default theme
pub fn get_theme(&self) -> &str {
&self.theme
}
/// Replace "General Bots" with the product name in a string
pub fn replace_branding(&self, text: &str) -> String {
text.replace("General Bots", &self.name)
.replace("general bots", &self.name.to_lowercase())
.replace("GENERAL BOTS", &self.name.to_uppercase())
}
/// Get copyright text with year substitution
pub fn get_copyright(&self) -> String {
let year = chrono::Utc::now().format("%Y").to_string();
let template = self.copyright.as_deref()
.unwrap_or("© {year} {name}. All rights reserved.");
template
.replace("{year}", &year)
.replace("{name}", &self.name)
}
/// Get all enabled apps as a vector
pub fn get_enabled_apps(&self) -> Vec<String> {
self.apps.iter().cloned().collect()
}
/// Reload configuration from file
pub fn reload() -> Result<(), ProductConfigError> {
let new_config = Self::load()?;
let mut config = PRODUCT_CONFIG.write()
.map_err(|_| ProductConfigError::LockError)?;
*config = new_config;
info!("Product configuration reloaded");
Ok(())
}
}
/// Error type for product configuration
#[derive(Debug)]
pub enum ProductConfigError {
IoError(std::io::Error),
ParseError(String),
LockError,
}
impl std::fmt::Display for ProductConfigError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::IoError(e) => write!(f, "IO error reading .product file: {}", e),
Self::ParseError(msg) => write!(f, "Parse error in .product file: {}", msg),
Self::LockError => write!(f, "Failed to acquire lock on product configuration"),
}
}
}
impl std::error::Error for ProductConfigError {}
/// Helper function to get product name
pub fn get_product_name() -> String {
PRODUCT_CONFIG
.read()
.map(|c| c.name.clone())
.unwrap_or_else(|_| "General Bots".to_string())
}
/// Helper function to check if an app is enabled
pub fn is_app_enabled(app: &str) -> bool {
PRODUCT_CONFIG
.read()
.map(|c| c.is_app_enabled(app))
.unwrap_or(true)
}
/// Helper function to get default theme
pub fn get_default_theme() -> String {
PRODUCT_CONFIG
.read()
.map(|c| c.theme.clone())
.unwrap_or_else(|_| "sentient".to_string())
}
/// Helper function to replace branding in text
pub fn replace_branding(text: &str) -> String {
PRODUCT_CONFIG
.read()
.map(|c| c.replace_branding(text))
.unwrap_or_else(|_| text.to_string())
}
/// Helper function to get product config for serialization
pub fn get_product_config_json() -> serde_json::Value {
// Get compiled features from our new module
let compiled = crate::core::features::COMPILED_FEATURES;
// Get current config
let config = PRODUCT_CONFIG.read().ok();
// Determine effective apps (intersection of enabled + compiled)
let effective_apps: Vec<String> = config
.as_ref()
.map(|c| c.get_enabled_apps())
.unwrap_or_default()
.into_iter()
.filter(|app| compiled.contains(&app.as_str()) || app == "settings" || app == "auth") // Always allow settings/auth
.collect();
match config {
Some(c) => serde_json::json!({
"name": c.name,
"apps": effective_apps,
"compiled_features": compiled,
"version": env!("CARGO_PKG_VERSION"),
"theme": c.theme,
"logo": c.logo,
"favicon": c.favicon,
"primary_color": c.primary_color,
"docs_url": c.docs_url,
"copyright": c.get_copyright(),
}),
None => serde_json::json!({
"name": "General Bots",
"apps": compiled, // If no config, show all compiled
"compiled_features": compiled,
"version": env!("CARGO_PKG_VERSION"),
"theme": "sentient",
})
}
}
/// Get workspace manifest with detailed feature information
pub fn get_workspace_manifest() -> serde_json::Value {
let manifest = crate::core::manifest::WorkspaceManifest::new();
serde_json::to_value(manifest).unwrap_or_else(|_| serde_json::json!({}))
}
/// Middleware to check if an app is enabled before allowing API access
pub async fn app_gate_middleware(
req: axum::http::Request<axum::body::Body>,
next: axum::middleware::Next,
) -> axum::response::Response {
use axum::http::StatusCode;
use axum::response::IntoResponse;
let path = req.uri().path();
// Map API paths to app names
let app_name = match path {
p if p.starts_with("/api/calendar") => Some("calendar"),
p if p.starts_with("/api/mail") || p.starts_with("/api/email") => Some("mail"),
p if p.starts_with("/api/drive") || p.starts_with("/api/files") => Some("drive"),
p if p.starts_with("/api/tasks") => Some("tasks"),
p if p.starts_with("/api/docs") => Some("docs"),
p if p.starts_with("/api/paper") => Some("paper"),
p if p.starts_with("/api/sheet") => Some("sheet"),
p if p.starts_with("/api/slides") => Some("slides"),
p if p.starts_with("/api/meet") => Some("meet"),
p if p.starts_with("/api/research") => Some("research"),
p if p.starts_with("/api/sources") => Some("sources"),
p if p.starts_with("/api/analytics") => Some("analytics"),
p if p.starts_with("/api/admin") => Some("admin"),
p if p.starts_with("/api/monitoring") => Some("monitoring"),
p if p.starts_with("/api/settings") => Some("settings"),
p if p.starts_with("/api/ui/calendar") => Some("calendar"),
p if p.starts_with("/api/ui/mail") => Some("mail"),
p if p.starts_with("/api/ui/drive") => Some("drive"),
p if p.starts_with("/api/ui/tasks") => Some("tasks"),
p if p.starts_with("/api/ui/docs") => Some("docs"),
p if p.starts_with("/api/ui/paper") => Some("paper"),
p if p.starts_with("/api/ui/sheet") => Some("sheet"),
p if p.starts_with("/api/ui/slides") => Some("slides"),
p if p.starts_with("/api/ui/meet") => Some("meet"),
p if p.starts_with("/api/ui/research") => Some("research"),
p if p.starts_with("/api/ui/sources") => Some("sources"),
p if p.starts_with("/api/ui/analytics") => Some("analytics"),
p if p.starts_with("/api/ui/admin") => Some("admin"),
p if p.starts_with("/api/ui/monitoring") => Some("monitoring"),
p if p.starts_with("/api/ui/settings") => Some("settings"),
_ => None, // Allow all other paths
};
// Check if the app is enabled
if let Some(app) = app_name {
// First check: is it even compiled?
// Note: settings, auth, admin are core features usually, but we check anyway if they are in features list
// Some core apps like settings might not be in feature flags explicitly or always enabled.
// For simplicity, if it's not in compiled features but is a known core route, we might allow it,
// but here we enforce strict feature containment.
// Exception: 'settings' and 'auth' are often core.
if app != "settings" && app != "auth" && !crate::core::features::is_feature_compiled(app) {
let error_response = serde_json::json!({
"error": "not_implemented",
"message": format!("The '{}' feature is not compiled in this build", app),
"code": 501
});
return (
StatusCode::NOT_IMPLEMENTED,
axum::Json(error_response)
).into_response();
}
if !is_app_enabled(app) {
let error_response = serde_json::json!({
"error": "app_disabled",
"message": format!("The '{}' app is not enabled for this installation", app),
"code": 403
});
return (
StatusCode::FORBIDDEN,
axum::Json(error_response)
).into_response();
}
}
next.run(req).await
}
/// Get list of disabled apps for logging/debugging
pub fn get_disabled_apps() -> Vec<String> {
let all_apps = vec![
"chat", "mail", "calendar", "drive", "tasks", "docs", "paper",
"sheet", "slides", "meet", "research", "sources", "analytics",
"admin", "monitoring", "settings",
];
all_apps
.into_iter()
.filter(|app| !is_app_enabled(app))
.map(|s| s.to_string())
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_default_config() {
let config = ProductConfig::default();
assert_eq!(config.name, "General Bots");
assert_eq!(config.theme, "sentient");
assert!(config.is_app_enabled("chat"));
assert!(config.is_app_enabled("drive"));
}
#[test]
fn test_parse_config() {
let content = r#"
# Test config
name=My Custom Bot
apps=chat,drive,tasks
theme=dark
"#;
let config = ProductConfig::parse(content).unwrap();
assert_eq!(config.name, "My Custom Bot");
assert_eq!(config.theme, "dark");
assert!(config.is_app_enabled("chat"));
assert!(config.is_app_enabled("drive"));
assert!(config.is_app_enabled("tasks"));
assert!(!config.is_app_enabled("mail"));
assert!(!config.is_app_enabled("calendar"));
}
#[test]
fn test_replace_branding() {
let config = ProductConfig {
name: "Acme Bot".to_string(),
..Default::default()
};
assert_eq!(
config.replace_branding("Welcome to General Bots"),
"Welcome to Acme Bot"
);
}
#[test]
fn test_case_insensitive_apps() {
let content = "apps=Chat,DRIVE,Tasks";
let config = ProductConfig::parse(content).unwrap();
assert!(config.is_app_enabled("chat"));
assert!(config.is_app_enabled("CHAT"));
assert!(config.is_app_enabled("Chat"));
assert!(config.is_app_enabled("drive"));
assert!(config.is_app_enabled("tasks"));
}
}