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