branding.go raw

   1  package branding
   2  
   3  import (
   4  	"bytes"
   5  	"encoding/json"
   6  	"fmt"
   7  	"io/fs"
   8  	"mime"
   9  	"os"
  10  	"path/filepath"
  11  	"regexp"
  12  	"strings"
  13  
  14  	"next.orly.dev/pkg/lol/chk"
  15  	"next.orly.dev/pkg/lol/log"
  16  )
  17  
  18  // Manager handles loading and serving custom branding assets
  19  type Manager struct {
  20  	dir    string
  21  	config Config
  22  
  23  	// Cached assets for performance
  24  	cachedAssets map[string][]byte
  25  	cachedCSS    []byte
  26  }
  27  
  28  // New creates a new branding Manager by loading configuration from the specified directory
  29  func New(dir string) (*Manager, error) {
  30  	m := &Manager{
  31  		dir:          dir,
  32  		cachedAssets: make(map[string][]byte),
  33  	}
  34  
  35  	// Load branding.json
  36  	configPath := filepath.Join(dir, "branding.json")
  37  	data, err := os.ReadFile(configPath)
  38  	if err != nil {
  39  		if os.IsNotExist(err) {
  40  			log.I.F("branding.json not found in %s, using defaults", dir)
  41  			m.config = DefaultConfig()
  42  		} else {
  43  			return nil, fmt.Errorf("failed to read branding.json: %w", err)
  44  		}
  45  	} else {
  46  		if err := json.Unmarshal(data, &m.config); err != nil {
  47  			return nil, fmt.Errorf("failed to parse branding.json: %w", err)
  48  		}
  49  	}
  50  
  51  	// Pre-load and cache CSS
  52  	if err := m.loadCSS(); err != nil {
  53  		log.W.F("failed to load custom CSS: %v", err)
  54  	}
  55  
  56  	return m, nil
  57  }
  58  
  59  // Dir returns the branding directory path
  60  func (m *Manager) Dir() string {
  61  	return m.dir
  62  }
  63  
  64  // Config returns the loaded branding configuration
  65  func (m *Manager) Config() Config {
  66  	return m.config
  67  }
  68  
  69  // GetAsset returns a custom asset by name with its MIME type
  70  // Returns the asset data, MIME type, and whether it was found
  71  func (m *Manager) GetAsset(name string) ([]byte, string, bool) {
  72  	var assetPath string
  73  
  74  	switch name {
  75  	case "logo":
  76  		assetPath = m.config.Assets.Logo
  77  	case "favicon":
  78  		assetPath = m.config.Assets.Favicon
  79  	case "icon-192":
  80  		assetPath = m.config.Assets.Icon192
  81  	case "icon-512":
  82  		assetPath = m.config.Assets.Icon512
  83  	default:
  84  		return nil, "", false
  85  	}
  86  
  87  	if assetPath == "" {
  88  		return nil, "", false
  89  	}
  90  
  91  	// Check cache first
  92  	if data, ok := m.cachedAssets[name]; ok {
  93  		return data, m.getMimeType(assetPath), true
  94  	}
  95  
  96  	// Load from disk
  97  	fullPath := filepath.Join(m.dir, assetPath)
  98  	data, err := os.ReadFile(fullPath)
  99  	if chk.D(err) {
 100  		return nil, "", false
 101  	}
 102  
 103  	// Cache for next time
 104  	m.cachedAssets[name] = data
 105  	return data, m.getMimeType(assetPath), true
 106  }
 107  
 108  // GetAssetPath returns the full filesystem path for a custom asset
 109  func (m *Manager) GetAssetPath(name string) (string, bool) {
 110  	var assetPath string
 111  
 112  	switch name {
 113  	case "logo":
 114  		assetPath = m.config.Assets.Logo
 115  	case "favicon":
 116  		assetPath = m.config.Assets.Favicon
 117  	case "icon-192":
 118  		assetPath = m.config.Assets.Icon192
 119  	case "icon-512":
 120  		assetPath = m.config.Assets.Icon512
 121  	default:
 122  		return "", false
 123  	}
 124  
 125  	if assetPath == "" {
 126  		return "", false
 127  	}
 128  
 129  	fullPath := filepath.Join(m.dir, assetPath)
 130  	if _, err := os.Stat(fullPath); err != nil {
 131  		return "", false
 132  	}
 133  
 134  	return fullPath, true
 135  }
 136  
 137  // loadCSS loads and caches the custom CSS files
 138  func (m *Manager) loadCSS() error {
 139  	var combined bytes.Buffer
 140  
 141  	// Load variables CSS first (if exists)
 142  	if m.config.CSS.VariablesCSS != "" {
 143  		varsPath := filepath.Join(m.dir, m.config.CSS.VariablesCSS)
 144  		if data, err := os.ReadFile(varsPath); err == nil {
 145  			combined.Write(data)
 146  			combined.WriteString("\n")
 147  		}
 148  	}
 149  
 150  	// Load custom CSS (if exists)
 151  	if m.config.CSS.CustomCSS != "" {
 152  		customPath := filepath.Join(m.dir, m.config.CSS.CustomCSS)
 153  		if data, err := os.ReadFile(customPath); err == nil {
 154  			combined.Write(data)
 155  		}
 156  	}
 157  
 158  	if combined.Len() > 0 {
 159  		m.cachedCSS = combined.Bytes()
 160  	}
 161  
 162  	return nil
 163  }
 164  
 165  // GetCustomCSS returns the combined custom CSS content
 166  func (m *Manager) GetCustomCSS() ([]byte, error) {
 167  	if m.cachedCSS == nil {
 168  		return nil, fs.ErrNotExist
 169  	}
 170  	return m.cachedCSS, nil
 171  }
 172  
 173  // HasCustomCSS returns true if custom CSS is available
 174  func (m *Manager) HasCustomCSS() bool {
 175  	return len(m.cachedCSS) > 0
 176  }
 177  
 178  // GetManifest generates a customized manifest.json
 179  func (m *Manager) GetManifest(originalManifest []byte) ([]byte, error) {
 180  	var manifest map[string]any
 181  
 182  	if err := json.Unmarshal(originalManifest, &manifest); err != nil {
 183  		return nil, fmt.Errorf("failed to parse original manifest: %w", err)
 184  	}
 185  
 186  	// Apply customizations
 187  	if m.config.App.Name != "" {
 188  		manifest["name"] = m.config.App.Name
 189  	}
 190  	if m.config.App.ShortName != "" {
 191  		manifest["short_name"] = m.config.App.ShortName
 192  	}
 193  	if m.config.App.Description != "" {
 194  		manifest["description"] = m.config.App.Description
 195  	}
 196  	if m.config.Manifest.ThemeColor != "" {
 197  		manifest["theme_color"] = m.config.Manifest.ThemeColor
 198  	}
 199  	if m.config.Manifest.BackgroundColor != "" {
 200  		manifest["background_color"] = m.config.Manifest.BackgroundColor
 201  	}
 202  
 203  	// Update icon paths to use branding endpoints
 204  	if icons, ok := manifest["icons"].([]any); ok {
 205  		for i, icon := range icons {
 206  			if iconMap, ok := icon.(map[string]any); ok {
 207  				if src, ok := iconMap["src"].(string); ok {
 208  					// Replace icon paths with branding paths
 209  					if strings.Contains(src, "192") {
 210  						iconMap["src"] = "/branding/icon-192.png"
 211  					} else if strings.Contains(src, "512") {
 212  						iconMap["src"] = "/branding/icon-512.png"
 213  					}
 214  					icons[i] = iconMap
 215  				}
 216  			}
 217  		}
 218  		manifest["icons"] = icons
 219  	}
 220  
 221  	return json.MarshalIndent(manifest, "", "  ")
 222  }
 223  
 224  // ModifyIndexHTML modifies the index.html to inject custom branding
 225  func (m *Manager) ModifyIndexHTML(original []byte) ([]byte, error) {
 226  	html := string(original)
 227  
 228  	// Inject custom CSS link before </head>
 229  	if m.HasCustomCSS() {
 230  		cssLink := `<link rel="stylesheet" href="/branding/custom.css">`
 231  		html = strings.Replace(html, "</head>", cssLink+"\n</head>", 1)
 232  	}
 233  
 234  	// Inject JavaScript to change header text at runtime
 235  	if m.config.App.Name != "" {
 236  		// This script runs after DOM is loaded and updates the header text
 237  		brandingScript := fmt.Sprintf(`<script>
 238  (function() {
 239      var appName = %q;
 240      function updateBranding() {
 241          var titles = document.querySelectorAll('.app-title');
 242          titles.forEach(function(el) {
 243              var badge = el.querySelector('.permission-badge');
 244              el.childNodes.forEach(function(node) {
 245                  if (node.nodeType === 3 && node.textContent.trim()) {
 246                      node.textContent = appName + ' ';
 247                  }
 248              });
 249              if (!el.textContent.includes(appName)) {
 250                  if (badge) {
 251                      el.innerHTML = appName + ' ' + badge.outerHTML;
 252                  } else {
 253                      el.textContent = appName;
 254                  }
 255              }
 256          });
 257      }
 258      if (document.readyState === 'loading') {
 259          document.addEventListener('DOMContentLoaded', updateBranding);
 260      } else {
 261          updateBranding();
 262      }
 263      // Also run periodically to catch Svelte updates
 264      setInterval(updateBranding, 500);
 265      setTimeout(function() { clearInterval(this); }, 10000);
 266  })();
 267  </script>`, m.config.App.Name+" dashboard")
 268  		html = strings.Replace(html, "</head>", brandingScript+"\n</head>", 1)
 269  	}
 270  
 271  	// Replace title if custom title is set
 272  	if m.config.App.Title != "" {
 273  		titleRegex := regexp.MustCompile(`<title>[^<]*</title>`)
 274  		html = titleRegex.ReplaceAllString(html, fmt.Sprintf("<title>%s</title>", m.config.App.Title))
 275  	}
 276  
 277  	// Replace logo path to use branding endpoint
 278  	if m.config.Assets.Logo != "" {
 279  		// Replace orly.png references with branding logo endpoint
 280  		html = strings.ReplaceAll(html, `"/orly.png"`, `"/branding/logo.png"`)
 281  		html = strings.ReplaceAll(html, `'/orly.png'`, `'/branding/logo.png'`)
 282  		html = strings.ReplaceAll(html, `src="/orly.png"`, `src="/branding/logo.png"`)
 283  	}
 284  
 285  	// Replace favicon path
 286  	if m.config.Assets.Favicon != "" {
 287  		html = strings.ReplaceAll(html, `href="/favicon.png"`, `href="/branding/favicon.png"`)
 288  		html = strings.ReplaceAll(html, `href="favicon.png"`, `href="/branding/favicon.png"`)
 289  	}
 290  
 291  	// Replace manifest path to use dynamic endpoint
 292  	html = strings.ReplaceAll(html, `href="/manifest.json"`, `href="/branding/manifest.json"`)
 293  	html = strings.ReplaceAll(html, `href="manifest.json"`, `href="/branding/manifest.json"`)
 294  
 295  	return []byte(html), nil
 296  }
 297  
 298  // NIP11Config returns the NIP-11 branding configuration
 299  func (m *Manager) NIP11Config() NIP11Config {
 300  	return m.config.NIP11
 301  }
 302  
 303  // AppName returns the custom app name, or empty string if not set
 304  func (m *Manager) AppName() string {
 305  	return m.config.App.Name
 306  }
 307  
 308  // getMimeType determines the MIME type from a file path
 309  func (m *Manager) getMimeType(path string) string {
 310  	ext := filepath.Ext(path)
 311  	mimeType := mime.TypeByExtension(ext)
 312  	if mimeType == "" {
 313  		// Default fallbacks
 314  		switch strings.ToLower(ext) {
 315  		case ".png":
 316  			return "image/png"
 317  		case ".jpg", ".jpeg":
 318  			return "image/jpeg"
 319  		case ".gif":
 320  			return "image/gif"
 321  		case ".svg":
 322  			return "image/svg+xml"
 323  		case ".ico":
 324  			return "image/x-icon"
 325  		case ".css":
 326  			return "text/css"
 327  		case ".js":
 328  			return "application/javascript"
 329  		default:
 330  			return "application/octet-stream"
 331  		}
 332  	}
 333  	return mimeType
 334  }
 335  
 336  // ClearCache clears all cached assets (useful for hot-reload during development)
 337  func (m *Manager) ClearCache() {
 338  	m.cachedAssets = make(map[string][]byte)
 339  	m.cachedCSS = nil
 340  	_ = m.loadCSS()
 341  }
 342