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