package branding import ( "bytes" "encoding/json" "fmt" "io/fs" "mime" "os" "path/filepath" "regexp" "strings" "lol.mleku.dev/chk" "lol.mleku.dev/log" ) // Manager handles loading and serving custom branding assets type Manager struct { dir string config Config // Cached assets for performance cachedAssets map[string][]byte cachedCSS []byte } // New creates a new branding Manager by loading configuration from the specified directory func New(dir string) (*Manager, error) { m := &Manager{ dir: dir, cachedAssets: make(map[string][]byte), } // Load branding.json configPath := filepath.Join(dir, "branding.json") data, err := os.ReadFile(configPath) if err != nil { if os.IsNotExist(err) { log.I.F("branding.json not found in %s, using defaults", dir) m.config = DefaultConfig() } else { return nil, fmt.Errorf("failed to read branding.json: %w", err) } } else { if err := json.Unmarshal(data, &m.config); err != nil { return nil, fmt.Errorf("failed to parse branding.json: %w", err) } } // Pre-load and cache CSS if err := m.loadCSS(); err != nil { log.W.F("failed to load custom CSS: %v", err) } return m, nil } // Dir returns the branding directory path func (m *Manager) Dir() string { return m.dir } // Config returns the loaded branding configuration func (m *Manager) Config() Config { return m.config } // GetAsset returns a custom asset by name with its MIME type // Returns the asset data, MIME type, and whether it was found func (m *Manager) GetAsset(name string) ([]byte, string, bool) { var assetPath string switch name { case "logo": assetPath = m.config.Assets.Logo case "favicon": assetPath = m.config.Assets.Favicon case "icon-192": assetPath = m.config.Assets.Icon192 case "icon-512": assetPath = m.config.Assets.Icon512 default: return nil, "", false } if assetPath == "" { return nil, "", false } // Check cache first if data, ok := m.cachedAssets[name]; ok { return data, m.getMimeType(assetPath), true } // Load from disk fullPath := filepath.Join(m.dir, assetPath) data, err := os.ReadFile(fullPath) if chk.D(err) { return nil, "", false } // Cache for next time m.cachedAssets[name] = data return data, m.getMimeType(assetPath), true } // GetAssetPath returns the full filesystem path for a custom asset func (m *Manager) GetAssetPath(name string) (string, bool) { var assetPath string switch name { case "logo": assetPath = m.config.Assets.Logo case "favicon": assetPath = m.config.Assets.Favicon case "icon-192": assetPath = m.config.Assets.Icon192 case "icon-512": assetPath = m.config.Assets.Icon512 default: return "", false } if assetPath == "" { return "", false } fullPath := filepath.Join(m.dir, assetPath) if _, err := os.Stat(fullPath); err != nil { return "", false } return fullPath, true } // loadCSS loads and caches the custom CSS files func (m *Manager) loadCSS() error { var combined bytes.Buffer // Load variables CSS first (if exists) if m.config.CSS.VariablesCSS != "" { varsPath := filepath.Join(m.dir, m.config.CSS.VariablesCSS) if data, err := os.ReadFile(varsPath); err == nil { combined.Write(data) combined.WriteString("\n") } } // Load custom CSS (if exists) if m.config.CSS.CustomCSS != "" { customPath := filepath.Join(m.dir, m.config.CSS.CustomCSS) if data, err := os.ReadFile(customPath); err == nil { combined.Write(data) } } if combined.Len() > 0 { m.cachedCSS = combined.Bytes() } return nil } // GetCustomCSS returns the combined custom CSS content func (m *Manager) GetCustomCSS() ([]byte, error) { if m.cachedCSS == nil { return nil, fs.ErrNotExist } return m.cachedCSS, nil } // HasCustomCSS returns true if custom CSS is available func (m *Manager) HasCustomCSS() bool { return len(m.cachedCSS) > 0 } // GetManifest generates a customized manifest.json func (m *Manager) GetManifest(originalManifest []byte) ([]byte, error) { var manifest map[string]any if err := json.Unmarshal(originalManifest, &manifest); err != nil { return nil, fmt.Errorf("failed to parse original manifest: %w", err) } // Apply customizations if m.config.App.Name != "" { manifest["name"] = m.config.App.Name } if m.config.App.ShortName != "" { manifest["short_name"] = m.config.App.ShortName } if m.config.App.Description != "" { manifest["description"] = m.config.App.Description } if m.config.Manifest.ThemeColor != "" { manifest["theme_color"] = m.config.Manifest.ThemeColor } if m.config.Manifest.BackgroundColor != "" { manifest["background_color"] = m.config.Manifest.BackgroundColor } // Update icon paths to use branding endpoints if icons, ok := manifest["icons"].([]any); ok { for i, icon := range icons { if iconMap, ok := icon.(map[string]any); ok { if src, ok := iconMap["src"].(string); ok { // Replace icon paths with branding paths if strings.Contains(src, "192") { iconMap["src"] = "/branding/icon-192.png" } else if strings.Contains(src, "512") { iconMap["src"] = "/branding/icon-512.png" } icons[i] = iconMap } } } manifest["icons"] = icons } return json.MarshalIndent(manifest, "", " ") } // ModifyIndexHTML modifies the index.html to inject custom branding func (m *Manager) ModifyIndexHTML(original []byte) ([]byte, error) { html := string(original) // Inject custom CSS link before if m.HasCustomCSS() { cssLink := `` html = strings.Replace(html, "", cssLink+"\n", 1) } // Inject JavaScript to change header text at runtime if m.config.App.Name != "" { // This script runs after DOM is loaded and updates the header text brandingScript := fmt.Sprintf(``, m.config.App.Name+" dashboard") html = strings.Replace(html, "", brandingScript+"\n", 1) } // Replace title if custom title is set if m.config.App.Title != "" { titleRegex := regexp.MustCompile(`