1package webkit
2
3import (
4 "bytes"
5 "context"
6 "embed"
7 "fmt"
8 "io"
9 "io/fs"
10 "net/http"
11 "net/url"
12 "path"
13 "strconv"
14 "strings"
15 "sync"
16
17 "github.com/andybalholm/brotli"
18 "github.com/bnema/dumber/internal/application/port"
19 "github.com/bnema/dumber/internal/infrastructure/webutil"
20 "github.com/bnema/dumber/internal/logging"
21 "github.com/bnema/puregotk/v4/gio"
22 "github.com/bnema/puregotk/v4/soup"
23 "github.com/bnema/puregotk/v4/webkit"
24 "github.com/rs/zerolog"
25)
26
27// Scheme path constants
28const (
29 HistoryPath = "history"
30 FavoritesPath = "favorites"
31 ConfigPath = "config"
32 ErrorPath = "error"
33 CrashPath = "crash"
34 IndexHTML = "index.html"
35 httpGET = "GET"
36 maxSystemviewsWASMBytes = 64 * 1024 * 1024
37 systemviewsAssetDir = "systemviews"
38)
39
40// SchemeRequest represents a request to a custom URI scheme.
41type SchemeRequest struct {
42 inner *webkit.URISchemeRequest
43 URI string
44 Path string
45 Method string
46 Scheme string
47 Origin string
48 Referer string
49}
50
51// SchemeResponse represents a response to a scheme request.
52type SchemeResponse struct {
53 Data []byte
54 ContentType string
55 StatusCode int
56 Headers map[string]string
57 SuppressDefaultHeaders bool
58}
59
60// PageHandler generates content for a specific page path.
61type PageHandler interface {
62 Handle(req *SchemeRequest) *SchemeResponse
63}
64
65// PageHandlerFunc is an adapter to allow use of ordinary functions as PageHandlers.
66type PageHandlerFunc func(req *SchemeRequest) *SchemeResponse
67
68func (f PageHandlerFunc) Handle(req *SchemeRequest) *SchemeResponse {
69 return f(req)
70}
71
72// DumbSchemeHandler handles dumb:// URI scheme requests.
73type DumbSchemeHandler struct {
74 ctx context.Context
75 handlers map[string]PageHandler
76 assets embed.FS
77 faviconResolver port.FaviconSystemviewResolver
78 assetDir string // default subdirectory within embed.FS (e.g., "systemviews")
79 logger zerolog.Logger
80 mu sync.RWMutex
81 currentConfigPayload func() ([]byte, error)
82 defaultConfigPayload func() ([]byte, error)
83}
84
85// NewDumbSchemeHandler creates a new handler for the dumb:// scheme.
86func NewDumbSchemeHandler(ctx context.Context) *DumbSchemeHandler {
87 log := logging.FromContext(ctx)
88
89 h := &DumbSchemeHandler{
90 ctx: ctx,
91 handlers: make(map[string]PageHandler),
92 assetDir: systemviewsAssetDir,
93 logger: log.With().Str("component", "scheme-handler").Logger(),
94 }
95
96 // Register default pages
97 h.registerDefaults()
98
99 return h
100}
101
102// SetAssets sets the embedded filesystem containing systemviews assets.
103func (h *DumbSchemeHandler) SetAssets(assets embed.FS) {
104 h.mu.Lock()
105 defer h.mu.Unlock()
106 h.assets = assets
107 h.logger.Debug().Msg("assets filesystem configured")
108}
109
110// SetConfigPayloadBuilders wires the config payload builders used by /api/config.
111func (h *DumbSchemeHandler) SetConfigPayloadBuilders(current, defaultPayload func() ([]byte, error)) {
112 h.mu.Lock()
113 defer h.mu.Unlock()
114 h.currentConfigPayload = current
115 h.defaultConfigPayload = defaultPayload
116 h.logger.Debug().Msg("config payload builders configured")
117}
118
119func (h *DumbSchemeHandler) SetFaviconResolver(resolver port.FaviconSystemviewResolver) {
120 h.mu.Lock()
121 defer h.mu.Unlock()
122 h.faviconResolver = resolver
123}
124
125// registerDefaults sets up default page handlers.
126func (h *DumbSchemeHandler) registerDefaults() {
127 // Error page (static fallback)
128 h.RegisterPage("/error", PageHandlerFunc(func(_ *SchemeRequest) *SchemeResponse {
129 return &SchemeResponse{
130 Data: []byte(errorPageHTML),
131 ContentType: "text/html",
132 StatusCode: http.StatusOK,
133 }
134 }))
135
136 // Crash page (web process termination fallback)
137 h.RegisterPage("/"+CrashPath, PageHandlerFunc(func(req *SchemeRequest) *SchemeResponse {
138 if req.Method != "" && req.Method != httpGET {
139 return nil
140 }
141 originalURI := sanitizeCrashPageOriginalURI(crashOriginalURI(req.URI))
142 return &SchemeResponse{
143 Data: []byte(buildCrashPageHTML(originalURI)),
144 ContentType: "text/html; charset=utf-8",
145 StatusCode: http.StatusOK,
146 }
147 }))
148
149 // API: Get current config (used by dumb://config)
150 h.RegisterPage("/api/config", PageHandlerFunc(func(req *SchemeRequest) *SchemeResponse {
151 if req.Method != "" && req.Method != httpGET {
152 return nil
153 }
154 if !isTrustedSystemviewAPIRequest(req) {
155 return privateJSONErrorResponse(http.StatusForbidden, "forbidden")
156 }
157
158 h.mu.RLock()
159 build := h.currentConfigPayload
160 h.mu.RUnlock()
161 return buildConfigResponse(build)
162 }))
163
164 // API: Get default config (used by Reset Defaults in dumb://config)
165 h.RegisterPage("/api/config/default", PageHandlerFunc(func(req *SchemeRequest) *SchemeResponse {
166 if req.Method != "" && req.Method != httpGET {
167 return nil
168 }
169 if !isTrustedSystemviewAPIRequest(req) {
170 return privateJSONErrorResponse(http.StatusForbidden, "forbidden")
171 }
172
173 h.mu.RLock()
174 build := h.defaultConfigPayload
175 h.mu.RUnlock()
176 return buildConfigResponse(build)
177 }))
178
179 h.RegisterPage("/api/favicon", PageHandlerFunc(func(req *SchemeRequest) *SchemeResponse {
180 if req.Method != "" && req.Method != httpGET {
181 return nil
182 }
183 return h.handleFaviconAPI(req)
184 }))
185}
186
187func crashOriginalURI(requestURI string) string {
188 if requestURI == "" {
189 return ""
190 }
191 parsed, err := url.Parse(requestURI)
192 if err != nil {
193 return ""
194 }
195 return strings.TrimSpace(parsed.Query().Get("url"))
196}
197
198func sanitizeCrashPageOriginalURI(originalURI string) string {
199 return webutil.SanitizeCrashPageOriginalURI(originalURI)
200}
201
202func buildCrashPageHTML(originalURI string) string {
203 return webutil.BuildCrashPageHTML(originalURI)
204}
205
206const systemviewFaviconSize = 32
207
208func (h *DumbSchemeHandler) handleFaviconAPI(req *SchemeRequest) *SchemeResponse {
209 if !isTrustedSystemviewFaviconRequest(req) {
210 return privateJSONErrorResponse(http.StatusForbidden, "forbidden")
211 }
212 h.mu.RLock()
213 resolver := h.faviconResolver
214 h.mu.RUnlock()
215 if resolver == nil {
216 return privateJSONErrorResponse(http.StatusNotFound, "favicon unavailable")
217 }
218 if req == nil {
219 return privateJSONErrorResponse(http.StatusBadRequest, "invalid request")
220 }
221 parsed, err := url.Parse(req.URI)
222 if err != nil {
223 return privateJSONErrorResponse(http.StatusBadRequest, "invalid request URL")
224 }
225 domain := strings.TrimSpace(parsed.Query().Get("domain"))
226 if domain == "" {
227 return privateJSONErrorResponse(http.StatusBadRequest, "missing domain")
228 }
229 size := systemviewFaviconSize
230 if rawSize := strings.TrimSpace(parsed.Query().Get("size")); rawSize != "" {
231 parsedSize, parseErr := strconv.Atoi(rawSize)
232 if parseErr != nil || parsedSize != systemviewFaviconSize {
233 return privateJSONErrorResponse(http.StatusBadRequest, "unsupported favicon size")
234 }
235 size = parsedSize
236 }
237
238 resolveCtx := h.ctx
239 if resolveCtx == nil {
240 resolveCtx = context.Background()
241 }
242 resolved, err := resolver.ResolveSystemviewIcon(resolveCtx, domain, size)
243 if err != nil || resolved == nil || len(resolved.Bytes) == 0 {
244 return privateJSONErrorResponse(http.StatusNotFound, "favicon not cached")
245 }
246 contentType := resolved.ContentType
247 if contentType == "" {
248 contentType = "image/png"
249 }
250 return &SchemeResponse{
251 Data: resolved.Bytes,
252 ContentType: contentType,
253 StatusCode: http.StatusOK,
254 Headers: map[string]string{"Cache-Control": "no-store"},
255 SuppressDefaultHeaders: true,
256 }
257}
258
259func isTrustedSystemviewFaviconRequest(req *SchemeRequest) bool {
260 return isTrustedSystemviewAPIRequest(req)
261}
262
263func isTrustedSystemviewAPIRequest(req *SchemeRequest) bool {
264 if req == nil {
265 return false
266 }
267 origin := strings.TrimSpace(req.Origin)
268 if origin != "" && !strings.EqualFold(origin, "null") {
269 return isTrustedSystemviewURL(origin)
270 }
271 return isTrustedSystemviewURL(req.Referer)
272}
273
274func isTrustedSystemviewURL(raw string) bool {
275 parsed, err := url.Parse(strings.TrimSpace(raw))
276 if err != nil || parsed.Scheme == "" {
277 return false
278 }
279 if !strings.EqualFold(parsed.Scheme, "dumb") {
280 return false
281 }
282 host := parsed.Host
283 if host == "" {
284 host = parsed.Opaque
285 }
286 if idx := strings.IndexAny(host, "/?#"); idx >= 0 {
287 host = host[:idx]
288 }
289 switch host {
290 case HistoryPath, FavoritesPath, ConfigPath, ErrorPath, CrashPath:
291 return true
292 default:
293 return false
294 }
295}
296
297func jsonErrorResponse(status int, message string) *SchemeResponse {
298 return &SchemeResponse{
299 Data: fmt.Appendf(nil, `{"error":%q}`, message),
300 ContentType: "application/json",
301 StatusCode: status,
302 }
303}
304
305func privateJSONErrorResponse(status int, message string) *SchemeResponse {
306 resp := jsonErrorResponse(status, message)
307 resp.Headers = map[string]string{"Cache-Control": "no-store"}
308 resp.SuppressDefaultHeaders = true
309 return resp
310}
311
312func trustedPrivateAPIHeaders() map[string]string {
313 return map[string]string{
314 "Access-Control-Allow-Origin": "*",
315 "Access-Control-Allow-Methods": "GET, OPTIONS",
316 "Access-Control-Allow-Headers": "Content-Type",
317 "Cache-Control": "no-store",
318 }
319}
320
321func buildConfigResponse(build func() ([]byte, error)) *SchemeResponse {
322 if build == nil {
323 resp := privateJSONErrorResponse(http.StatusInternalServerError, "config payload builder not configured")
324 resp.Headers = trustedPrivateAPIHeaders()
325 return resp
326 }
327
328 data, err := build()
329 if err != nil {
330 resp := privateJSONErrorResponse(http.StatusInternalServerError, err.Error())
331 resp.Headers = trustedPrivateAPIHeaders()
332 return resp
333 }
334
335 return &SchemeResponse{
336 Data: data,
337 ContentType: "application/json",
338 StatusCode: http.StatusOK,
339 Headers: trustedPrivateAPIHeaders(),
340 SuppressDefaultHeaders: true,
341 }
342}
343
344// RegisterPage registers a handler for a specific path.
345func (h *DumbSchemeHandler) RegisterPage(pagePath string, handler PageHandler) {
346 h.mu.Lock()
347 defer h.mu.Unlock()
348 h.handlers[pagePath] = handler
349 h.logger.Debug().Str("path", pagePath).Msg("registered page handler")
350}
351
352// HandleRequest processes a scheme request and sends the response.
353func (h *DumbSchemeHandler) HandleRequest(reqPtr uintptr) {
354 req := webkit.URISchemeRequestNewFromInternalPtr(reqPtr)
355 if req == nil {
356 return
357 }
358
359 uri := req.GetUri()
360 requestHeaders := req.GetHttpHeaders()
361 schemeReq := &SchemeRequest{
362 inner: req,
363 URI: uri,
364 Path: req.GetPath(),
365 Method: req.GetHttpMethod(),
366 Scheme: req.GetScheme(),
367 }
368 if requestHeaders != nil {
369 schemeReq.Origin = strings.TrimSpace(requestHeaders.GetOne("Origin"))
370 schemeReq.Referer = strings.TrimSpace(requestHeaders.GetOne("Referer"))
371 }
372
373 h.logger.Debug().
374 Str("uri", schemeReq.URI).
375 Str("path", schemeReq.Path).
376 Str("method", schemeReq.Method).
377 Msg("handling scheme request")
378
379 // Parse the URI to extract host and path
380 u, err := url.Parse(uri)
381 if err != nil || u.Scheme != "dumb" {
382 h.logger.Error().Err(err).Str("uri", uri).Msg("invalid URI")
383 h.sendResponse(req, &SchemeResponse{
384 Data: []byte("Invalid URI"),
385 ContentType: "text/plain",
386 StatusCode: http.StatusBadRequest,
387 })
388 return
389 }
390
391 // API endpoints should never be treated as static assets
392 if strings.HasPrefix(schemeReq.Path, "/api/") {
393 h.mu.RLock()
394 handler, ok := h.handlers[schemeReq.Path]
395 if !ok {
396 handler, ok = h.handlers[strings.TrimPrefix(schemeReq.Path, "/")]
397 }
398 h.mu.RUnlock()
399 if ok {
400 response := handler.Handle(schemeReq)
401 h.sendResponse(req, response)
402 return
403 }
404 }
405
406 // Try to serve from embedded assets
407 if response := h.handleAsset(u); response != nil {
408 h.sendResponse(req, response)
409 return
410 }
411
412 // Fall back to registered handlers
413 h.mu.RLock()
414 handler, ok := h.handlers[schemeReq.Path]
415 if !ok {
416 handler, ok = h.handlers[strings.TrimPrefix(schemeReq.Path, "/")]
417 }
418 h.mu.RUnlock()
419
420 var response *SchemeResponse
421 if ok {
422 response = handler.Handle(schemeReq)
423 } else {
424 response = &SchemeResponse{
425 Data: []byte(notFoundHTML),
426 ContentType: "text/html",
427 StatusCode: http.StatusNotFound,
428 }
429 }
430
431 h.sendResponse(req, response)
432}
433
434// handleAsset serves static assets from the embedded filesystem.
435// Returns nil if no asset was found (allowing fallback to registered handlers).
436func (h *DumbSchemeHandler) handleAsset(u *url.URL) *SchemeResponse {
437 h.mu.RLock()
438 hasAssets := h.assets != (embed.FS{})
439 h.mu.RUnlock()
440
441 if !hasAssets {
442 return nil
443 }
444
445 assetDir, relPath, ok := resolveAssetPath(u)
446 if !ok {
447 return nil
448 }
449 if assetDir == "" {
450 assetDir = h.assetDir
451 }
452
453 fullPath, relPath, ok := safeSystemviewsAssetPath(assetDir, relPath)
454 if !ok {
455 return nil
456 }
457
458 data, err := readAssetWithEncoding(h.assets, fullPath, relPath)
459 if err != nil {
460 h.logger.Debug().Str("path", fullPath).Err(err).Msg("asset not found")
461 return nil
462 }
463
464 contentType := webutil.GetMimeType(relPath)
465 h.logger.Debug().
466 Str("path", fullPath).
467 Str("content_type", contentType).
468 Int("size", len(data)).
469 Msg("serving asset")
470
471 return &SchemeResponse{
472 Data: data,
473 ContentType: contentType,
474 StatusCode: http.StatusOK,
475 Headers: nil,
476 }
477}
478
479func safeSystemviewsAssetPath(assetDir, relPath string) (fullPath, cleanRelPath string, ok bool) {
480 assetDir = strings.Trim(assetDir, "/")
481 if assetDir != systemviewsAssetDir {
482 return "", "", false
483 }
484
485 relPath = strings.TrimLeft(relPath, "/")
486 if relPath == "" || strings.ContainsRune(relPath, '\x00') {
487 return "", "", false
488 }
489
490 cleanRelPath = path.Clean(relPath)
491 if cleanRelPath == "." || cleanRelPath == ".." || strings.HasPrefix(cleanRelPath, "../") || path.IsAbs(cleanRelPath) {
492 return "", "", false
493 }
494
495 fullPath = path.Join(assetDir, cleanRelPath)
496 if fullPath != assetDir && !strings.HasPrefix(fullPath, assetDir+"/") {
497 return "", "", false
498 }
499 return fullPath, cleanRelPath, true
500}
501
502func readAssetWithEncoding(assets fs.FS, fullPath, relPath string) ([]byte, error) {
503 var compressedErr error
504 if strings.HasSuffix(relPath, ".wasm") {
505 if compressed, err := fs.ReadFile(assets, fullPath+".br"); err == nil {
506 data, err := io.ReadAll(io.LimitReader(brotli.NewReader(bytes.NewReader(compressed)), maxSystemviewsWASMBytes+1))
507 if err != nil {
508 compressedErr = err
509 } else if len(data) > maxSystemviewsWASMBytes {
510 compressedErr = fmt.Errorf("decompressed asset %s exceeds %d bytes", fullPath, maxSystemviewsWASMBytes)
511 } else {
512 return data, nil
513 }
514 }
515 }
516 data, err := fs.ReadFile(assets, fullPath)
517 if err == nil {
518 return data, nil
519 }
520 if compressedErr != nil {
521 return nil, compressedErr
522 }
523 return nil, err
524}
525
526func resolveAssetPath(u *url.URL) (assetDir, relPath string, ok bool) {
527 if u == nil {
528 return "", "", false
529 }
530
531 rootByHost := map[string]struct {
532 assetDir string
533 file string
534 }{
535 HistoryPath: {assetDir: systemviewsAssetDir, file: IndexHTML},
536 FavoritesPath: {assetDir: systemviewsAssetDir, file: IndexHTML},
537 ConfigPath: {assetDir: systemviewsAssetDir, file: IndexHTML},
538 ErrorPath: {assetDir: systemviewsAssetDir, file: IndexHTML},
539 CrashPath: {assetDir: systemviewsAssetDir, file: IndexHTML},
540 }
541
542 if root, ok := rootByHost[u.Host]; ok {
543 assetPath := strings.TrimPrefix(u.Path, "/")
544 if assetPath == "" {
545 return root.assetDir, root.file, true
546 }
547 return root.assetDir, assetPath, true
548 }
549
550 switch u.Opaque {
551 case HistoryPath, FavoritesPath, ConfigPath, ErrorPath, CrashPath:
552 return systemviewsAssetDir, IndexHTML, true
553 default:
554 return "", "", false
555 }
556}
557
558func shouldAddCORSHeaders(requestPath string) bool {
559 requestPath = strings.TrimSpace(requestPath)
560 return strings.HasSuffix(requestPath, ".wasm")
561}
562
563func responseHeadersForPath(requestPath, contentType string) map[string]string {
564 if !shouldAddCORSHeaders(requestPath) {
565 return nil
566 }
567
568 headers := map[string]string{
569 "Access-Control-Allow-Origin": "*",
570 "Access-Control-Allow-Methods": "GET, POST, OPTIONS",
571 "Access-Control-Allow-Headers": "Content-Type",
572 "Access-Control-Max-Age": "86400",
573 }
574 if contentType != "" {
575 headers["Content-Type"] = contentType
576 }
577 return headers
578}
579
580// sendResponse sends the response back to WebKit.
581func (h *DumbSchemeHandler) sendResponse(req *webkit.URISchemeRequest, response *SchemeResponse) {
582 if response == nil {
583 response = &SchemeResponse{
584 Data: []byte("Internal error"),
585 ContentType: "text/plain",
586 StatusCode: http.StatusInternalServerError,
587 }
588 }
589
590 contentType := response.ContentType
591 if contentType == "" {
592 contentType = "text/html"
593 }
594
595 // Create MemoryInputStream from data directly
596 stream := gio.NewMemoryInputStreamFromData(response.Data, len(response.Data), nil)
597 if stream == nil {
598 h.logger.Error().Msg("failed to create MemoryInputStream for response")
599 return
600 }
601
602 // Create response object for more control
603 schemeResp := webkit.NewURISchemeResponse(&stream.InputStream, int64(len(response.Data)))
604 if schemeResp == nil {
605 h.logger.Error().Msg("failed to create URISchemeResponse")
606 return
607 }
608 schemeResp.SetContentType(contentType)
609 schemeResp.SetStatus(uint(response.StatusCode), nil)
610
611 // WebKit can treat custom schemes as CORS-relevant for the wasm runtime asset.
612 // Private systemview APIs opt out with SuppressDefaultHeaders and validate callers.
613 var headers map[string]string
614 if !response.SuppressDefaultHeaders {
615 headers = responseHeadersForPath(req.GetPath(), contentType)
616 }
617 for name, value := range response.Headers {
618 if headers == nil {
619 headers = map[string]string{}
620 }
621 headers[name] = value
622 }
623 if len(headers) > 0 {
624 hdrs := soup.NewMessageHeaders(soup.MessageHeadersResponseValue)
625 for name, value := range headers {
626 hdrs.Append(name, value)
627 }
628 schemeResp.SetHttpHeaders(hdrs)
629 }
630
631 req.FinishWithResponse(schemeResp)
632}
633
634// RegisterWithContext registers the dumb:// scheme with a WebKitContext.
635// The scheme is always registered on the default WebContext to ensure WebViews
636// (which use the default WebContext) can load dumb:// URLs.
637func (h *DumbSchemeHandler) RegisterWithContext(wkCtx *WebKitContext) {
638 if wkCtx == nil || wkCtx.Context() == nil {
639 h.logger.Error().Msg("cannot register scheme: context is nil")
640 return
641 }
642
643 callback := webkit.URISchemeRequestCallback(func(reqPtr, _ uintptr) {
644 h.HandleRequest(reqPtr)
645 })
646
647 // Always register on the default WebContext since WebViews use it
648 defaultCtx := webkit.WebContextGetDefault()
649 if defaultCtx != nil {
650 defaultCtx.RegisterUriScheme("dumb", &callback, 0, nil)
651
652 // Mark scheme as local, secure, and CORS-enabled for proper security policies
653 if secMgr := defaultCtx.GetSecurityManager(); secMgr != nil {
654 secMgr.RegisterUriSchemeAsLocal("dumb")
655 secMgr.RegisterUriSchemeAsSecure("dumb")
656 secMgr.RegisterUriSchemeAsCorsEnabled("dumb")
657 }
658 h.logger.Debug().Msg("dumb:// scheme registered on default WebContext")
659 }
660
661 // Also register on custom WebContext if different from default
662 customCtx := wkCtx.Context()
663 if customCtx != nil && customCtx.GoPointer() != 0 && (defaultCtx == nil || customCtx.GoPointer() != defaultCtx.GoPointer()) {
664 wkCtx.Context().RegisterUriScheme("dumb", &callback, 0, nil)
665
666 if secMgr := customCtx.GetSecurityManager(); secMgr != nil {
667 secMgr.RegisterUriSchemeAsLocal("dumb")
668 secMgr.RegisterUriSchemeAsSecure("dumb")
669 secMgr.RegisterUriSchemeAsCorsEnabled("dumb")
670 }
671 h.logger.Debug().Msg("dumb:// scheme also registered on custom WebContext")
672 }
673
674 h.logger.Info().Msg("dumb:// scheme registered")
675}
676
677// Default page templates (fallback when assets not available)
678
679const errorPageHTML = `<!DOCTYPE html>
680<html>
681<head>
682 <meta charset="utf-8">
683 <title>Error</title>
684 <style>
685 body {
686 font-family: system-ui, -apple-system, sans-serif;
687 background: #1a1a2e;
688 color: #eee;
689 display: flex;
690 align-items: center;
691 justify-content: center;
692 height: 100vh;
693 margin: 0;
694 }
695 .container {
696 text-align: center;
697 }
698 h1 { color: #e74c3c; }
699 p { color: #888; }
700 </style>
701</head>
702<body>
703 <div class="container">
704 <h1>Error</h1>
705 <p>The page could not be loaded.</p>
706 </div>
707</body>
708</html>`
709
710const notFoundHTML = `<!DOCTYPE html>
711<html>
712<head>
713 <meta charset="utf-8">
714 <title>Not Found</title>
715 <style>
716 body {
717 font-family: system-ui, -apple-system, sans-serif;
718 background: #1a1a2e;
719 color: #eee;
720 display: flex;
721 align-items: center;
722 justify-content: center;
723 height: 100vh;
724 margin: 0;
725 }
726 .container {
727 text-align: center;
728 }
729 h1 { color: #f39c12; }
730 p { color: #888; }
731 </style>
732</head>
733<body>
734 <div class="container">
735 <h1>404</h1>
736 <p>Page not found.</p>
737 </div>
738</body>
739</html>`