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