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