1package cef
2
3import (
4 "bytes"
5 "context"
6 "crypto/sha256"
7 "encoding/base64"
8 "encoding/hex"
9 "encoding/json"
10 "errors"
11 "fmt"
12 "html"
13 "io/fs"
14 "maps"
15 "net/http"
16 "net/url"
17 "strings"
18 "sync"
19 "unsafe"
20
21 purecef "github.com/bnema/purego-cef/cef"
22
23 "github.com/bnema/dumber/internal/application/port"
24 "github.com/bnema/dumber/internal/infrastructure/webutil"
25 "github.com/bnema/dumber/internal/logging"
26 "github.com/rs/zerolog"
27
28 "github.com/bnema/dumber/assets"
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 = webutil.MaxSystemviewsWASMBytes
40 systemviewsAssetDir = webutil.SystemviewsAssetDir
41)
42
43// systemviewTrust accepts requesters served from the internal HTTPS origin or
44// dumb:// pages. CEF system views have a real origin, so an opaque "null"
45// Origin is rejected rather than falling back to the referrer.
46var systemviewTrust = webutil.SystemviewTrust{IsTrustedURL: isTrustedSystemviewURL}
47
48var (
49 safeSystemviewsAssetPath = webutil.SafeSystemviewsAssetPath
50 readAssetWithEncoding = webutil.ReadSystemviewAsset
51)
52
53// pageRootFiles maps internal page hosts/paths to their HTML entry points.
54var pageRootFiles = map[string]string{
55 historyPath: indexHTML,
56 favoritesPath: indexHTML,
57 configPath: indexHTML,
58 errorPath: indexHTML,
59}
60
61var cefNewResourceHandler = purecef.NewResourceHandler
62
63// dumbSchemeHandler serves both the conceptual dumb:// URLs and the actual
64// internal https://dumber.invalid origin used by CEF.
65type dumbSchemeHandler struct {
66 ctx context.Context
67 messageRouter *MessageRouter
68 faviconResolver port.FaviconSystemviewResolver
69 // assets is the captured immutable asset filesystem. Together with
70 // bundle it forms one replaceable bundle state: installing a different
71 // FS replaces both, and in-flight requests retain their captured pair.
72 // A replaced bundle's cached WASM stays alive until its last in-flight
73 // request finishes, so old and new bundles briefly overlap in memory;
74 // the retained cache budget is one WASM per active bundle. Production
75 // embeds are immutable; no live file watching is added.
76 assets fs.FS
77 bundle *systemviewAssetBundle
78 assetDir string
79 logger zerolog.Logger
80 currentConfigPayload func() ([]byte, error)
81 defaultConfigPayload func() ([]byte, error)
82 mu sync.RWMutex
83
84 // onClipboardSet is called when JS sends copied text via /api/clipboard-set.
85 // Set by the engine to write to the GDK system clipboard.
86 onClipboardSet func(text string)
87
88 // onEditableFocus is called when JS reports that an editable element gained
89 // DOM focus. The engine uses this to reassert CEF browser focus in OSR mode.
90 onEditableFocus func(browser purecef.Browser)
91
92 // onPopupOpen/onPopupNavigate/onPopupClose bridge synthetic window.open()
93 // proxies from page JavaScript back into the browser process when native
94 // related popups are not available (e.g. CEF OSR regular-webview fallback path).
95 onPopupOpen func(browser purecef.Browser, payload rendererBridgePopupOpenPayload)
96 onPopupNavigate func(browser purecef.Browser, payload rendererBridgePopupNavigatePayload)
97 onPopupClose func(browser purecef.Browser, payload rendererBridgePopupClosePayload)
98 onPopupOpenerNavigate func(browser purecef.Browser, payload popupOpenerNavigatePayload)
99 onPopupOpenerPostMessage func(browser purecef.Browser, payload popupOpenerPostMessagePayload)
100 // onVimPage receives Vim Mode page runtime results (copy, open, end).
101 onVimPage func(browser purecef.Browser, payload vimPageBridgePayload)
102
103 // bridgeNonceValidator checks whether a bridge nonce belongs to the active
104 // browser/navigation context that issued the request.
105 bridgeNonceValidator func(browser purecef.Browser, bridgeNonce string) bool
106}
107
108// newDumbSchemeHandler creates a handler for internal CEF pages.
109func newDumbSchemeHandler(
110 ctx context.Context,
111 router *MessageRouter,
112 currentConfigPayload func() ([]byte, error),
113 defaultConfigPayload func() ([]byte, error),
114) (*dumbSchemeHandler, error) {
115 if currentConfigPayload == nil {
116 return nil, fmt.Errorf("current config payload builder not configured")
117 }
118 if defaultConfigPayload == nil {
119 return nil, fmt.Errorf("default config payload builder not configured")
120 }
121
122 log := logging.FromContext(ctx)
123 return &dumbSchemeHandler{
124 ctx: ctx,
125 messageRouter: router,
126 assetDir: systemviewsAssetDir,
127 logger: log.With().Str("component", "scheme-handler").Logger(),
128 currentConfigPayload: currentConfigPayload,
129 defaultConfigPayload: defaultConfigPayload,
130 }, nil
131}
132
133// setAssets installs one immutable asset filesystem and its fresh WASM
134// cache as a single bundle state. In-flight requests keep their captured
135// bundle and can never publish old bytes into the new cache.
136func (h *dumbSchemeHandler) setAssets(assetsFS fs.FS) {
137 h.mu.Lock()
138 defer h.mu.Unlock()
139 h.assets = assetsFS
140 h.bundle = newSystemviewAssetBundle(assetsFS)
141 h.logger.Debug().Msg("assets filesystem configured")
142}
143
144func (h *dumbSchemeHandler) setFaviconResolver(resolver port.FaviconSystemviewResolver) {
145 h.mu.Lock()
146 defer h.mu.Unlock()
147 h.faviconResolver = resolver
148}
149
150// Create implements purecef.SchemeHandlerFactory. Called on the CEF IO thread
151// for requests to either dumb:// or the internal https origin.
152func (h *dumbSchemeHandler) Create(browser purecef.Browser, _ purecef.Frame, _ string, request purecef.Request) purecef.ResourceHandler {
153 reqURL := request.GetURL()
154 method := request.GetMethod()
155
156 h.logger.Debug().
157 Str("url", reqURL).
158 Str("method", method).
159 Msg("scheme handler create")
160
161 u, err := url.Parse(reqURL)
162 if err != nil {
163 h.logger.Error().Err(err).Str("url", reqURL).Msg("invalid URL")
164 return h.newErrorResourceHandler(http.StatusBadRequest, "Invalid URL")
165 }
166
167 if isCEFCrashPageURL(u) {
168 originalURI := sanitizeCEFCrashPageOriginalURI(strings.TrimSpace(u.Query().Get("url")))
169 return h.newRawResourceHandler(http.StatusOK, "text/html; charset=utf-8", []byte(buildCEFCrashPageHTML(originalURI)))
170 }
171
172 // Route API requests before redirecting conceptual dumb:// URLs to the
173 // internal HTTPS origin. External pages use dumb://api/... for clipboard,
174 // focus, and popup bridges specifically to bypass page CSP; redirecting
175 // those requests to https://dumber.invalid makes site connect-src policies
176 // block them.
177 if apiPath, ok := resolveAPIPath(u); ok {
178 return h.handleAPI(browser, method, apiPath, request)
179 }
180
181 // Keep dumb:// as the app-level abstraction, but redirect CEF page/asset
182 // loads to a normal HTTPS origin so Chromium applies the standard web
183 // security model.
184 if isConceptualInternalURL(reqURL) {
185 targetURL := toActualInternalURL(reqURL)
186 if targetURL != "" && targetURL != reqURL {
187 return h.newRedirectResourceHandler(http.StatusTemporaryRedirect, targetURL)
188 }
189 }
190
191 // Serve static assets.
192 return h.handleAsset(u)
193}
194
195// handleAPI routes API requests to the message router or built-in handlers.
196func (h *dumbSchemeHandler) handleAPI(
197 browser purecef.Browser,
198 method string,
199 requestPath string,
200 request purecef.Request,
201) purecef.ResourceHandler {
202 if strings.EqualFold(method, http.MethodOptions) {
203 if requestPath == "/api/config" || requestPath == "/api/config/default" {
204 if denied := h.rejectUntrustedConfigRequester(request); denied != nil {
205 return denied
206 }
207 return h.newPrivateAPIRawResourceHandler(http.StatusNoContent, "text/plain; charset=utf-8", nil)
208 }
209 return h.newAPIRawResourceHandler(http.StatusNoContent, "text/plain; charset=utf-8", nil)
210 }
211 if isAPIGetMethod(method) {
212 if handler, ok := h.handleAPIGet(requestPath, request); ok {
213 return handler
214 }
215 }
216 if isAPIPostMethod(method) {
217 if handler, ok := h.handleAPIPost(browser, requestPath, request); ok {
218 return handler
219 }
220 }
221 return h.newAPIJSONResourceHandler(http.StatusNotFound, map[string]string{"error": "not found"})
222}
223
224func isAPIGetMethod(method string) bool {
225 return method == "" || strings.EqualFold(method, http.MethodGet)
226}
227
228func isAPIPostMethod(method string) bool {
229 return strings.EqualFold(method, http.MethodPost)
230}
231
232func (h *dumbSchemeHandler) handleAPIGet(requestPath string, request purecef.Request) (purecef.ResourceHandler, bool) {
233 switch requestPath {
234 case "/api/config":
235 if denied := h.rejectUntrustedConfigRequester(request); denied != nil {
236 return denied, true
237 }
238 return h.handleConfigAPI(h.currentConfigPayload), true
239 case "/api/config/default":
240 if denied := h.rejectUntrustedConfigRequester(request); denied != nil {
241 return denied, true
242 }
243 return h.handleConfigAPI(h.defaultConfigPayload), true
244 case "/api/favicon":
245 return h.handleFaviconAPI(request), true
246 default:
247 return nil, false
248 }
249}
250
251func (h *dumbSchemeHandler) handleAPIPost(
252 browser purecef.Browser,
253 requestPath string,
254 request purecef.Request,
255) (purecef.ResourceHandler, bool) {
256 switch requestPath {
257 case "/api/message":
258 return h.handleMessageAPI(request), true
259 case "/api/clipboard-set":
260 return h.handleClipboardSet(request, browser), true
261 case "/api/focus-sync":
262 return h.handleFocusSync(request, browser), true
263 case "/api/popup-open":
264 return h.handlePopupOpen(request, browser), true
265 case "/api/popup-navigate":
266 return h.handlePopupNavigate(request, browser), true
267 case "/api/popup-close":
268 return h.handlePopupClose(request, browser), true
269 case "/api/popup-opener-navigate":
270 return h.handlePopupOpenerNavigate(request, browser), true
271 case "/api/popup-opener-post-message":
272 return h.handlePopupOpenerPostMessage(request, browser), true
273 case "/api/vim-page":
274 return h.handleVimPage(request, browser), true
275 default:
276 return nil, false
277 }
278}
279
280func (h *dumbSchemeHandler) handleMessageAPI(request purecef.Request) purecef.ResourceHandler {
281 if denied := h.rejectUntrustedSystemviewRequester(request); denied != nil {
282 return denied
283 }
284 body := readBodyFromHeader(request)
285 if body == nil {
286 return h.newAPIJSONResourceHandler(http.StatusBadRequest, map[string]string{"error": "empty body"})
287 }
288 resp, err := h.messageRouter.HandleMessage(h.ctx, 0, body)
289 if err != nil {
290 return h.newAPIJSONResourceHandler(http.StatusInternalServerError, map[string]string{"error": err.Error()})
291 }
292 return h.newAPIRawResourceHandler(http.StatusOK, "application/json", resp)
293}
294
295func (h *dumbSchemeHandler) rejectForbiddenAPIOrigin(request purecef.Request) purecef.ResourceHandler {
296 origin := strings.TrimSpace(request.GetHeaderByName("Origin"))
297 if origin == "" || strings.EqualFold(origin, actualInternalOrigin) {
298 return nil
299 }
300 return h.newAPIJSONResourceHandler(http.StatusForbidden, map[string]string{"error": "forbidden origin"})
301}
302
303func (h *dumbSchemeHandler) rejectUntrustedConfigRequester(request purecef.Request) purecef.ResourceHandler {
304 return h.rejectUntrustedSystemviewRequester(request)
305}
306
307func (h *dumbSchemeHandler) rejectUntrustedSystemviewRequester(request purecef.Request) purecef.ResourceHandler {
308 if request == nil {
309 return h.newPrivateAPIJSONResourceHandler(http.StatusForbidden, map[string]string{"error": "forbidden"})
310 }
311 origin := request.GetHeaderByName("Origin")
312 referrer := ""
313 if strings.TrimSpace(origin) == "" {
314 referrer = strings.TrimSpace(request.GetReferrerURL())
315 if referrer == "" {
316 referrer = request.GetHeaderByName("Referer")
317 }
318 }
319 if !systemviewTrust.Allows(origin, referrer) {
320 return h.newPrivateAPIJSONResourceHandler(http.StatusForbidden, map[string]string{"error": "forbidden"})
321 }
322 return nil
323}
324
325func (h *dumbSchemeHandler) handleFaviconAPI(request purecef.Request) purecef.ResourceHandler {
326 if denied := h.rejectUntrustedFaviconRequester(request); denied != nil {
327 return denied
328 }
329
330 h.mu.RLock()
331 resolver := h.faviconResolver
332 h.mu.RUnlock()
333 if resolver == nil {
334 return h.newPrivateAPIJSONResourceHandler(http.StatusNotFound, map[string]string{"error": "favicon unavailable"})
335 }
336
337 domain, size, err := webutil.ParseSystemviewFaviconRequest(request.GetURL())
338 if err != nil {
339 return h.newPrivateAPIJSONResourceHandler(http.StatusBadRequest, map[string]string{"error": err.Error()})
340 }
341
342 return newFaviconResourceHandler(h.ctx, resolver, domain, size)
343}
344
345func (h *dumbSchemeHandler) rejectUntrustedFaviconRequester(request purecef.Request) purecef.ResourceHandler {
346 return h.rejectUntrustedSystemviewRequester(request)
347}
348
349func isTrustedSystemviewURL(raw string) bool {
350 parsed, err := url.Parse(strings.TrimSpace(raw))
351 if err != nil || parsed.Scheme == "" {
352 return false
353 }
354 if strings.EqualFold(parsed.Scheme, actualInternalScheme) {
355 return strings.EqualFold(parsed.Host, actualInternalHost)
356 }
357 host, ok := webutil.DumbURLHost(raw)
358 return ok && isInternalPageHost(host)
359}
360
361func resolveConfigPayload(build func() ([]byte, error)) ([]byte, error) {
362 if build == nil {
363 return nil, fmt.Errorf("config payload builder not configured")
364 }
365 return build()
366}
367
368// handleConfigAPI returns a JSON response with the given config payload builder.
369func (h *dumbSchemeHandler) handleConfigAPI(build func() ([]byte, error)) purecef.ResourceHandler {
370 data, err := resolveConfigPayload(build)
371 if err != nil {
372 return h.newPrivateAPIJSONResourceHandler(http.StatusInternalServerError, map[string]string{"error": err.Error()})
373 }
374 return h.newPrivateAPIRawResourceHandler(http.StatusOK, "application/json", data)
375}
376
377// handleClipboardSet receives copied text from JS copy/cut events and writes
378// it to the system clipboard via the engine callback.
379const (
380 maxClipboardBytes = 10 << 20 // 10 MB
381 dumberBodyHeaderName = "X-Dumber-Body"
382 dumberBridgeActionHeaderName = "X-Dumber-Bridge-Action"
383 dumberBridgeNonceHeaderName = "X-Dumber-Bridge-Nonce"
384 dumberBridgeActionFocusSync = "focus-sync"
385)
386
387func (h *dumbSchemeHandler) handleClipboardSet(request purecef.Request, browser purecef.Browser) purecef.ResourceHandler {
388 h.logger.Debug().Msg("cef: /api/clipboard-set request received")
389
390 if !h.hasTrustedBridgeNonce(request, browser) {
391 h.logger.Warn().Msg("cef: clipboard-set — rejected request without valid bridge nonce")
392 return h.newAPIJSONResourceHandler(http.StatusForbidden, map[string]string{"error": "forbidden"})
393 }
394
395 body := readBodyFromHeader(request)
396 if body == nil {
397 h.logger.Debug().Msg("cef: clipboard-set — empty body (no X-Dumber-Body header)")
398 return h.newAPIJSONResourceHandler(http.StatusBadRequest, map[string]string{"error": "empty body"})
399 }
400 if len(body) > maxClipboardBytes {
401 h.logger.Warn().Int("body_len", len(body)).Msg("cef: clipboard-set — payload too large")
402 return h.newAPIJSONResourceHandler(http.StatusRequestEntityTooLarge, map[string]string{"error": "payload too large"})
403 }
404
405 var payload struct {
406 Text string `json:"text"`
407 }
408 if err := json.Unmarshal(body, &payload); err != nil || payload.Text == "" {
409 h.logger.Debug().Int("body_len", len(body)).Msg("cef: clipboard-set — invalid or empty payload")
410 return h.newAPIJSONResourceHandler(http.StatusBadRequest, map[string]string{"error": "invalid payload"})
411 }
412
413 h.logger.Debug().Int("text_len", len(payload.Text)).Msg("cef: clipboard-set — forwarding to GDK clipboard")
414
415 if h.onClipboardSet != nil {
416 h.onClipboardSet(payload.Text)
417 } else {
418 h.logger.Warn().Msg("cef: clipboard-set — onClipboardSet callback not wired")
419 }
420
421 return h.newAPIJSONResourceHandler(http.StatusOK, map[string]any{"ok": true})
422}
423
424func (h *dumbSchemeHandler) handleFocusSync(request purecef.Request, browser purecef.Browser) purecef.ResourceHandler {
425 bridgeAction := ""
426 if request != nil {
427 bridgeAction = strings.TrimSpace(request.GetHeaderByName(dumberBridgeActionHeaderName))
428 }
429 if !strings.EqualFold(bridgeAction, dumberBridgeActionFocusSync) {
430 h.logger.Warn().Msg("cef: focus-sync — rejected request without trusted bridge action header")
431 return h.newAPIJSONResourceHandler(http.StatusForbidden, map[string]string{"error": "forbidden"})
432 }
433 if browser == nil {
434 h.logger.Debug().Msg("cef: focus-sync — browser unavailable")
435 return h.newAPIJSONResourceHandler(http.StatusBadRequest, map[string]string{"error": "browser unavailable"})
436 }
437 if !h.hasTrustedBridgeNonce(request, browser) {
438 h.logger.Warn().Msg("cef: focus-sync — rejected request without valid bridge nonce")
439 return h.newAPIJSONResourceHandler(http.StatusForbidden, map[string]string{"error": "forbidden"})
440 }
441
442 if h.onEditableFocus != nil {
443 h.onEditableFocus(browser)
444 } else {
445 h.logger.Debug().Msg("cef: focus-sync — onEditableFocus callback not wired")
446 }
447
448 return h.newAPIJSONResourceHandler(http.StatusOK, map[string]any{"ok": true})
449}
450
451func handlePopupBridgeRequest[T any](
452 h *dumbSchemeHandler,
453 request purecef.Request,
454 browser purecef.Browser,
455 action string,
456 decode func([]byte) (T, error),
457 dispatch func(purecef.Browser, T),
458) purecef.ResourceHandler {
459 if browser == nil {
460 h.logger.Debug().Msg("cef: " + action + " — browser unavailable")
461 return h.newAPIJSONResourceHandler(http.StatusBadRequest, map[string]string{"error": "browser unavailable"})
462 }
463 if !h.hasTrustedBridgeNonce(request, browser) {
464 h.logger.Warn().Msg("cef: " + action + " — rejected request without valid bridge nonce")
465 return h.newAPIJSONResourceHandler(http.StatusForbidden, map[string]string{"error": "forbidden"})
466 }
467 body := readBodyFromHeader(request)
468 if body == nil {
469 return h.newAPIJSONResourceHandler(http.StatusBadRequest, map[string]string{"error": "empty body"})
470 }
471 payload, err := decode(body)
472 if err != nil {
473 return h.newAPIJSONResourceHandler(http.StatusBadRequest, map[string]string{"error": err.Error()})
474 }
475 if dispatch != nil {
476 dispatch(browser, payload)
477 } else {
478 h.logger.Warn().Msg("cef: " + action + " — callback not wired")
479 }
480 return h.newAPIJSONResourceHandler(http.StatusOK, map[string]any{"ok": true})
481}
482
483// handleVimPage accepts Vim page runtime results. It does not use the shared
484// bridge nonce: the payload carries a per-interaction token that the source
485// WebView validates, so the bridge nonce is never exposed to page-reachable
486// Vim calls.
487func (h *dumbSchemeHandler) handleVimPage(request purecef.Request, browser purecef.Browser) purecef.ResourceHandler {
488 if browser == nil {
489 return h.newAPIJSONResourceHandler(http.StatusBadRequest, map[string]string{"error": "browser unavailable"})
490 }
491 body := readBodyFromHeader(request)
492 if body == nil {
493 return h.newAPIJSONResourceHandler(http.StatusBadRequest, map[string]string{"error": "empty body"})
494 }
495 payload, err := decodeVimPageBridgePayload(body)
496 if errors.Is(err, errVimPagePayloadTooLarge) {
497 return h.newAPIJSONResourceHandler(http.StatusRequestEntityTooLarge, map[string]string{"error": "payload too large"})
498 }
499 if err != nil {
500 return h.newAPIJSONResourceHandler(http.StatusBadRequest, map[string]string{"error": "invalid payload"})
501 }
502 if h.onVimPage == nil {
503 h.logger.Warn().Msg("cef: vim-page — callback not wired")
504 } else {
505 h.onVimPage(browser, payload)
506 }
507 return h.newAPIJSONResourceHandler(http.StatusOK, map[string]any{"ok": true})
508}
509
510func (h *dumbSchemeHandler) handlePopupOpen(request purecef.Request, browser purecef.Browser) purecef.ResourceHandler {
511 return handlePopupBridgeRequest(h, request, browser, "popup-open", decodeRendererBridgePopupOpenPayload, h.onPopupOpen)
512}
513
514func (h *dumbSchemeHandler) handlePopupNavigate(request purecef.Request, browser purecef.Browser) purecef.ResourceHandler {
515 return handlePopupBridgeRequest(h, request, browser, "popup-navigate", decodeRendererBridgePopupNavigatePayload, h.onPopupNavigate)
516}
517
518func (h *dumbSchemeHandler) handlePopupClose(request purecef.Request, browser purecef.Browser) purecef.ResourceHandler {
519 return handlePopupBridgeRequest(h, request, browser, "popup-close", decodeRendererBridgePopupClosePayload, h.onPopupClose)
520}
521
522func decodePopupOpenerNavigatePayload(body []byte) (popupOpenerNavigatePayload, error) {
523 var payload popupOpenerNavigatePayload
524 if err := json.Unmarshal(body, &payload); err != nil {
525 return payload, err
526 }
527 payload.URL = strings.TrimSpace(payload.URL)
528 if payload.URL == "" {
529 return payload, fmt.Errorf("missing url")
530 }
531 return payload, nil
532}
533
534func decodePopupOpenerPostMessagePayload(body []byte) (popupOpenerPostMessagePayload, error) {
535 var payload popupOpenerPostMessagePayload
536 if err := json.Unmarshal(body, &payload); err != nil {
537 return payload, err
538 }
539 payload.DataKind = strings.TrimSpace(payload.DataKind)
540 payload.TargetOrigin = strings.TrimSpace(payload.TargetOrigin)
541 payload.SourceOrigin = strings.TrimSpace(payload.SourceOrigin)
542 payload.SourceHref = strings.TrimSpace(payload.SourceHref)
543 if payload.TargetOrigin == "" {
544 return payload, fmt.Errorf("missing target origin")
545 }
546 return payload, nil
547}
548
549func (h *dumbSchemeHandler) handlePopupOpenerNavigate(request purecef.Request, browser purecef.Browser) purecef.ResourceHandler {
550 return handlePopupBridgeRequest(h, request, browser, "popup-opener-navigate", decodePopupOpenerNavigatePayload, h.onPopupOpenerNavigate)
551}
552
553func (h *dumbSchemeHandler) handlePopupOpenerPostMessage(request purecef.Request, browser purecef.Browser) purecef.ResourceHandler {
554 return handlePopupBridgeRequest(
555 h,
556 request,
557 browser,
558 "popup-opener-post-message",
559 decodePopupOpenerPostMessagePayload,
560 h.onPopupOpenerPostMessage,
561 )
562}
563
564func (h *dumbSchemeHandler) hasTrustedBridgeNonce(request purecef.Request, browser purecef.Browser) bool {
565 if browser == nil || h == nil || h.bridgeNonceValidator == nil || request == nil {
566 return false
567 }
568 bridgeNonce := strings.TrimSpace(request.GetHeaderByName(dumberBridgeNonceHeaderName))
569 if bridgeNonce == "" {
570 return false
571 }
572 return h.bridgeNonceValidator(browser, bridgeNonce)
573}
574
575func (h *dumbSchemeHandler) newRedirectResourceHandler(status int, location string) purecef.ResourceHandler {
576 return newStaticResourceHandler(status, "text/plain; charset=utf-8", nil, map[string]string{
577 "Location": location,
578 "Cache-Control": "no-store",
579 })
580}
581
582// handleAsset serves static files from the captured asset bundle.
583func (h *dumbSchemeHandler) handleAsset(u *url.URL) purecef.ResourceHandler {
584 h.mu.RLock()
585 assetsFS, bundle := h.assets, h.bundle
586 h.mu.RUnlock()
587
588 if assetsFS == nil || bundle == nil {
589 return h.newErrorResourceHandler(http.StatusInternalServerError, "Assets not configured")
590 }
591
592 assetDir, relPath, ok := resolveAssetPath(u)
593 if !ok {
594 return h.newErrorResourceHandler(http.StatusNotFound, "Page not found")
595 }
596 if assetDir == "" {
597 assetDir = h.assetDir
598 }
599
600 fullPath, relPath, ok := safeSystemviewsAssetPath(assetDir, relPath)
601 if !ok {
602 return h.newErrorResourceHandler(http.StatusNotFound, "Asset not found")
603 }
604
605 // The bundle WASM is served from the once-loaded immutable cache
606 // through a deferred handler: Create returns immediately and the
607 // decode runs off the CEF IO thread. The shell keeps its usable
608 // relative URLs on disk but is served with content-versioned
609 // references from the same captured bundle. Every other asset keeps
610 // the legacy synchronous per-request read path.
611 if relPath == indexHTML {
612 return h.serveVersionedShell(assetsFS, bundle, fullPath)
613 }
614 if isVersionedAssetFile(relPath) {
615 v, present, valid := parseAssetVersion(u)
616 return h.serveVersionedStatic(assetsFS, bundle, fullPath, relPath, v, present, valid)
617 }
618
619 data, err := readAssetWithEncoding(assetsFS, fullPath, relPath)
620 if err != nil {
621 h.logger.Debug().Str("path", fullPath).Err(err).Msg("asset not found")
622 return h.newErrorResourceHandler(http.StatusNotFound, "Asset not found")
623 }
624
625 contentType := getMimeType(relPath)
626 h.logger.Debug().
627 Str("path", fullPath).
628 Str("content_type", contentType).
629 Int("size", len(data)).
630 Msg("serving asset")
631
632 // Unversioned static assets are servable but explicitly noncacheable;
633 // only version-verified bytes earn immutable headers below.
634 return newStaticResourceHandler(http.StatusOK, contentType, data,
635 map[string]string{"Cache-Control": noStoreCacheControl})
636}
637
638// immutableCacheControl grants long-lived private caching to exactly one
639// verified content version. It is served only for allowlisted static
640// files whose ?v= digest matches the SHA-256 of the exact bytes served.
641const immutableCacheControl = "private, max-age=31536000, immutable"
642
643// noStoreCacheControl marks responses that must never be cached: the
644// unversioned shell and static assets, dynamic API content, and every
645// version-gating error.
646const noStoreCacheControl = "no-store"
647
648// serveVersionedShell serves the internal shell with its known static
649// references rewritten to content-versioned URLs (?v=<sha256>) from the
650// captured bundle's manifest. The shell itself is unversioned and stays
651// noncacheable; version queries on the shell are ignored because the
652// shell document is never immutable. An invalid manifest yields a clear
653// noncacheable error instead of unverified versioned content.
654func (h *dumbSchemeHandler) serveVersionedShell(assetsFS fs.FS, bundle *systemviewAssetBundle, fullPath string) purecef.ResourceHandler {
655 data, err := readAssetWithEncoding(assetsFS, fullPath, indexHTML)
656 if err != nil {
657 h.logger.Debug().Str("path", fullPath).Err(err).Msg("shell not found")
658 return h.newErrorResourceHandler(http.StatusNotFound, "Asset not found")
659 }
660 m, err := bundle.Manifest()
661 if err != nil {
662 h.logger.Error().Err(err).Msg("asset manifest invalid; refusing to version shell")
663 return newStaticResourceHandler(http.StatusInternalServerError, "text/html; charset=utf-8",
664 errorPageBody(http.StatusInternalServerError, "Asset manifest invalid"),
665 map[string]string{"Cache-Control": noStoreCacheControl})
666 }
667 return newStaticResourceHandler(http.StatusOK, getMimeType(indexHTML), versionShellRefs(data, m),
668 map[string]string{"Cache-Control": noStoreCacheControl})
669}
670
671// versionShellRefs rewrites the shell's known static references with
672// content-version query values: ./systemviews.wasm becomes
673// ./systemviews.wasm?v=<digest>, likewise for the pinned JS and CSS.
674// Only manifest-pinned files are rewritten and already-versioned
675// references pass through untouched, so the on-disk shell keeps concrete
676// usable relative URLs for alternate shells.
677func versionShellRefs(shell []byte, m assets.Manifest) []byte {
678 out := shell
679 for _, name := range assets.ManifestFiles {
680 plain := "./" + name
681 if bytes.Contains(out, []byte(plain+"?v=")) {
682 continue
683 }
684 out = bytes.ReplaceAll(out, []byte(plain), []byte(plain+"?v="+m.Files[name].SHA256))
685 }
686 return out
687}
688
689// isVersionedAssetFile reports whether relPath is a canonical allowlisted
690// static file: exactly the manifest-pinned servables. Only these may earn
691// immutable headers; everything else (including the manifest itself and
692// the shell) stays on the noncacheable path.
693func isVersionedAssetFile(relPath string) bool {
694 for _, name := range assets.ManifestFiles {
695 if relPath == name {
696 return true
697 }
698 }
699 return false
700}
701
702// parseAssetVersion extracts the content-version query value with
703// exactly-one semantics: absent reports present=false (unversioned
704// request), while duplicate or empty values report present=true with
705// valid=false (a version-gating error, never an unversioned request).
706// Malformed escape sequences are fail-closed too: u.Query silently drops
707// unparseable components, so the raw query is parsed explicitly and any
708// parse error alongside a v parameter rejects the request.
709func parseAssetVersion(u *url.URL) (v string, present, valid bool) {
710 if u == nil {
711 return "", false, false
712 }
713 vals, qerr := url.ParseQuery(u.RawQuery)
714 if qerr != nil {
715 // A malformed query carrying a v parameter is a version-gating
716 // error; without one it is an ordinary unversioned request.
717 return "", hasVersionParam(u.RawQuery), false
718 }
719 vs, ok := vals["v"]
720 if !ok || len(vs) == 0 {
721 return "", false, false
722 }
723 if len(vs) != 1 || vs[0] == "" {
724 return "", true, false
725 }
726 return vs[0], true, true
727}
728
729// hasVersionParam reports whether the raw query carries a v parameter,
730// used only to classify malformed queries fail-closed.
731func hasVersionParam(rawQuery string) bool {
732 for _, part := range strings.Split(rawQuery, "&") {
733 name, _, _ := strings.Cut(part, "=")
734 if name == "v" {
735 return true
736 }
737 }
738 return false
739}
740
741// serveVersionedStatic serves one allowlisted static file with byte-bound
742// cache policy: exactly one valid ?v= matching the SHA-256 of the exact
743// served bytes earns immutable headers. URL-to-manifest equality alone is
744// insufficient — the bytes are hashed and compared. Duplicate, malformed,
745// or mismatching versions, missing artifacts, and stale manifests all
746// return a noncacheable error; unversioned requests serve explicitly
747// noncacheable without verification.
748func (h *dumbSchemeHandler) serveVersionedStatic(
749 assetsFS fs.FS, bundle *systemviewAssetBundle, fullPath, relPath, v string, present, valid bool,
750) purecef.ResourceHandler {
751 if present && !valid {
752 return h.versionError(fullPath)
753 }
754 // Unversioned requests never consult the manifest: they serve
755 // explicitly noncacheable without verification, including on
756 // manifest-less bundles.
757 if !present {
758 return h.serveUnversionedStatic(assetsFS, bundle, fullPath, relPath)
759 }
760 m, err := bundle.Manifest()
761 if err != nil {
762 h.logger.Error().Err(err).Msg("asset manifest invalid; refusing versioned static")
763 return newStaticResourceHandler(http.StatusInternalServerError, "text/html; charset=utf-8",
764 errorPageBody(http.StatusInternalServerError, "Asset manifest invalid"),
765 map[string]string{"Cache-Control": noStoreCacheControl})
766 }
767 pin, ok := m.Files[relPath]
768 if !ok {
769 h.logger.Error().Str("path", fullPath).Msg("asset manifest missing pin; refusing versioned static")
770 return newStaticResourceHandler(http.StatusInternalServerError, "text/html; charset=utf-8",
771 errorPageBody(http.StatusInternalServerError, "Asset manifest invalid"),
772 map[string]string{"Cache-Control": noStoreCacheControl})
773 }
774 if v != pin.SHA256 {
775 return h.versionError(fullPath)
776 }
777 if relPath == "systemviews.wasm" {
778 return newSystemviewWASMResourceHandler(h.ctx, bundle, true, pin.SHA256)
779 }
780 data, err := readAssetWithEncoding(assetsFS, fullPath, relPath)
781 if err != nil {
782 return h.versionError(fullPath)
783 }
784 if sha256Hex(data) != pin.SHA256 {
785 h.logger.Debug().Str("path", fullPath).Msg("versioned asset bytes do not match manifest pin")
786 return h.versionError(fullPath)
787 }
788 h.logger.Debug().
789 Str("path", fullPath).
790 Str("content_type", getMimeType(relPath)).
791 Int("size", len(data)).
792 Msg("serving verified immutable asset")
793 return newStaticResourceHandler(http.StatusOK, getMimeType(relPath), data,
794 map[string]string{"Cache-Control": immutableCacheControl})
795}
796
797// serveUnversionedStatic serves an allowlisted file requested without a
798// version: readable, explicitly noncacheable, unverified.
799func (h *dumbSchemeHandler) serveUnversionedStatic(
800 assetsFS fs.FS, bundle *systemviewAssetBundle, fullPath, relPath string,
801) purecef.ResourceHandler {
802 if relPath == "systemviews.wasm" {
803 return newSystemviewWASMResourceHandler(h.ctx, bundle, false, "")
804 }
805 data, err := readAssetWithEncoding(assetsFS, fullPath, relPath)
806 if err != nil {
807 h.logger.Debug().Str("path", fullPath).Err(err).Msg("asset not found")
808 return h.newErrorResourceHandler(http.StatusNotFound, "Asset not found")
809 }
810 return newStaticResourceHandler(http.StatusOK, getMimeType(relPath), data,
811 map[string]string{"Cache-Control": noStoreCacheControl})
812}
813
814// versionError returns a noncacheable not-found for version-gating
815// failures: duplicates, malformed values, mismatches, and missing
816// versioned bytes all read as not found without inviting caching or
817// enumeration.
818func (h *dumbSchemeHandler) versionError(fullPath string) purecef.ResourceHandler {
819 h.logger.Debug().Str("path", fullPath).Msg("versioned asset rejected")
820 return newStaticResourceHandler(http.StatusNotFound, "text/html; charset=utf-8", errorPageBody(http.StatusNotFound, "Asset not found"),
821 map[string]string{"Cache-Control": noStoreCacheControl})
822}
823
824// sha256Hex digests exact served bytes for manifest comparison.
825func sha256Hex(data []byte) string {
826 sum := sha256.Sum256(data)
827 return hex.EncodeToString(sum[:])
828}
829
830// resolveAssetPath maps either a dumb:// URL or the actual internal HTTPS URL
831// to a relative asset path inside assets/systemviews.
832func resolveAssetPath(u *url.URL) (assetDir, relPath string, ok bool) {
833 if u == nil {
834 return "", "", false
835 }
836
837 if strings.EqualFold(u.Scheme, actualInternalScheme) && strings.EqualFold(u.Host, actualInternalHost) {
838 return resolveActualAssetPath(u)
839 }
840
841 return resolveConceptualAssetPath(u)
842}
843
844func resolveConceptualAssetPath(u *url.URL) (assetDir, relPath string, ok bool) {
845 if u == nil {
846 return "", "", false
847 }
848
849 if root, ok := pageRootFiles[u.Host]; ok {
850 assetPath := strings.TrimPrefix(u.Path, "/")
851 if assetPath == "" {
852 return assetDirForPageHost(u.Host), root, true
853 }
854 // Don't serve API paths as assets.
855 if strings.HasPrefix(assetPath, "api/") {
856 return "", "", false
857 }
858 return assetDirForPageHost(u.Host), assetPath, true
859 }
860
861 // Handle opaque URLs like dumb:history.
862 if root, ok := pageRootFiles[u.Opaque]; ok {
863 return assetDirForPageHost(u.Opaque), root, true
864 }
865 return "", "", false
866}
867
868func resolveActualAssetPath(u *url.URL) (assetDir, relPath string, ok bool) {
869 if u == nil {
870 return "", "", false
871 }
872
873 assetPath := strings.Trim(u.Path, "/")
874 if assetPath == "" {
875 return "", "", false
876 }
877
878 parts := strings.SplitN(assetPath, "/", 2)
879 page := parts[0]
880 if root, ok := pageRootFiles[page]; ok {
881 if len(parts) == 1 {
882 return assetDirForPageHost(page), root, true
883 }
884 if page == historyPath && parts[1] == "crash" {
885 return "", "", false
886 }
887 return assetDirForPageHost(page), parts[1], true
888 }
889
890 if strings.HasPrefix(assetPath, "api/") {
891 return "", "", false
892 }
893
894 if !strings.Contains(assetPath, "/") && strings.Contains(assetPath, ".") {
895 return systemviewsAssetDir, assetPath, true
896 }
897
898 return "", "", false
899}
900
901func assetDirForPageHost(host string) string {
902 switch host {
903 case historyPath, favoritesPath, configPath, errorPath:
904 return systemviewsAssetDir
905 default:
906 return ""
907 }
908}
909
910func isCEFCrashPageURL(u *url.URL) bool {
911 if u == nil {
912 return false
913 }
914
915 switch {
916 case strings.EqualFold(u.Scheme, actualInternalScheme) && strings.EqualFold(u.Host, actualInternalHost):
917 return strings.Trim(u.Path, "/") == historyPath+"/crash"
918 case strings.EqualFold(u.Scheme, "dumb") && u.Host == historyPath:
919 return strings.Trim(u.Path, "/") == "crash"
920 default:
921 return false
922 }
923}
924
925func sanitizeCEFCrashPageOriginalURI(originalURI string) string {
926 return webutil.SanitizeCrashPageOriginalURI(originalURI)
927}
928
929func buildCEFCrashPageHTML(originalURI string) string {
930 return webutil.BuildCrashPageHTML(originalURI)
931}
932
933func getMimeType(filename string) string {
934 return webutil.GetMimeType(filename)
935}
936
937// splitMimeCharset splits "text/html; charset=utf-8" into ("text/html", "utf-8").
938// If no charset parameter is present, charset is empty.
939func splitMimeCharset(contentType string) (mimeType, charset string) {
940 parts := strings.SplitN(contentType, ";", 2)
941 mimeType = strings.TrimSpace(parts[0])
942 if len(parts) > 1 {
943 param := strings.TrimSpace(parts[1])
944 if strings.HasPrefix(strings.ToLower(param), "charset=") {
945 charset = strings.TrimSpace(param[len("charset="):])
946 }
947 }
948 return
949}
950
951// ---------------------------------------------------------------------------
952// ResourceHandler implementation
953// ---------------------------------------------------------------------------
954
955// staticResourceHandler serves a fixed byte buffer as a CEF ResourceHandler.
956type staticResourceHandler struct {
957 data []byte
958 contentType string
959 statusCode int
960 headers map[string]string
961 offset int
962}
963
964func apiResponseHeaders(extra map[string]string) map[string]string {
965 headers := map[string]string{
966 "Access-Control-Allow-Origin": "*",
967 "Access-Control-Allow-Methods": "GET, POST, OPTIONS",
968 "Access-Control-Allow-Headers": strings.Join([]string{
969 "Content-Type",
970 "X-Dumber-Body",
971 "X-Dumber-Bridge-Action",
972 "X-Dumber-Bridge-Nonce",
973 }, ", "),
974 "Access-Control-Max-Age": "86400",
975 "Cache-Control": "no-store",
976 }
977 maps.Copy(headers, extra)
978 return headers
979}
980
981func newStaticResourceHandler(status int, contentType string, data []byte, headers map[string]string) purecef.ResourceHandler {
982 return cefNewResourceHandler(&staticResourceHandler{
983 data: data,
984 contentType: contentType,
985 statusCode: status,
986 headers: headers,
987 })
988}
989
990func (h *dumbSchemeHandler) newRawResourceHandler(status int, contentType string, data []byte) purecef.ResourceHandler {
991 return newStaticResourceHandler(status, contentType, data, nil)
992}
993
994func (h *dumbSchemeHandler) newAPIRawResourceHandler(status int, contentType string, data []byte) purecef.ResourceHandler {
995 return newStaticResourceHandler(status, contentType, data, apiResponseHeaders(nil))
996}
997
998func (h *dumbSchemeHandler) newPrivateAPIRawResourceHandler(status int, contentType string, data []byte) purecef.ResourceHandler {
999 return newStaticResourceHandler(status, contentType, data, map[string]string{"Cache-Control": "no-store"})
1000}
1001
1002func (h *dumbSchemeHandler) newErrorResourceHandler(status int, msg string) purecef.ResourceHandler {
1003 return h.newRawResourceHandler(status, "text/html; charset=utf-8", errorPageBody(status, msg))
1004}
1005
1006// errorPageBody renders the shared plain error document. Deferred handlers
1007// reuse it so failures stay byte-identical to the synchronous error path.
1008func errorPageBody(status int, msg string) []byte {
1009 escaped := html.EscapeString(msg)
1010 return []byte(fmt.Sprintf(`<!DOCTYPE html><html><body><h1>%d</h1><p>%s</p></body></html>`, status, escaped))
1011}
1012
1013func (h *dumbSchemeHandler) newAPIJSONResourceHandler(status int, v any) purecef.ResourceHandler {
1014 data, err := json.Marshal(v)
1015 if err != nil {
1016 fallback, _ := json.Marshal(map[string]string{"error": "JSON encoding failed"})
1017 return h.newAPIRawResourceHandler(http.StatusInternalServerError, "application/json", fallback)
1018 }
1019 return h.newAPIRawResourceHandler(status, "application/json", data)
1020}
1021
1022func (h *dumbSchemeHandler) newPrivateAPIJSONResourceHandler(status int, v any) purecef.ResourceHandler {
1023 data, err := json.Marshal(v)
1024 if err != nil {
1025 fallback, _ := json.Marshal(map[string]string{"error": "JSON encoding failed"})
1026 return h.newPrivateAPIRawResourceHandler(http.StatusInternalServerError, "application/json", fallback)
1027 }
1028 return h.newPrivateAPIRawResourceHandler(status, "application/json", data)
1029}
1030
1031func newFaviconResourceHandler(
1032 ctx context.Context,
1033 resolver port.FaviconSystemviewResolver,
1034 domain string,
1035 size int,
1036) purecef.ResourceHandler {
1037 reqCtx, cancel := context.WithCancel(ctx) //nolint:gosec // canceled by load completion or CEF Cancel callback
1038 return cefNewResourceHandler(&faviconResourceHandler{
1039 ctx: reqCtx,
1040 cancel: cancel,
1041 resolver: resolver,
1042 domain: domain,
1043 size: size,
1044 done: make(chan struct{}),
1045 headers: map[string]string{"Cache-Control": "no-store"},
1046 })
1047}
1048
1049type faviconResourceHandler struct {
1050 ctx context.Context
1051 cancel context.CancelFunc
1052 cancelOnce sync.Once
1053 resolver port.FaviconSystemviewResolver
1054 domain string
1055 size int
1056 headers map[string]string
1057 once sync.Once
1058 done chan struct{}
1059 data []byte
1060 contentType string
1061 statusCode int
1062 offset int
1063}
1064
1065func (rh *faviconResourceHandler) load() {
1066 defer close(rh.done)
1067 defer rh.cancelRequest()
1068 rh.statusCode = http.StatusNotFound
1069 rh.contentType = "application/json"
1070 rh.data = []byte(`{"error":"favicon not cached"}`)
1071 if rh.resolver == nil {
1072 return
1073 }
1074 resolved, err := rh.resolver.ResolveSystemviewIcon(rh.ctx, rh.domain, rh.size)
1075 if err != nil || resolved == nil || len(resolved.Bytes) == 0 {
1076 return
1077 }
1078 rh.statusCode = http.StatusOK
1079 rh.contentType = resolved.ContentType
1080 if rh.contentType == "" {
1081 rh.contentType = "image/png"
1082 }
1083 rh.data = resolved.Bytes
1084}
1085
1086func (rh *faviconResourceHandler) Open(_ purecef.Request, handleRequest *int32, callback purecef.Callback) int32 {
1087 if handleRequest != nil {
1088 *handleRequest = 0
1089 }
1090 rh.once.Do(func() {
1091 go func() {
1092 rh.load()
1093 if callback != nil {
1094 callback.Cont()
1095 }
1096 }()
1097 })
1098 return 1
1099}
1100
1101func (rh *faviconResourceHandler) ProcessRequest(_ purecef.Request, callback purecef.Callback) int32 {
1102 rh.once.Do(func() {
1103 go func() {
1104 rh.load()
1105 if callback != nil {
1106 callback.Cont()
1107 }
1108 }()
1109 })
1110 return 1
1111}
1112
1113func (rh *faviconResourceHandler) GetResponseHeaders(response purecef.Response, responseLength *int64, _ uintptr) {
1114 <-rh.done
1115 response.SetStatus(int32(rh.statusCode))
1116 if text := http.StatusText(rh.statusCode); text != "" {
1117 response.SetStatusText(text)
1118 }
1119 mimeType, charset := splitMimeCharset(rh.contentType)
1120 response.SetMimeType(mimeType)
1121 if charset != "" {
1122 response.SetCharset(charset)
1123 }
1124 for name, value := range rh.headers {
1125 response.SetHeaderByName(name, value, 1)
1126 }
1127 if responseLength != nil {
1128 *responseLength = int64(len(rh.data))
1129 }
1130}
1131
1132func (rh *faviconResourceHandler) Skip(_ int64, _ *int64, _ purecef.ResourceSkipCallback) int32 {
1133 return 0
1134}
1135
1136func (rh *faviconResourceHandler) Read(dataOut unsafe.Pointer, bytesToRead int32, bytesRead *int32, _ purecef.ResourceReadCallback) int32 {
1137 <-rh.done
1138 if rh.offset >= len(rh.data) {
1139 return 0
1140 }
1141 remaining := len(rh.data) - rh.offset
1142 toRead := min(int(bytesToRead), remaining)
1143 dst := unsafe.Slice((*byte)(dataOut), toRead)
1144 copy(dst, rh.data[rh.offset:rh.offset+toRead])
1145 rh.offset += toRead
1146 if bytesRead != nil {
1147 *bytesRead = int32(toRead)
1148 }
1149 return 1
1150}
1151
1152func (rh *faviconResourceHandler) ReadResponse(_ unsafe.Pointer, _ int32, _ *int32, _ purecef.Callback) int32 {
1153 return 0
1154}
1155
1156func (rh *faviconResourceHandler) Cancel() { rh.cancelRequest() }
1157
1158func (rh *faviconResourceHandler) cancelRequest() {
1159 if rh.cancel != nil {
1160 rh.cancelOnce.Do(rh.cancel)
1161 }
1162}
1163
1164// Open handles the request immediately (synchronous).
1165func (rh *staticResourceHandler) Open(_ purecef.Request, handleRequest *int32, _ purecef.Callback) int32 {
1166 // Set handleRequest = true (1) to indicate we handle it immediately.
1167 if handleRequest != nil {
1168 *handleRequest = 1
1169 }
1170 return 1
1171}
1172
1173// ProcessRequest is deprecated; Open is used instead.
1174func (rh *staticResourceHandler) ProcessRequest(_ purecef.Request, _ purecef.Callback) int32 {
1175 return 0
1176}
1177
1178// GetResponseHeaders sets status code, MIME type, and content length.
1179// CEF's SetMimeType expects the MIME type without charset parameters;
1180// the charset must be set separately via SetCharset.
1181func (rh *staticResourceHandler) GetResponseHeaders(response purecef.Response, responseLength *int64, _ uintptr) {
1182 response.SetStatus(int32(rh.statusCode))
1183 if text := http.StatusText(rh.statusCode); text != "" {
1184 response.SetStatusText(text)
1185 }
1186 mimeType, charset := splitMimeCharset(rh.contentType)
1187 response.SetMimeType(mimeType)
1188 if charset != "" {
1189 response.SetCharset(charset)
1190 }
1191 for name, value := range rh.headers {
1192 response.SetHeaderByName(name, value, 1)
1193 }
1194 if responseLength != nil {
1195 *responseLength = int64(len(rh.data))
1196 }
1197}
1198
1199// Skip is not used for static content.
1200func (rh *staticResourceHandler) Skip(_ int64, _ *int64, _ purecef.ResourceSkipCallback) int32 {
1201 return 0
1202}
1203
1204// Read copies data into the output buffer.
1205func (rh *staticResourceHandler) Read(
1206 dataOut unsafe.Pointer, bytesToRead int32,
1207 bytesRead *int32, _ purecef.ResourceReadCallback,
1208) int32 {
1209 if rh.offset >= len(rh.data) {
1210 return 0
1211 }
1212
1213 remaining := len(rh.data) - rh.offset
1214 toRead := min(int(bytesToRead), remaining)
1215
1216 // Copy data to the output buffer.
1217 dst := unsafe.Slice((*byte)(dataOut), toRead)
1218 copy(dst, rh.data[rh.offset:rh.offset+toRead])
1219 rh.offset += toRead
1220
1221 if bytesRead != nil {
1222 *bytesRead = int32(toRead)
1223 }
1224 return 1
1225}
1226
1227// ReadResponse is deprecated; Read is used instead.
1228func (rh *staticResourceHandler) ReadResponse(_ unsafe.Pointer, _ int32, _ *int32, _ purecef.Callback) int32 {
1229 return 0
1230}
1231
1232// Cancel is a no-op for static content.
1233func (rh *staticResourceHandler) Cancel() {}
1234
1235// ---------------------------------------------------------------------------
1236// Request body reader
1237// ---------------------------------------------------------------------------
1238
1239// readBodyFromHeader extracts the message body from the X-Dumber-Body header.
1240// The JS bridge base64-encodes the JSON body into this header to avoid the
1241// purego-cef PostData element wrapping limitation (wrapPostDataElement is unexported).
1242func readBodyFromHeader(request purecef.Request) []byte {
1243 encoded := request.GetHeaderByName(dumberBodyHeaderName)
1244 if encoded == "" {
1245 return nil
1246 }
1247 body, err := base64.StdEncoding.DecodeString(encoded)
1248 if err != nil {
1249 return nil
1250 }
1251 return body
1252}