v0.29.0 21b3150

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

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