v0.30.0 eb3068f

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

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