1package cef
2
3import (
4 "bytes"
5 "context"
6 "embed"
7 "encoding/base64"
8 "encoding/json"
9 "fmt"
10 "html"
11 "io"
12 "io/fs"
13 "net/http"
14 "net/url"
15 "os"
16 "path"
17 "strconv"
18 "strings"
19 "sync"
20 "unsafe"
21
22 "github.com/andybalholm/brotli"
23 purecef "github.com/bnema/purego-cef/cef"
24
25 "github.com/bnema/dumber/internal/application/port"
26 domainurl "github.com/bnema/dumber/internal/domain/url"
27 "github.com/bnema/dumber/internal/infrastructure/webutil"
28 "github.com/bnema/dumber/internal/logging"
29 "github.com/rs/zerolog"
30)
31
32// Scheme path constants matching WebKit's naming.
33const (
34 historyPath = "history"
35 favoritesPath = "favorites"
36 configPath = "config"
37 errorPath = "error"
38 indexHTML = "index.html"
39 maxSchemeTruncatedURLLength = 240
40 maxSystemviewsWASMBytes = 64 * 1024 * 1024
41 systemviewsAssetDir = "systemviews"
42)
43
44// pageRootFiles maps internal page hosts/paths to their HTML entry points.
45var pageRootFiles = map[string]string{
46 historyPath: indexHTML,
47 favoritesPath: indexHTML,
48 configPath: indexHTML,
49 errorPath: indexHTML,
50}
51
52var cefNewResourceHandler = purecef.NewResourceHandler
53
54// dumbSchemeHandler serves both the conceptual dumb:// URLs and the actual
55// internal https://dumber.invalid origin used by CEF.
56type dumbSchemeHandler struct {
57 ctx context.Context
58 messageRouter *MessageRouter
59 faviconService port.FaviconService
60 assets embed.FS
61 assetsSet bool
62 assetDir string
63 logger zerolog.Logger
64 currentConfigPayload func() ([]byte, error)
65 defaultConfigPayload func() ([]byte, error)
66 mu sync.RWMutex
67
68 // onClipboardSet is called when JS sends copied text via /api/clipboard-set.
69 // Set by the engine to write to the GDK system clipboard.
70 onClipboardSet func(text string)
71
72 // onEditableFocus is called when JS reports that an editable element gained
73 // DOM focus. The engine uses this to reassert CEF browser focus in OSR mode.
74 onEditableFocus func(browser purecef.Browser)
75
76 // onPopupOpen/onPopupNavigate/onPopupClose bridge synthetic window.open()
77 // proxies from page JavaScript back into the browser process when native
78 // related popups are not available (e.g. CEF OSR regular-webview fallback path).
79 onPopupOpen func(browser purecef.Browser, payload rendererBridgePopupOpenPayload)
80 onPopupNavigate func(browser purecef.Browser, payload rendererBridgePopupNavigatePayload)
81 onPopupClose func(browser purecef.Browser, payload rendererBridgePopupClosePayload)
82 onPopupOpenerNavigate func(browser purecef.Browser, payload popupOpenerNavigatePayload)
83 onPopupOpenerPostMessage func(browser purecef.Browser, payload popupOpenerPostMessagePayload)
84
85 // bridgeNonceValidator checks whether a bridge nonce belongs to the active
86 // browser/navigation context that issued the request.
87 bridgeNonceValidator func(browser purecef.Browser, bridgeNonce string) bool
88}
89
90// newDumbSchemeHandler creates a handler for internal CEF pages.
91func newDumbSchemeHandler(
92 ctx context.Context,
93 router *MessageRouter,
94 currentConfigPayload func() ([]byte, error),
95 defaultConfigPayload func() ([]byte, error),
96) (*dumbSchemeHandler, error) {
97 if currentConfigPayload == nil {
98 return nil, fmt.Errorf("current config payload builder not configured")
99 }
100 if defaultConfigPayload == nil {
101 return nil, fmt.Errorf("default config payload builder not configured")
102 }
103
104 log := logging.FromContext(ctx)
105 return &dumbSchemeHandler{
106 ctx: ctx,
107 messageRouter: router,
108 assetDir: systemviewsAssetDir,
109 logger: log.With().Str("component", "scheme-handler").Logger(),
110 currentConfigPayload: currentConfigPayload,
111 defaultConfigPayload: defaultConfigPayload,
112 }, nil
113}
114
115// setAssets sets the embedded filesystem containing systemviews assets.
116func (h *dumbSchemeHandler) setAssets(assets embed.FS) {
117 h.mu.Lock()
118 defer h.mu.Unlock()
119 h.assets = assets
120 h.assetsSet = true
121 h.logger.Debug().Msg("assets filesystem configured")
122}
123
124func (h *dumbSchemeHandler) setFaviconService(service port.FaviconService) {
125 h.mu.Lock()
126 defer h.mu.Unlock()
127 h.faviconService = service
128}
129
130// Create implements purecef.SchemeHandlerFactory. Called on the CEF IO thread
131// for requests to either dumb:// or the internal https origin.
132func (h *dumbSchemeHandler) Create(browser purecef.Browser, _ purecef.Frame, _ string, request purecef.Request) purecef.ResourceHandler {
133 reqURL := request.GetURL()
134 method := request.GetMethod()
135
136 h.logger.Debug().
137 Str("url", reqURL).
138 Str("method", method).
139 Msg("scheme handler create")
140
141 u, err := url.Parse(reqURL)
142 if err != nil {
143 h.logger.Error().Err(err).Str("url", reqURL).Msg("invalid URL")
144 return h.newErrorResourceHandler(http.StatusBadRequest, "Invalid URL")
145 }
146
147 if isCEFCrashPageURL(u) {
148 originalURI := sanitizeCEFCrashPageOriginalURI(strings.TrimSpace(u.Query().Get("url")))
149 return h.newRawResourceHandler(http.StatusOK, "text/html; charset=utf-8", []byte(buildCEFCrashPageHTML(originalURI)))
150 }
151
152 // Route API requests before redirecting conceptual dumb:// URLs to the
153 // internal HTTPS origin. External pages use dumb://api/... for clipboard,
154 // focus, and popup bridges specifically to bypass page CSP; redirecting
155 // those requests to https://dumber.invalid makes site connect-src policies
156 // block them.
157 if apiPath, ok := resolveAPIPath(u); ok {
158 return h.handleAPI(browser, method, apiPath, request)
159 }
160
161 // Keep dumb:// as the app-level abstraction, but redirect CEF page/asset
162 // loads to a normal HTTPS origin so Chromium applies the standard web
163 // security model.
164 if isConceptualInternalURL(reqURL) {
165 targetURL := toActualInternalURL(reqURL)
166 if targetURL != "" && targetURL != reqURL {
167 return h.newRedirectResourceHandler(http.StatusTemporaryRedirect, targetURL)
168 }
169 }
170
171 // Serve static assets.
172 return h.handleAsset(u)
173}
174
175// handleAPI routes API requests to the message router or built-in handlers.
176func (h *dumbSchemeHandler) handleAPI(
177 browser purecef.Browser,
178 method string,
179 requestPath string,
180 request purecef.Request,
181) purecef.ResourceHandler {
182 if strings.EqualFold(method, http.MethodOptions) {
183 if requestPath == "/api/config" || requestPath == "/api/config/default" {
184 if denied := h.rejectUntrustedConfigRequester(request); denied != nil {
185 return denied
186 }
187 return h.newPrivateAPIRawResourceHandler(http.StatusNoContent, "text/plain; charset=utf-8", nil)
188 }
189 return h.newAPIRawResourceHandler(http.StatusNoContent, "text/plain; charset=utf-8", nil)
190 }
191 if isAPIGetMethod(method) {
192 if handler, ok := h.handleAPIGet(requestPath, request); ok {
193 return handler
194 }
195 }
196 if isAPIPostMethod(method) {
197 if handler, ok := h.handleAPIPost(browser, requestPath, request); ok {
198 return handler
199 }
200 }
201 return h.newAPIJSONResourceHandler(http.StatusNotFound, map[string]string{"error": "not found"})
202}
203
204func isAPIGetMethod(method string) bool {
205 return method == "" || strings.EqualFold(method, http.MethodGet)
206}
207
208func isAPIPostMethod(method string) bool {
209 return strings.EqualFold(method, http.MethodPost)
210}
211
212func (h *dumbSchemeHandler) handleAPIGet(requestPath string, request purecef.Request) (purecef.ResourceHandler, bool) {
213 switch requestPath {
214 case "/api/config":
215 if denied := h.rejectUntrustedConfigRequester(request); denied != nil {
216 return denied, true
217 }
218 return h.handleConfigAPI(h.currentConfigPayload), true
219 case "/api/config/default":
220 if denied := h.rejectUntrustedConfigRequester(request); denied != nil {
221 return denied, true
222 }
223 return h.handleConfigAPI(h.defaultConfigPayload), true
224 case "/api/favicon":
225 return h.handleFaviconAPI(request), true
226 default:
227 return nil, false
228 }
229}
230
231func (h *dumbSchemeHandler) handleAPIPost(
232 browser purecef.Browser,
233 requestPath string,
234 request purecef.Request,
235) (purecef.ResourceHandler, bool) {
236 switch requestPath {
237 case "/api/message":
238 return h.handleMessageAPI(request), true
239 case "/api/clipboard-set":
240 return h.handleClipboardSet(request, browser), true
241 case "/api/focus-sync":
242 return h.handleFocusSync(request, browser), true
243 case "/api/popup-open":
244 return h.handlePopupOpen(request, browser), true
245 case "/api/popup-navigate":
246 return h.handlePopupNavigate(request, browser), true
247 case "/api/popup-close":
248 return h.handlePopupClose(request, browser), true
249 case "/api/popup-opener-navigate":
250 return h.handlePopupOpenerNavigate(request, browser), true
251 case "/api/popup-opener-post-message":
252 return h.handlePopupOpenerPostMessage(request, browser), true
253 default:
254 return nil, false
255 }
256}
257
258func (h *dumbSchemeHandler) handleMessageAPI(request purecef.Request) purecef.ResourceHandler {
259 if denied := h.rejectUntrustedSystemviewRequester(request); denied != nil {
260 return denied
261 }
262 body := readBodyFromHeader(request)
263 if body == nil {
264 return h.newAPIJSONResourceHandler(http.StatusBadRequest, map[string]string{"error": "empty body"})
265 }
266 resp, err := h.messageRouter.HandleMessage(h.ctx, 0, body)
267 if err != nil {
268 return h.newAPIJSONResourceHandler(http.StatusInternalServerError, map[string]string{"error": err.Error()})
269 }
270 return h.newAPIRawResourceHandler(http.StatusOK, "application/json", resp)
271}
272
273func (h *dumbSchemeHandler) rejectForbiddenAPIOrigin(request purecef.Request) purecef.ResourceHandler {
274 origin := strings.TrimSpace(request.GetHeaderByName("Origin"))
275 if origin == "" || strings.EqualFold(origin, actualInternalOrigin) {
276 return nil
277 }
278 return h.newAPIJSONResourceHandler(http.StatusForbidden, map[string]string{"error": "forbidden origin"})
279}
280
281func (h *dumbSchemeHandler) rejectUntrustedConfigRequester(request purecef.Request) purecef.ResourceHandler {
282 return h.rejectUntrustedSystemviewRequester(request)
283}
284
285func (h *dumbSchemeHandler) rejectUntrustedSystemviewRequester(request purecef.Request) purecef.ResourceHandler {
286 if request == nil {
287 return h.newPrivateAPIJSONResourceHandler(http.StatusForbidden, map[string]string{"error": "forbidden"})
288 }
289 origin := strings.TrimSpace(request.GetHeaderByName("Origin"))
290 if origin != "" {
291 if isTrustedSystemviewURL(origin) {
292 return nil
293 }
294 return h.newPrivateAPIJSONResourceHandler(http.StatusForbidden, map[string]string{"error": "forbidden"})
295 }
296 referrer := strings.TrimSpace(request.GetReferrerURL())
297 if referrer == "" {
298 referrer = strings.TrimSpace(request.GetHeaderByName("Referer"))
299 }
300 if !isTrustedSystemviewURL(referrer) {
301 return h.newPrivateAPIJSONResourceHandler(http.StatusForbidden, map[string]string{"error": "forbidden"})
302 }
303 return nil
304}
305
306const systemviewFaviconSize = 32
307
308func (h *dumbSchemeHandler) handleFaviconAPI(request purecef.Request) purecef.ResourceHandler {
309 if denied := h.rejectUntrustedFaviconRequester(request); denied != nil {
310 return denied
311 }
312
313 h.mu.RLock()
314 service := h.faviconService
315 h.mu.RUnlock()
316 if service == nil {
317 return h.newPrivateAPIJSONResourceHandler(http.StatusNotFound, map[string]string{"error": "favicon unavailable"})
318 }
319
320 parsed, err := url.Parse(request.GetURL())
321 if err != nil {
322 return h.newPrivateAPIJSONResourceHandler(http.StatusBadRequest, map[string]string{"error": "invalid request URL"})
323 }
324 domain := domainurl.CanonicalDomain(parsed.Query().Get("domain"))
325 if domain == "" {
326 return h.newPrivateAPIJSONResourceHandler(http.StatusBadRequest, map[string]string{"error": "missing domain"})
327 }
328 size := systemviewFaviconSize
329 if rawSize := strings.TrimSpace(parsed.Query().Get("size")); rawSize != "" {
330 parsedSize, parseErr := strconv.Atoi(rawSize)
331 if parseErr != nil || parsedSize != systemviewFaviconSize {
332 return h.newPrivateAPIJSONResourceHandler(http.StatusBadRequest, map[string]string{"error": "unsupported favicon size"})
333 }
334 size = parsedSize
335 }
336
337 return newFaviconResourceHandler(service, domain, size)
338}
339
340func (h *dumbSchemeHandler) rejectUntrustedFaviconRequester(request purecef.Request) purecef.ResourceHandler {
341 return h.rejectUntrustedSystemviewRequester(request)
342}
343
344func isTrustedSystemviewURL(raw string) bool {
345 parsed, err := url.Parse(strings.TrimSpace(raw))
346 if err != nil || parsed.Scheme == "" {
347 return false
348 }
349 if strings.EqualFold(parsed.Scheme, actualInternalScheme) {
350 return strings.EqualFold(parsed.Host, actualInternalHost)
351 }
352 if !strings.EqualFold(parsed.Scheme, "dumb") {
353 return false
354 }
355 host := parsed.Host
356 if host == "" {
357 host = parsed.Opaque
358 }
359 if idx := strings.IndexAny(host, "/?#"); idx >= 0 {
360 host = host[:idx]
361 }
362 return isInternalPageHost(host)
363}
364
365func resolveConfigPayload(build func() ([]byte, error)) ([]byte, error) {
366 if build == nil {
367 return nil, fmt.Errorf("config payload builder not configured")
368 }
369 return build()
370}
371
372// handleConfigAPI returns a JSON response with the given config payload builder.
373func (h *dumbSchemeHandler) handleConfigAPI(build func() ([]byte, error)) purecef.ResourceHandler {
374 data, err := resolveConfigPayload(build)
375 if err != nil {
376 return h.newPrivateAPIJSONResourceHandler(http.StatusInternalServerError, map[string]string{"error": err.Error()})
377 }
378 return h.newPrivateAPIRawResourceHandler(http.StatusOK, "application/json", data)
379}
380
381// handleClipboardSet receives copied text from JS copy/cut events and writes
382// it to the system clipboard via the engine callback.
383const (
384 maxClipboardBytes = 10 << 20 // 10 MB
385 dumberBodyHeaderName = "X-Dumber-Body"
386 dumberBridgeActionHeaderName = "X-Dumber-Bridge-Action"
387 dumberBridgeNonceHeaderName = "X-Dumber-Bridge-Nonce"
388 dumberBridgeActionFocusSync = "focus-sync"
389)
390
391func (h *dumbSchemeHandler) handleClipboardSet(request purecef.Request, browser purecef.Browser) purecef.ResourceHandler {
392 h.logger.Debug().Msg("cef: /api/clipboard-set request received")
393
394 if !h.hasTrustedBridgeNonce(request, browser) {
395 h.logger.Warn().Msg("cef: clipboard-set — rejected request without valid bridge nonce")
396 return h.newAPIJSONResourceHandler(http.StatusForbidden, map[string]string{"error": "forbidden"})
397 }
398
399 body := readBodyFromHeader(request)
400 if body == nil {
401 h.logger.Debug().Msg("cef: clipboard-set — empty body (no X-Dumber-Body header)")
402 return h.newAPIJSONResourceHandler(http.StatusBadRequest, map[string]string{"error": "empty body"})
403 }
404 if len(body) > maxClipboardBytes {
405 h.logger.Warn().Int("body_len", len(body)).Msg("cef: clipboard-set — payload too large")
406 return h.newAPIJSONResourceHandler(http.StatusRequestEntityTooLarge, map[string]string{"error": "payload too large"})
407 }
408
409 var payload struct {
410 Text string `json:"text"`
411 }
412 if err := json.Unmarshal(body, &payload); err != nil || payload.Text == "" {
413 h.logger.Debug().Int("body_len", len(body)).Msg("cef: clipboard-set — invalid or empty payload")
414 return h.newAPIJSONResourceHandler(http.StatusBadRequest, map[string]string{"error": "invalid payload"})
415 }
416
417 h.logger.Debug().Int("text_len", len(payload.Text)).Msg("cef: clipboard-set — forwarding to GDK clipboard")
418
419 if h.onClipboardSet != nil {
420 h.onClipboardSet(payload.Text)
421 } else {
422 h.logger.Warn().Msg("cef: clipboard-set — onClipboardSet callback not wired")
423 }
424
425 return h.newAPIJSONResourceHandler(http.StatusOK, map[string]any{"ok": true})
426}
427
428func (h *dumbSchemeHandler) handleFocusSync(request purecef.Request, browser purecef.Browser) purecef.ResourceHandler {
429 bridgeAction := ""
430 if request != nil {
431 bridgeAction = strings.TrimSpace(request.GetHeaderByName(dumberBridgeActionHeaderName))
432 }
433 if !strings.EqualFold(bridgeAction, dumberBridgeActionFocusSync) {
434 h.logger.Warn().Msg("cef: focus-sync — rejected request without trusted bridge action header")
435 return h.newAPIJSONResourceHandler(http.StatusForbidden, map[string]string{"error": "forbidden"})
436 }
437 if browser == nil {
438 h.logger.Debug().Msg("cef: focus-sync — browser unavailable")
439 return h.newAPIJSONResourceHandler(http.StatusBadRequest, map[string]string{"error": "browser unavailable"})
440 }
441 if !h.hasTrustedBridgeNonce(request, browser) {
442 h.logger.Warn().Msg("cef: focus-sync — rejected request without valid bridge nonce")
443 return h.newAPIJSONResourceHandler(http.StatusForbidden, map[string]string{"error": "forbidden"})
444 }
445
446 if h.onEditableFocus != nil {
447 h.onEditableFocus(browser)
448 } else {
449 h.logger.Debug().Msg("cef: focus-sync — onEditableFocus callback not wired")
450 }
451
452 return h.newAPIJSONResourceHandler(http.StatusOK, map[string]any{"ok": true})
453}
454
455func handlePopupBridgeRequest[T any](
456 h *dumbSchemeHandler,
457 request purecef.Request,
458 browser purecef.Browser,
459 action string,
460 decode func([]byte) (T, error),
461 dispatch func(purecef.Browser, T),
462) purecef.ResourceHandler {
463 if browser == nil {
464 h.logger.Debug().Msg("cef: " + action + " — browser unavailable")
465 return h.newAPIJSONResourceHandler(http.StatusBadRequest, map[string]string{"error": "browser unavailable"})
466 }
467 if !h.hasTrustedBridgeNonce(request, browser) {
468 h.logger.Warn().Msg("cef: " + action + " — rejected request without valid bridge nonce")
469 return h.newAPIJSONResourceHandler(http.StatusForbidden, map[string]string{"error": "forbidden"})
470 }
471 body := readBodyFromHeader(request)
472 if body == nil {
473 return h.newAPIJSONResourceHandler(http.StatusBadRequest, map[string]string{"error": "empty body"})
474 }
475 payload, err := decode(body)
476 if err != nil {
477 return h.newAPIJSONResourceHandler(http.StatusBadRequest, map[string]string{"error": err.Error()})
478 }
479 if dispatch != nil {
480 dispatch(browser, payload)
481 } else {
482 h.logger.Warn().Msg("cef: " + action + " — callback not wired")
483 }
484 return h.newAPIJSONResourceHandler(http.StatusOK, map[string]any{"ok": true})
485}
486
487func (h *dumbSchemeHandler) handlePopupOpen(request purecef.Request, browser purecef.Browser) purecef.ResourceHandler {
488 return handlePopupBridgeRequest(h, request, browser, "popup-open", decodeRendererBridgePopupOpenPayload, h.onPopupOpen)
489}
490
491func (h *dumbSchemeHandler) handlePopupNavigate(request purecef.Request, browser purecef.Browser) purecef.ResourceHandler {
492 return handlePopupBridgeRequest(h, request, browser, "popup-navigate", decodeRendererBridgePopupNavigatePayload, h.onPopupNavigate)
493}
494
495func (h *dumbSchemeHandler) handlePopupClose(request purecef.Request, browser purecef.Browser) purecef.ResourceHandler {
496 return handlePopupBridgeRequest(h, request, browser, "popup-close", decodeRendererBridgePopupClosePayload, h.onPopupClose)
497}
498
499func decodePopupOpenerNavigatePayload(body []byte) (popupOpenerNavigatePayload, error) {
500 var payload popupOpenerNavigatePayload
501 if err := json.Unmarshal(body, &payload); err != nil {
502 return payload, err
503 }
504 payload.URL = strings.TrimSpace(payload.URL)
505 if payload.URL == "" {
506 return payload, fmt.Errorf("missing url")
507 }
508 return payload, nil
509}
510
511func decodePopupOpenerPostMessagePayload(body []byte) (popupOpenerPostMessagePayload, error) {
512 var payload popupOpenerPostMessagePayload
513 if err := json.Unmarshal(body, &payload); err != nil {
514 return payload, err
515 }
516 payload.DataKind = strings.TrimSpace(payload.DataKind)
517 payload.TargetOrigin = strings.TrimSpace(payload.TargetOrigin)
518 payload.SourceOrigin = strings.TrimSpace(payload.SourceOrigin)
519 payload.SourceHref = strings.TrimSpace(payload.SourceHref)
520 if payload.TargetOrigin == "" {
521 return payload, fmt.Errorf("missing target origin")
522 }
523 return payload, nil
524}
525
526func (h *dumbSchemeHandler) handlePopupOpenerNavigate(request purecef.Request, browser purecef.Browser) purecef.ResourceHandler {
527 return handlePopupBridgeRequest(h, request, browser, "popup-opener-navigate", decodePopupOpenerNavigatePayload, h.onPopupOpenerNavigate)
528}
529
530func (h *dumbSchemeHandler) handlePopupOpenerPostMessage(request purecef.Request, browser purecef.Browser) purecef.ResourceHandler {
531 return handlePopupBridgeRequest(
532 h,
533 request,
534 browser,
535 "popup-opener-post-message",
536 decodePopupOpenerPostMessagePayload,
537 h.onPopupOpenerPostMessage,
538 )
539}
540
541func (h *dumbSchemeHandler) hasTrustedBridgeNonce(request purecef.Request, browser purecef.Browser) bool {
542 if browser == nil || h == nil || h.bridgeNonceValidator == nil || request == nil {
543 return false
544 }
545 bridgeNonce := strings.TrimSpace(request.GetHeaderByName(dumberBridgeNonceHeaderName))
546 if bridgeNonce == "" {
547 return false
548 }
549 return h.bridgeNonceValidator(browser, bridgeNonce)
550}
551
552func (h *dumbSchemeHandler) newRedirectResourceHandler(status int, location string) purecef.ResourceHandler {
553 return newStaticResourceHandler(status, "text/plain; charset=utf-8", nil, map[string]string{
554 "Location": location,
555 "Cache-Control": "no-store",
556 })
557}
558
559// handleAsset serves static files from the embedded filesystem.
560func (h *dumbSchemeHandler) handleAsset(u *url.URL) purecef.ResourceHandler {
561 h.mu.RLock()
562 hasAssets := h.assetsSet
563 h.mu.RUnlock()
564
565 if !hasAssets {
566 return h.newErrorResourceHandler(http.StatusInternalServerError, "Assets not configured")
567 }
568
569 assetDir, relPath, ok := resolveAssetPath(u)
570 if !ok {
571 return h.newErrorResourceHandler(http.StatusNotFound, "Page not found")
572 }
573 if assetDir == "" {
574 assetDir = h.assetDir
575 }
576
577 fullPath, relPath, ok := safeSystemviewsAssetPath(assetDir, relPath)
578 if !ok {
579 return h.newErrorResourceHandler(http.StatusNotFound, "Asset not found")
580 }
581
582 data, err := readAssetWithEncoding(h.assets, fullPath, relPath)
583 if err != nil {
584 h.logger.Debug().Str("path", fullPath).Err(err).Msg("asset not found")
585 return h.newErrorResourceHandler(http.StatusNotFound, "Asset not found")
586 }
587
588 contentType := getMimeType(relPath)
589 h.logger.Debug().
590 Str("path", fullPath).
591 Str("content_type", contentType).
592 Int("size", len(data)).
593 Msg("serving asset")
594
595 return newStaticResourceHandler(http.StatusOK, contentType, data, nil)
596}
597
598func safeSystemviewsAssetPath(assetDir, relPath string) (fullPath, cleanRelPath string, ok bool) {
599 assetDir = strings.Trim(assetDir, "/")
600 if assetDir != systemviewsAssetDir {
601 return "", "", false
602 }
603
604 relPath = strings.TrimLeft(relPath, "/")
605 if relPath == "" || strings.ContainsRune(relPath, '\x00') {
606 return "", "", false
607 }
608
609 cleanRelPath = path.Clean(relPath)
610 if cleanRelPath == "." || cleanRelPath == ".." || strings.HasPrefix(cleanRelPath, "../") || path.IsAbs(cleanRelPath) {
611 return "", "", false
612 }
613
614 fullPath = path.Join(assetDir, cleanRelPath)
615 if fullPath != assetDir && !strings.HasPrefix(fullPath, assetDir+"/") {
616 return "", "", false
617 }
618 return fullPath, cleanRelPath, true
619}
620
621func readAssetWithEncoding(assets embed.FS, fullPath, relPath string) ([]byte, error) {
622 if strings.HasSuffix(relPath, ".wasm") {
623 if compressed, err := fs.ReadFile(assets, fullPath+".br"); err == nil {
624 data, err := io.ReadAll(io.LimitReader(brotli.NewReader(bytes.NewReader(compressed)), maxSystemviewsWASMBytes+1))
625 if err != nil {
626 return nil, err
627 }
628 if len(data) > maxSystemviewsWASMBytes {
629 return nil, fmt.Errorf("decompressed asset %s exceeds %d bytes", fullPath, maxSystemviewsWASMBytes)
630 }
631 return data, nil
632 }
633 }
634 data, err := fs.ReadFile(assets, fullPath)
635 return data, err
636}
637
638// resolveAssetPath maps either a dumb:// URL or the actual internal HTTPS URL
639// to a relative asset path inside assets/systemviews.
640func resolveAssetPath(u *url.URL) (assetDir, relPath string, ok bool) {
641 if u == nil {
642 return "", "", false
643 }
644
645 if strings.EqualFold(u.Scheme, actualInternalScheme) && strings.EqualFold(u.Host, actualInternalHost) {
646 return resolveActualAssetPath(u)
647 }
648
649 return resolveConceptualAssetPath(u)
650}
651
652func resolveConceptualAssetPath(u *url.URL) (assetDir, relPath string, ok bool) {
653 if u == nil {
654 return "", "", false
655 }
656
657 if root, ok := pageRootFiles[u.Host]; ok {
658 assetPath := strings.TrimPrefix(u.Path, "/")
659 if assetPath == "" {
660 return assetDirForPageHost(u.Host), root, true
661 }
662 // Don't serve API paths as assets.
663 if strings.HasPrefix(assetPath, "api/") {
664 return "", "", false
665 }
666 return assetDirForPageHost(u.Host), assetPath, true
667 }
668
669 // Handle opaque URLs like dumb:history.
670 if root, ok := pageRootFiles[u.Opaque]; ok {
671 return assetDirForPageHost(u.Opaque), root, true
672 }
673 return "", "", false
674}
675
676func resolveActualAssetPath(u *url.URL) (assetDir, relPath string, ok bool) {
677 if u == nil {
678 return "", "", false
679 }
680
681 assetPath := strings.Trim(u.Path, "/")
682 if assetPath == "" {
683 return "", "", false
684 }
685
686 parts := strings.SplitN(assetPath, "/", 2)
687 page := parts[0]
688 if root, ok := pageRootFiles[page]; ok {
689 if len(parts) == 1 {
690 return assetDirForPageHost(page), root, true
691 }
692 if page == historyPath && parts[1] == "crash" {
693 return "", "", false
694 }
695 return assetDirForPageHost(page), parts[1], true
696 }
697
698 if strings.HasPrefix(assetPath, "api/") {
699 return "", "", false
700 }
701
702 if !strings.Contains(assetPath, "/") && strings.Contains(assetPath, ".") {
703 return systemviewsAssetDir, assetPath, true
704 }
705
706 return "", "", false
707}
708
709func assetDirForPageHost(host string) string {
710 switch host {
711 case historyPath, favoritesPath, configPath, errorPath:
712 return systemviewsAssetDir
713 default:
714 return ""
715 }
716}
717
718func isCEFCrashPageURL(u *url.URL) bool {
719 if u == nil {
720 return false
721 }
722
723 switch {
724 case strings.EqualFold(u.Scheme, actualInternalScheme) && strings.EqualFold(u.Host, actualInternalHost):
725 return strings.Trim(u.Path, "/") == historyPath+"/crash"
726 case strings.EqualFold(u.Scheme, "dumb") && u.Host == historyPath:
727 return strings.Trim(u.Path, "/") == "crash"
728 default:
729 return false
730 }
731}
732
733func sanitizeCEFCrashPageOriginalURI(originalURI string) string {
734 return webutil.SanitizeCrashPageOriginalURI(originalURI)
735}
736
737func buildCEFCrashPageHTML(originalURI string) string {
738 return webutil.BuildCrashPageHTML(originalURI)
739}
740
741func getMimeType(filename string) string {
742 return webutil.GetMimeType(filename)
743}
744
745// splitMimeCharset splits "text/html; charset=utf-8" into ("text/html", "utf-8").
746// If no charset parameter is present, charset is empty.
747func splitMimeCharset(contentType string) (mimeType, charset string) {
748 parts := strings.SplitN(contentType, ";", 2)
749 mimeType = strings.TrimSpace(parts[0])
750 if len(parts) > 1 {
751 param := strings.TrimSpace(parts[1])
752 if strings.HasPrefix(strings.ToLower(param), "charset=") {
753 charset = strings.TrimSpace(param[len("charset="):])
754 }
755 }
756 return
757}
758
759// ---------------------------------------------------------------------------
760// ResourceHandler implementation
761// ---------------------------------------------------------------------------
762
763// staticResourceHandler serves a fixed byte buffer as a CEF ResourceHandler.
764type staticResourceHandler struct {
765 data []byte
766 contentType string
767 statusCode int
768 headers map[string]string
769 offset int
770}
771
772func apiResponseHeaders(extra map[string]string) map[string]string {
773 headers := map[string]string{
774 "Access-Control-Allow-Origin": "*",
775 "Access-Control-Allow-Methods": "GET, POST, OPTIONS",
776 "Access-Control-Allow-Headers": strings.Join([]string{
777 "Content-Type",
778 "X-Dumber-Body",
779 "X-Dumber-Bridge-Action",
780 "X-Dumber-Bridge-Nonce",
781 }, ", "),
782 "Access-Control-Max-Age": "86400",
783 "Cache-Control": "no-store",
784 }
785 for name, value := range extra {
786 headers[name] = value
787 }
788 return headers
789}
790
791func newStaticResourceHandler(status int, contentType string, data []byte, headers map[string]string) purecef.ResourceHandler {
792 return cefNewResourceHandler(&staticResourceHandler{
793 data: data,
794 contentType: contentType,
795 statusCode: status,
796 headers: headers,
797 })
798}
799
800func (h *dumbSchemeHandler) newRawResourceHandler(status int, contentType string, data []byte) purecef.ResourceHandler {
801 return newStaticResourceHandler(status, contentType, data, nil)
802}
803
804func (h *dumbSchemeHandler) newAPIRawResourceHandler(status int, contentType string, data []byte) purecef.ResourceHandler {
805 return newStaticResourceHandler(status, contentType, data, apiResponseHeaders(nil))
806}
807
808func (h *dumbSchemeHandler) newPrivateAPIRawResourceHandler(status int, contentType string, data []byte) purecef.ResourceHandler {
809 return newStaticResourceHandler(status, contentType, data, map[string]string{"Cache-Control": "no-store"})
810}
811
812func (h *dumbSchemeHandler) newErrorResourceHandler(status int, msg string) purecef.ResourceHandler {
813 escaped := html.EscapeString(msg)
814 body := fmt.Sprintf(`<!DOCTYPE html><html><body><h1>%d</h1><p>%s</p></body></html>`, status, escaped)
815 return h.newRawResourceHandler(status, "text/html; charset=utf-8", []byte(body))
816}
817
818func (h *dumbSchemeHandler) newAPIJSONResourceHandler(status int, v any) purecef.ResourceHandler {
819 data, err := json.Marshal(v)
820 if err != nil {
821 fallback, _ := json.Marshal(map[string]string{"error": "JSON encoding failed"})
822 return h.newAPIRawResourceHandler(http.StatusInternalServerError, "application/json", fallback)
823 }
824 return h.newAPIRawResourceHandler(status, "application/json", data)
825}
826
827func (h *dumbSchemeHandler) newPrivateAPIJSONResourceHandler(status int, v any) purecef.ResourceHandler {
828 data, err := json.Marshal(v)
829 if err != nil {
830 fallback, _ := json.Marshal(map[string]string{"error": "JSON encoding failed"})
831 return h.newPrivateAPIRawResourceHandler(http.StatusInternalServerError, "application/json", fallback)
832 }
833 return h.newPrivateAPIRawResourceHandler(status, "application/json", data)
834}
835
836func newFaviconResourceHandler(service port.FaviconService, domain string, size int) purecef.ResourceHandler {
837 return cefNewResourceHandler(&faviconResourceHandler{
838 service: service,
839 domain: domain,
840 size: size,
841 done: make(chan struct{}),
842 headers: map[string]string{"Cache-Control": "no-store"},
843 })
844}
845
846const maxFaviconMemoryCacheEntries = 256
847
848var systemviewFaviconCache = newFaviconMemoryCache(maxFaviconMemoryCacheEntries)
849
850type faviconCacheEntry struct {
851 statusCode int
852 contentType string
853 data []byte
854}
855
856type faviconMemoryCache struct {
857 mu sync.Mutex
858 max int
859 entries map[string]faviconCacheEntry
860 order []string
861}
862
863func newFaviconMemoryCache(maxEntries int) *faviconMemoryCache {
864 return &faviconMemoryCache{
865 max: maxEntries,
866 entries: make(map[string]faviconCacheEntry),
867 order: make([]string, 0, maxEntries),
868 }
869}
870
871func faviconCacheKey(domain string, size int) string {
872 return strings.ToLower(strings.TrimSpace(domain)) + "\x00" + strconv.Itoa(size)
873}
874
875func (c *faviconMemoryCache) get(key string) (faviconCacheEntry, bool) {
876 if c == nil || key == "" {
877 return faviconCacheEntry{}, false
878 }
879 c.mu.Lock()
880 defer c.mu.Unlock()
881 entry, ok := c.entries[key]
882 if !ok {
883 return faviconCacheEntry{}, false
884 }
885 c.moveToBackLocked(key)
886 return entry, true
887}
888
889func (c *faviconMemoryCache) put(key string, entry faviconCacheEntry) {
890 if c == nil || key == "" || c.max <= 0 {
891 return
892 }
893 c.mu.Lock()
894 defer c.mu.Unlock()
895 if _, ok := c.entries[key]; !ok {
896 c.order = append(c.order, key)
897 } else {
898 c.moveToBackLocked(key)
899 }
900 c.entries[key] = entry
901 for len(c.order) > c.max {
902 oldest := c.order[0]
903 c.order = c.order[1:]
904 delete(c.entries, oldest)
905 }
906}
907
908func (c *faviconMemoryCache) moveToBackLocked(key string) {
909 for i, candidate := range c.order {
910 if candidate != key {
911 continue
912 }
913 copy(c.order[i:], c.order[i+1:])
914 c.order[len(c.order)-1] = key
915 return
916 }
917}
918
919type faviconResourceHandler struct {
920 service port.FaviconService
921 domain string
922 size int
923 headers map[string]string
924 once sync.Once
925 done chan struct{}
926 data []byte
927 contentType string
928 statusCode int
929 offset int
930}
931
932func (rh *faviconResourceHandler) load() {
933 defer close(rh.done)
934 rh.statusCode = http.StatusNotFound
935 rh.contentType = "application/json"
936 rh.data = []byte(`{"error":"favicon not cached"}`)
937 if rh.service == nil {
938 return
939 }
940 cacheKey := faviconCacheKey(rh.domain, rh.size)
941 if entry, ok := systemviewFaviconCache.get(cacheKey); ok {
942 rh.statusCode = entry.statusCode
943 rh.contentType = entry.contentType
944 rh.data = entry.data
945 return
946 }
947 if !rh.service.HasPNGSizedOnDisk(rh.domain, rh.size) {
948 return
949 }
950 diskPath := rh.service.DiskPathPNGSized(rh.domain, rh.size)
951 if diskPath == "" {
952 return
953 }
954 data, err := os.ReadFile(diskPath)
955 if err != nil || len(data) == 0 {
956 return
957 }
958 rh.statusCode = http.StatusOK
959 rh.contentType = "image/png"
960 rh.data = data
961 systemviewFaviconCache.put(cacheKey, faviconCacheEntry{
962 statusCode: rh.statusCode,
963 contentType: rh.contentType,
964 data: data,
965 })
966}
967
968func (rh *faviconResourceHandler) Open(_ purecef.Request, handleRequest *int32, callback purecef.Callback) int32 {
969 if handleRequest != nil {
970 *handleRequest = 0
971 }
972 rh.once.Do(func() {
973 go func() {
974 rh.load()
975 if callback != nil {
976 callback.Cont()
977 }
978 }()
979 })
980 return 1
981}
982
983func (rh *faviconResourceHandler) ProcessRequest(_ purecef.Request, callback purecef.Callback) int32 {
984 rh.once.Do(func() {
985 go func() {
986 rh.load()
987 if callback != nil {
988 callback.Cont()
989 }
990 }()
991 })
992 return 1
993}
994
995func (rh *faviconResourceHandler) GetResponseHeaders(response purecef.Response, responseLength *int64, _ uintptr) {
996 <-rh.done
997 response.SetStatus(int32(rh.statusCode))
998 if text := http.StatusText(rh.statusCode); text != "" {
999 response.SetStatusText(text)
1000 }
1001 mimeType, charset := splitMimeCharset(rh.contentType)
1002 response.SetMimeType(mimeType)
1003 if charset != "" {
1004 response.SetCharset(charset)
1005 }
1006 for name, value := range rh.headers {
1007 response.SetHeaderByName(name, value, 1)
1008 }
1009 if responseLength != nil {
1010 *responseLength = int64(len(rh.data))
1011 }
1012}
1013
1014func (rh *faviconResourceHandler) Skip(_ int64, _ *int64, _ purecef.ResourceSkipCallback) int32 {
1015 return 0
1016}
1017
1018func (rh *faviconResourceHandler) Read(dataOut unsafe.Pointer, bytesToRead int32, bytesRead *int32, _ purecef.ResourceReadCallback) int32 {
1019 <-rh.done
1020 if rh.offset >= len(rh.data) {
1021 return 0
1022 }
1023 remaining := len(rh.data) - rh.offset
1024 toRead := int(bytesToRead)
1025 if toRead > remaining {
1026 toRead = remaining
1027 }
1028 dst := unsafe.Slice((*byte)(dataOut), toRead)
1029 copy(dst, rh.data[rh.offset:rh.offset+toRead])
1030 rh.offset += toRead
1031 if bytesRead != nil {
1032 *bytesRead = int32(toRead)
1033 }
1034 return 1
1035}
1036
1037func (rh *faviconResourceHandler) ReadResponse(_ unsafe.Pointer, _ int32, _ *int32, _ purecef.Callback) int32 {
1038 return 0
1039}
1040
1041func (rh *faviconResourceHandler) Cancel() {}
1042
1043// Open handles the request immediately (synchronous).
1044func (rh *staticResourceHandler) Open(_ purecef.Request, handleRequest *int32, _ purecef.Callback) int32 {
1045 // Set handleRequest = true (1) to indicate we handle it immediately.
1046 if handleRequest != nil {
1047 *handleRequest = 1
1048 }
1049 return 1
1050}
1051
1052// ProcessRequest is deprecated; Open is used instead.
1053func (rh *staticResourceHandler) ProcessRequest(_ purecef.Request, _ purecef.Callback) int32 {
1054 return 0
1055}
1056
1057// GetResponseHeaders sets status code, MIME type, and content length.
1058// CEF's SetMimeType expects the MIME type without charset parameters;
1059// the charset must be set separately via SetCharset.
1060func (rh *staticResourceHandler) GetResponseHeaders(response purecef.Response, responseLength *int64, _ uintptr) {
1061 response.SetStatus(int32(rh.statusCode))
1062 if text := http.StatusText(rh.statusCode); text != "" {
1063 response.SetStatusText(text)
1064 }
1065 mimeType, charset := splitMimeCharset(rh.contentType)
1066 response.SetMimeType(mimeType)
1067 if charset != "" {
1068 response.SetCharset(charset)
1069 }
1070 for name, value := range rh.headers {
1071 response.SetHeaderByName(name, value, 1)
1072 }
1073 if responseLength != nil {
1074 *responseLength = int64(len(rh.data))
1075 }
1076}
1077
1078// Skip is not used for static content.
1079func (rh *staticResourceHandler) Skip(_ int64, _ *int64, _ purecef.ResourceSkipCallback) int32 {
1080 return 0
1081}
1082
1083// Read copies data into the output buffer.
1084func (rh *staticResourceHandler) Read(
1085 dataOut unsafe.Pointer, bytesToRead int32,
1086 bytesRead *int32, _ purecef.ResourceReadCallback,
1087) int32 {
1088 if rh.offset >= len(rh.data) {
1089 return 0
1090 }
1091
1092 remaining := len(rh.data) - rh.offset
1093 toRead := int(bytesToRead)
1094 if toRead > remaining {
1095 toRead = remaining
1096 }
1097
1098 // Copy data to the output buffer.
1099 dst := unsafe.Slice((*byte)(dataOut), toRead)
1100 copy(dst, rh.data[rh.offset:rh.offset+toRead])
1101 rh.offset += toRead
1102
1103 if bytesRead != nil {
1104 *bytesRead = int32(toRead)
1105 }
1106 return 1
1107}
1108
1109// ReadResponse is deprecated; Read is used instead.
1110func (rh *staticResourceHandler) ReadResponse(_ unsafe.Pointer, _ int32, _ *int32, _ purecef.Callback) int32 {
1111 return 0
1112}
1113
1114// Cancel is a no-op for static content.
1115func (rh *staticResourceHandler) Cancel() {}
1116
1117// ---------------------------------------------------------------------------
1118// Request body reader
1119// ---------------------------------------------------------------------------
1120
1121// readBodyFromHeader extracts the message body from the X-Dumber-Body header.
1122// The JS bridge base64-encodes the JSON body into this header to avoid the
1123// purego-cef PostData element wrapping limitation (wrapPostDataElement is unexported).
1124func readBodyFromHeader(request purecef.Request) []byte {
1125 encoded := request.GetHeaderByName(dumberBodyHeaderName)
1126 if encoded == "" {
1127 return nil
1128 }
1129 body, err := base64.StdEncoding.DecodeString(encoded)
1130 if err != nil {
1131 return nil
1132 }
1133 return body
1134}