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