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