v0.31.0 bf0756b

scheme_handler.go RawHistoryFolder

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

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 8

Tags 42

↑↓ moveenter openesc close