diff --git a/gno.land/cmd/gnoweb/main.go b/gno.land/cmd/gnoweb/main.go index 6500e44fcc4..8c0df00aa35 100644 --- a/gno.land/cmd/gnoweb/main.go +++ b/gno.land/cmd/gnoweb/main.go @@ -144,7 +144,6 @@ func setupWeb(cfg *webCfg, _ []string, io commands.IO) (func() error, error) { if cfg.verbose { level = zapcore.DebugLevel } - var zapLogger *zap.Logger if cfg.json { zapLogger = log.NewZapJSONLogger(io.Out(), level) @@ -155,23 +154,24 @@ func setupWeb(cfg *webCfg, _ []string, io commands.IO) (func() error, error) { logger := log.ZapLoggerToSlog(zapLogger) + // Setup app appcfg := gnoweb.NewDefaultAppConfig() appcfg.ChainID = cfg.chainid appcfg.NodeRemote = cfg.remote appcfg.RemoteHelp = cfg.remoteHelp + if appcfg.RemoteHelp == "" { + appcfg.RemoteHelp = appcfg.NodeRemote + } appcfg.Analytics = cfg.analytics appcfg.UnsafeHTML = cfg.html appcfg.FaucetURL = cfg.faucetURL appcfg.AssetsDir = cfg.assetsDir - if appcfg.RemoteHelp == "" { - appcfg.RemoteHelp = appcfg.NodeRemote - } - app, err := gnoweb.NewRouter(logger, appcfg) if err != nil { return nil, fmt.Errorf("unable to start gnoweb app: %w", err) } + // Resolve binding address bindaddr, err := net.ResolveTCPAddr("tcp", cfg.bind) if err != nil { return nil, fmt.Errorf("unable to resolve listener %q: %w", cfg.bind, err) @@ -179,6 +179,7 @@ func setupWeb(cfg *webCfg, _ []string, io commands.IO) (func() error, error) { logger.Info("Running", "listener", bindaddr.String()) + // Setup server server := &http.Server{ Handler: app, Addr: bindaddr.String(), @@ -187,10 +188,9 @@ func setupWeb(cfg *webCfg, _ []string, io commands.IO) (func() error, error) { return func() error { if err := server.ListenAndServe(); err != nil { - logger.Error("HTTP server stopped", " error:", err) + logger.Error("HTTP server stopped", "error", err) return commands.ExitCodeError(1) } - return nil }, nil } diff --git a/gno.land/pkg/gnoweb/Makefile b/gno.land/pkg/gnoweb/Makefile index 61397fef54f..b1ec8f6f20b 100644 --- a/gno.land/pkg/gnoweb/Makefile +++ b/gno.land/pkg/gnoweb/Makefile @@ -38,6 +38,9 @@ cache_dir := .cache # Install dependencies all: generate +test: + go test -v ./... + # Generate process generate: css ts static diff --git a/gno.land/pkg/gnoweb/app.go b/gno.land/pkg/gnoweb/app.go index dc13253468e..502311784e8 100644 --- a/gno.land/pkg/gnoweb/app.go +++ b/gno.land/pkg/gnoweb/app.go @@ -31,10 +31,12 @@ type AppConfig struct { ChainID string // AssetsPath is the base path to the gnoweb assets. AssetsPath string - // AssetDir, if set, will be used for assets instead of the embedded public directory + // AssetDir, if set, will be used for assets instead of the embedded public directory. AssetsDir string // FaucetURL, if specified, will be the URL to which `/faucet` redirects. FaucetURL string + // Domain is the domain used by the node. + Domain string } // NewDefaultAppConfig returns a new default [AppConfig]. The default sets @@ -42,17 +44,16 @@ type AppConfig struct { // to be served on /public/. func NewDefaultAppConfig() *AppConfig { const defaultRemote = "127.0.0.1:26657" - return &AppConfig{ - // same as Remote by default NodeRemote: defaultRemote, RemoteHelp: defaultRemote, ChainID: "dev", AssetsPath: "/public/", + Domain: "gno.land", } } -var chromaStyle = mustGetStyle("friendly") +var chromaDefaultStyle = mustGetStyle("friendly") func mustGetStyle(name string) *chroma.Style { s := styles.Get(name) @@ -62,15 +63,24 @@ func mustGetStyle(name string) *chroma.Style { return s } -// NewRouter initializes the gnoweb router, with the given logger and config. +// NewRouter initializes the gnoweb router with the specified logger and configuration. func NewRouter(logger *slog.Logger, cfg *AppConfig) (http.Handler, error) { + // Initialize RPC Client + client, err := client.NewHTTPClient(cfg.NodeRemote) + if err != nil { + return nil, fmt.Errorf("unable to create HTTP client: %w", err) + } + + // Configure Chroma highlighter chromaOptions := []chromahtml.Option{ chromahtml.WithLineNumbers(true), chromahtml.WithLinkableLineNumbers(true, "L"), chromahtml.WithClasses(true), chromahtml.ClassPrefix("chroma-"), } + chroma := chromahtml.New(chromaOptions...) + // Configure Goldmark markdown parser mdopts := []goldmark.Option{ goldmark.WithExtensions( markdown.NewHighlighting( @@ -81,36 +91,41 @@ func NewRouter(logger *slog.Logger, cfg *AppConfig) (http.Handler, error) { if cfg.UnsafeHTML { mdopts = append(mdopts, goldmark.WithRendererOptions(mdhtml.WithXHTML(), mdhtml.WithUnsafe())) } - md := goldmark.New(mdopts...) - client, err := client.NewHTTPClient(cfg.NodeRemote) - if err != nil { - return nil, fmt.Errorf("unable to create http client: %w", err) + // Configure WebClient + webcfg := HTMLWebClientConfig{ + Markdown: md, + Highlighter: NewChromaSourceHighlighter(chroma, chromaDefaultStyle), + Domain: cfg.Domain, + UnsafeHTML: cfg.UnsafeHTML, + RPCClient: client, } - webcli := NewWebClient(logger, client, md) - formatter := chromahtml.New(chromaOptions...) + webcli := NewHTMLClient(logger, &webcfg) chromaStylePath := path.Join(cfg.AssetsPath, "_chroma", "style.css") - var webConfig WebHandlerConfig - - webConfig.RenderClient = webcli - webConfig.Formatter = newFormatterWithStyle(formatter, chromaStyle) - - // Static meta - webConfig.Meta.AssetsPath = cfg.AssetsPath - webConfig.Meta.ChromaPath = chromaStylePath - webConfig.Meta.RemoteHelp = cfg.RemoteHelp - webConfig.Meta.ChainId = cfg.ChainID - webConfig.Meta.Analytics = cfg.Analytics + // Setup StaticMetadata + staticMeta := StaticMetadata{ + Domain: cfg.Domain, + AssetsPath: cfg.AssetsPath, + ChromaPath: chromaStylePath, + RemoteHelp: cfg.RemoteHelp, + ChainId: cfg.ChainID, + Analytics: cfg.Analytics, + } - // Setup main handler - webhandler := NewWebHandler(logger, webConfig) + // Configure WebHandler + webConfig := WebHandlerConfig{WebClient: webcli, Meta: staticMeta} + webhandler, err := NewWebHandler(logger, webConfig) + if err != nil { + return nil, fmt.Errorf("unable to create web handler: %w", err) + } + // Setup HTTP muxer mux := http.NewServeMux() - // Setup Webahndler along Alias Middleware + // Handle web handler with alias middleware mux.Handle("/", AliasAndRedirectMiddleware(webhandler, cfg.Analytics)) // Register faucet URL to `/faucet` if specified @@ -124,22 +139,20 @@ func NewRouter(logger *slog.Logger, cfg *AppConfig) (http.Handler, error) { })) } - // setup assets + // Handle Chroma CSS requests + // XXX: probably move this mux.Handle(chromaStylePath, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - // Setup Formatter w.Header().Set("Content-Type", "text/css") - if err := formatter.WriteCSS(w, chromaStyle); err != nil { - logger.Error("unable to write css", "err", err) + if err := chroma.WriteCSS(w, chromaDefaultStyle); err != nil { + logger.Error("unable to write CSS", "err", err) http.NotFound(w, r) } })) - // Normalize assets path - assetsBase := "/" + strings.Trim(cfg.AssetsPath, "/") + "/" - // Handle assets path + assetsBase := "/" + strings.Trim(cfg.AssetsPath, "/") + "/" if cfg.AssetsDir != "" { - logger.Debug("using assets dir instead of embed assets", "dir", cfg.AssetsDir) + logger.Debug("using assets dir instead of embedded assets", "dir", cfg.AssetsDir) mux.Handle(assetsBase, DevAssetHandler(assetsBase, cfg.AssetsDir)) } else { mux.Handle(assetsBase, AssetHandler()) diff --git a/gno.land/pkg/gnoweb/app_test.go b/gno.land/pkg/gnoweb/app_test.go index 78fe197a134..2730e8b41ca 100644 --- a/gno.land/pkg/gnoweb/app_test.go +++ b/gno.land/pkg/gnoweb/app_test.go @@ -24,9 +24,9 @@ func TestRoutes(t *testing.T) { status int substring string }{ - {"/", ok, "Welcome"}, // assert / gives 200 (OK). assert / contains "Welcome". + {"/", ok, "Welcome"}, // Check if / returns 200 (OK) and contains "Welcome". {"/about", ok, "blockchain"}, - {"/r/gnoland/blog", ok, ""}, // whatever content + {"/r/gnoland/blog", ok, ""}, // Any content {"/r/gnoland/blog$help", ok, "AdminSetAdminAddr"}, {"/r/gnoland/blog/", ok, "admin.gno"}, {"/r/gnoland/blog/admin.gno", ok, ">func<"}, @@ -47,12 +47,18 @@ func TestRoutes(t *testing.T) { {"/game-of-realms", found, "/contribute"}, {"/gor", found, "/contribute"}, {"/blog", found, "/r/gnoland/blog"}, - {"/404/not/found/", notFound, ""}, + {"/r/not/found/", notFound, ""}, + {"/404/not/found", notFound, ""}, {"/아스키문자가아닌경로", notFound, ""}, {"/%ED%85%8C%EC%8A%A4%ED%8A%B8", notFound, ""}, {"/グノー", notFound, ""}, - {"/⚛️", notFound, ""}, + {"/\u269B\uFE0F", notFound, ""}, // Unicode {"/p/demo/flow/LICENSE", ok, "BSD 3-Clause"}, + // Test assets + {"/public/styles.css", ok, ""}, + {"/public/js/index.js", ok, ""}, + {"/public/_chroma/style.css", ok, ""}, + {"/public/imgs/gnoland.svg", ok, ""}, } rootdir := gnoenv.RootDir() @@ -66,13 +72,13 @@ func TestRoutes(t *testing.T) { logger := log.NewTestingLogger(t) - // set the `remoteAddr` of the client to the listening address of the - // node, which is randomly assigned. + // Initialize the router with the current node's remote address router, err := NewRouter(logger, cfg) require.NoError(t, err) for _, r := range routes { t.Run(fmt.Sprintf("test route %s", r.route), func(t *testing.T) { + t.Logf("input: %q", r.route) request := httptest.NewRequest(http.MethodGet, r.route, nil) response := httptest.NewRecorder() router.ServeHTTP(response, request) @@ -84,24 +90,24 @@ func TestRoutes(t *testing.T) { func TestAnalytics(t *testing.T) { routes := []string{ - // special realms - "/", // home + // Special realms + "/", // Home "/about", "/start", - // redirects + // Redirects "/game-of-realms", "/getting-started", "/blog", "/boards", - // realm, source, help page + // Realm, source, help page "/r/gnoland/blog", "/r/gnoland/blog/admin.gno", "/r/demo/users:administrator", "/r/gnoland/blog$help", - // special pages + // Special pages "/404-not-found", } @@ -124,8 +130,8 @@ func TestAnalytics(t *testing.T) { request := httptest.NewRequest(http.MethodGet, route, nil) response := httptest.NewRecorder() + router.ServeHTTP(response, request) - fmt.Println("HELLO:", response.Body.String()) assert.Contains(t, response.Body.String(), "sa.gno.services") }) } @@ -142,6 +148,7 @@ func TestAnalytics(t *testing.T) { request := httptest.NewRequest(http.MethodGet, route, nil) response := httptest.NewRecorder() + router.ServeHTTP(response, request) assert.NotContains(t, response.Body.String(), "sa.gno.services") }) diff --git a/gno.land/pkg/gnoweb/format.go b/gno.land/pkg/gnoweb/format.go new file mode 100644 index 00000000000..67911bfa985 --- /dev/null +++ b/gno.land/pkg/gnoweb/format.go @@ -0,0 +1,69 @@ +package gnoweb + +import ( + "fmt" + "io" + "path/filepath" + "strings" + + "github.com/alecthomas/chroma/v2" + "github.com/alecthomas/chroma/v2/formatters/html" + "github.com/alecthomas/chroma/v2/lexers" +) + +// FormatSource defines the interface for formatting source code. +type FormatSource interface { + Format(w io.Writer, fileName string, file []byte) error +} + +// ChromaSourceHighlighter implements the Highlighter interface using the Chroma library. +type ChromaSourceHighlighter struct { + *html.Formatter + style *chroma.Style +} + +// NewChromaSourceHighlighter constructs a new ChromaHighlighter with the given formatter and style. +func NewChromaSourceHighlighter(formatter *html.Formatter, style *chroma.Style) FormatSource { + return &ChromaSourceHighlighter{Formatter: formatter, style: style} +} + +// Format applies syntax highlighting to the source code using Chroma. +func (f *ChromaSourceHighlighter) Format(w io.Writer, fileName string, src []byte) error { + var lexer chroma.Lexer + + // Determine the lexer to be used based on the file extension. + switch strings.ToLower(filepath.Ext(fileName)) { + case ".gno": + lexer = lexers.Get("go") + case ".md": + lexer = lexers.Get("markdown") + case ".mod": + lexer = lexers.Get("gomod") + default: + lexer = lexers.Get("txt") // Unsupported file type, default to plain text. + } + + if lexer == nil { + return fmt.Errorf("unsupported lexer for file %q", fileName) + } + + iterator, err := lexer.Tokenise(nil, string(src)) + if err != nil { + return fmt.Errorf("unable to tokenise %q: %w", fileName, err) + } + + if err := f.Formatter.Format(w, f.style, iterator); err != nil { + return fmt.Errorf("unable to format source file %q: %w", fileName, err) + } + + return nil +} + +// noopFormat is a no-operation highlighter that writes the source code as-is. +type noopFormat struct{} + +// Format writes the source code to the writer without any formatting. +func (f *noopFormat) Format(w io.Writer, fileName string, src []byte) error { + _, err := w.Write(src) + return err +} diff --git a/gno.land/pkg/gnoweb/formatter.go b/gno.land/pkg/gnoweb/formatter.go deleted file mode 100644 index e172afe9e21..00000000000 --- a/gno.land/pkg/gnoweb/formatter.go +++ /dev/null @@ -1,25 +0,0 @@ -package gnoweb - -import ( - "io" - - "github.com/alecthomas/chroma/v2" - "github.com/alecthomas/chroma/v2/formatters/html" -) - -type Formatter interface { - Format(w io.Writer, iterator chroma.Iterator) error -} - -type formatterWithStyle struct { - *html.Formatter - style *chroma.Style -} - -func newFormatterWithStyle(formater *html.Formatter, style *chroma.Style) Formatter { - return &formatterWithStyle{Formatter: formater, style: style} -} - -func (f *formatterWithStyle) Format(w io.Writer, iterator chroma.Iterator) error { - return f.Formatter.Format(w, f.style, iterator) -} diff --git a/gno.land/pkg/gnoweb/handler.go b/gno.land/pkg/gnoweb/handler.go index bc87f057e26..7baa8f6364a 100644 --- a/gno.land/pkg/gnoweb/handler.go +++ b/gno.land/pkg/gnoweb/handler.go @@ -13,15 +13,13 @@ import ( "strings" "time" - "github.com/alecthomas/chroma/v2" - "github.com/alecthomas/chroma/v2/lexers" "github.com/gnolang/gno/gno.land/pkg/gnoweb/components" - "github.com/gnolang/gno/gno.land/pkg/sdk/vm" // for error types + "github.com/gnolang/gno/gno.land/pkg/sdk/vm" // For error types ) -const DefaultChainDomain = "gno.land" - +// StaticMetadata holds static configuration for a web handler. type StaticMetadata struct { + Domain string AssetsPath string ChromaPath string RemoteHelp string @@ -29,35 +27,43 @@ type StaticMetadata struct { Analytics bool } +// WebHandlerConfig configures a WebHandler. type WebHandlerConfig struct { - Meta StaticMetadata - RenderClient *WebClient - Formatter Formatter + Meta StaticMetadata + WebClient WebClient } -type WebHandler struct { - formatter Formatter +// validate checks if the WebHandlerConfig is valid. +func (cfg WebHandlerConfig) validate() error { + if cfg.WebClient == nil { + return errors.New("no `WebClient` configured") + } + return nil +} - logger *slog.Logger - static StaticMetadata - webcli *WebClient +// WebHandler processes HTTP requests. +type WebHandler struct { + Logger *slog.Logger + Static StaticMetadata + Client WebClient } -func NewWebHandler(logger *slog.Logger, cfg WebHandlerConfig) *WebHandler { - if cfg.RenderClient == nil { - logger.Error("no renderer has been defined") +// NewWebHandler creates a new WebHandler. +func NewWebHandler(logger *slog.Logger, cfg WebHandlerConfig) (*WebHandler, error) { + if err := cfg.validate(); err != nil { + return nil, fmt.Errorf("config validate error: %w", err) } return &WebHandler{ - formatter: cfg.Formatter, - webcli: cfg.RenderClient, - logger: logger, - static: cfg.Meta, - } + Client: cfg.WebClient, + Static: cfg.Meta, + Logger: logger, + }, nil } +// ServeHTTP handles HTTP requests. func (h *WebHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { - h.logger.Debug("receiving request", "method", r.Method, "path", r.URL.Path) + h.Logger.Debug("receiving request", "method", r.Method, "path", r.URL.Path) if r.Method != http.MethodGet { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) @@ -67,47 +73,29 @@ func (h *WebHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { h.Get(w, r) } +// Get processes a GET HTTP request. func (h *WebHandler) Get(w http.ResponseWriter, r *http.Request) { var body bytes.Buffer start := time.Now() defer func() { - h.logger.Debug("request completed", + h.Logger.Debug("request completed", "url", r.URL.String(), "elapsed", time.Since(start).String()) }() - var indexData components.IndexData - indexData.HeadData.AssetsPath = h.static.AssetsPath - indexData.HeadData.ChromaPath = h.static.ChromaPath - indexData.FooterData.Analytics = h.static.Analytics - indexData.FooterData.AssetsPath = h.static.AssetsPath - - // Render the page body into the buffer - var status int - gnourl, err := ParseGnoURL(r.URL) - if err != nil { - h.logger.Warn("page not found", "path", r.URL.Path, "err", err) - status, err = http.StatusNotFound, components.RenderStatusComponent(&body, "page not found") - } else { - // TODO: real data (title & description) - indexData.HeadData.Title = "gno.land - " + gnourl.Path - - // Header - indexData.HeaderData.RealmPath = gnourl.Path - indexData.HeaderData.Breadcrumb.Parts = generateBreadcrumbPaths(gnourl.Path) - indexData.HeaderData.WebQuery = gnourl.WebQuery - - // Render - switch gnourl.Kind() { - case KindRealm, KindPure: - status, err = h.renderPackage(&body, gnourl) - default: - h.logger.Debug("invalid page kind", "kind", gnourl.Kind) - status, err = http.StatusNotFound, components.RenderStatusComponent(&body, "page not found") - } + indexData := components.IndexData{ + HeadData: components.HeadData{ + AssetsPath: h.Static.AssetsPath, + ChromaPath: h.Static.ChromaPath, + }, + FooterData: components.FooterData{ + Analytics: h.Static.Analytics, + AssetsPath: h.Static.AssetsPath, + }, } + status, err := h.renderPage(&body, r, &indexData) if err != nil { http.Error(w, "internal server error", http.StatusInternalServerError) return @@ -120,193 +108,199 @@ func (h *WebHandler) Get(w http.ResponseWriter, r *http.Request) { // Render the final page with the rendered body if err = components.RenderIndexComponent(w, indexData); err != nil { - h.logger.Error("failed to render index component", "err", err) + h.Logger.Error("failed to render index component", "err", err) } - - return } -func (h *WebHandler) renderPackage(w io.Writer, gnourl *GnoURL) (status int, err error) { - h.logger.Info("component render", "path", gnourl.Path, "args", gnourl.Args) +// renderPage renders the page into the given buffer and prepares the index data. +func (h *WebHandler) renderPage(body *bytes.Buffer, r *http.Request, indexData *components.IndexData) (int, error) { + gnourl, err := ParseGnoURL(r.URL) + if err != nil { + h.Logger.Warn("unable to parse url path", "path", r.URL.Path, "err", err) + return http.StatusNotFound, components.RenderStatusComponent(body, "invalid path") + } - kind := gnourl.Kind() + breadcrumb := components.BreadcrumbData{Parts: generateBreadcrumbPaths(gnourl.Path)} + indexData.HeadData.Title = h.Static.Domain + " - " + gnourl.Path + indexData.HeaderData = components.HeaderData{ + RealmPath: gnourl.Path, + Breadcrumb: breadcrumb, + WebQuery: gnourl.WebQuery, + } + + switch k := gnourl.Kind(); k { + case KindRealm, KindPure: + return h.GetPackagePage(body, gnourl) + default: + h.Logger.Debug("invalid path kind", "kind", k) + return http.StatusBadRequest, components.RenderStatusComponent(body, "invalid path") + } +} + +// GetPackagePage handles package pages. +func (h *WebHandler) GetPackagePage(w io.Writer, gnourl *GnoURL) (int, error) { + h.Logger.Info("component render", "path", gnourl.Path, "args", gnourl.Args) - // Display realm help page? - if kind == KindRealm && gnourl.WebQuery.Has("help") { - return h.renderRealmHelp(w, gnourl) + // Handle Help page + if gnourl.Kind() == KindRealm && gnourl.WebQuery.Has("help") { + return h.GetHelpPage(w, gnourl) } - // Display package source page? + // Handle Source page switch { case gnourl.WebQuery.Has("source"): - return h.renderRealmSource(w, gnourl) - case kind == KindPure, - strings.HasSuffix(gnourl.Path, "/"), - isFile(gnourl.Path): - i := strings.LastIndexByte(gnourl.Path, '/') - if i < 0 { - return http.StatusInternalServerError, fmt.Errorf("unable to get ending slash for %q", gnourl.Path) - } + return h.GetSource(w, gnourl) + case gnourl.Kind() == KindPure, gnourl.IsFile(), gnourl.IsDir(): + return h.handleFilePage(w, gnourl) + } - // Fill webquery with file infos - gnourl.WebQuery.Set("source", "") // set source + // Ultimately render realm content + return h.renderRealmContent(w, gnourl) +} - file := gnourl.Path[i+1:] - if file == "" { - return h.renderRealmDirectory(w, gnourl) - } +// handleFilePage processes pages that involve file handling. +func (h *WebHandler) handleFilePage(w io.Writer, gnourl *GnoURL) (int, error) { + i := strings.LastIndexByte(gnourl.Path, '/') + if i < 0 { + return http.StatusInternalServerError, fmt.Errorf("unable to get ending slash for %q", gnourl.Path) + } - gnourl.WebQuery.Set("file", file) - gnourl.Path = gnourl.Path[:i] + gnourl.WebQuery.Set("source", "") - return h.renderRealmSource(w, gnourl) + file := gnourl.Path[i+1:] + if file == "" { + return h.GetDirectoryPage(w, gnourl) } - // Render content into the content buffer + gnourl.WebQuery.Set("file", file) + gnourl.Path = gnourl.Path[:i] + + return h.GetSource(w, gnourl) +} + +// renderRealmContent renders the content of a realm. +func (h *WebHandler) renderRealmContent(w io.Writer, gnourl *GnoURL) (int, error) { var content bytes.Buffer - meta, err := h.webcli.Render(&content, gnourl.Path, gnourl.EncodeArgs()) + meta, err := h.Client.RenderRealm(&content, gnourl.Path, gnourl.EncodeArgs()) if err != nil { - if errors.Is(err, vm.InvalidPkgPathError{}) { - return http.StatusNotFound, components.RenderStatusComponent(w, "not found") - } - - h.logger.Error("unable to render markdown", "err", err) - return http.StatusInternalServerError, components.RenderStatusComponent(w, "internal error") + h.Logger.Error("unable to render realm", "err", err, "path", gnourl.EncodePath()) + return renderClientErrorStatusPage(w, gnourl, err) } err = components.RenderRealmComponent(w, components.RealmData{ TocItems: &components.RealmTOCData{ - Items: meta.Items, + Items: meta.Toc.Items, }, - // NOTE: `content` should have already been escaped by + // NOTE: `RenderRealm` should ensure that HTML content is + // sanitized before rendering Content: template.HTML(content.String()), //nolint:gosec }) if err != nil { - h.logger.Error("unable to render template", "err", err) + h.Logger.Error("unable to render template", "err", err) return http.StatusInternalServerError, components.RenderStatusComponent(w, "internal error") } - // Write the rendered content to the response writer return http.StatusOK, nil } -func (h *WebHandler) renderRealmHelp(w io.Writer, gnourl *GnoURL) (status int, err error) { - fsigs, err := h.webcli.Functions(gnourl.Path) +// GetHelpPage renders the help page. +func (h *WebHandler) GetHelpPage(w io.Writer, gnourl *GnoURL) (int, error) { + fsigs, err := h.Client.Functions(gnourl.Path) if err != nil { - h.logger.Error("unable to fetch path functions", "err", err) - return http.StatusInternalServerError, components.RenderStatusComponent(w, "internal error") + h.Logger.Error("unable to fetch path functions", "err", err) + return renderClientErrorStatusPage(w, gnourl, err) } - var selArgs map[string]string - var selFn string - if selFn = gnourl.WebQuery.Get("func"); selFn != "" { + selArgs := make(map[string]string) + selFn := gnourl.WebQuery.Get("func") + if selFn != "" { for _, fn := range fsigs { - if selFn != fn.FuncName { - continue + if selFn == fn.FuncName { + for _, param := range fn.Params { + selArgs[param.Name] = gnourl.WebQuery.Get(param.Name) + } + fsigs = []vm.FunctionSignature{fn} + break } - - selArgs = make(map[string]string) - for _, param := range fn.Params { - selArgs[param.Name] = gnourl.WebQuery.Get(param.Name) - } - - fsigs = []vm.FunctionSignature{fn} - break } } - // Catch last name of the path - // XXX: we should probably add a helper within the template realmName := filepath.Base(gnourl.Path) err = components.RenderHelpComponent(w, components.HelpData{ SelectedFunc: selFn, SelectedArgs: selArgs, RealmName: realmName, - ChainId: h.static.ChainId, + ChainId: h.Static.ChainId, // TODO: get chain domain and use that. - PkgPath: filepath.Join(DefaultChainDomain, gnourl.Path), - Remote: h.static.RemoteHelp, + PkgPath: filepath.Join(h.Static.Domain, gnourl.Path), + Remote: h.Static.RemoteHelp, Functions: fsigs, }) if err != nil { - h.logger.Error("unable to render helper", "err", err) + h.Logger.Error("unable to render helper", "err", err) return http.StatusInternalServerError, components.RenderStatusComponent(w, "internal error") } return http.StatusOK, nil } -func (h *WebHandler) renderRealmSource(w io.Writer, gnourl *GnoURL) (status int, err error) { - pkgPath := gnourl.Path +// GetSource renders the source page. +func (h *WebHandler) GetSource(w io.Writer, gnourl *GnoURL) (int, error) { + pkgPath := strings.TrimSuffix(gnourl.Path, "/") - files, err := h.webcli.Sources(pkgPath) + files, err := h.Client.Sources(pkgPath) if err != nil { - h.logger.Error("unable to list sources file", "path", gnourl.Path, "err", err) - return http.StatusInternalServerError, components.RenderStatusComponent(w, "internal error") + h.Logger.Error("unable to list sources file", "path", gnourl.Path, "err", err) + return renderClientErrorStatusPage(w, gnourl, err) } if len(files) == 0 { - h.logger.Debug("no files available", "path", gnourl.Path) + h.Logger.Debug("no files available", "path", gnourl.Path) return http.StatusOK, components.RenderStatusComponent(w, "no files available") } - var fileName string - file := gnourl.WebQuery.Get("file") - if file == "" { + fileName := gnourl.WebQuery.Get("file") + if fileName == "" || !slices.Contains(files, fileName) { fileName = files[0] - } else if slices.Contains(files, file) { - fileName = file - } else { - h.logger.Error("unable to render source", "file", file, "err", "file does not exist") - return http.StatusInternalServerError, components.RenderStatusComponent(w, "internal error") - } - - source, err := h.webcli.SourceFile(pkgPath, fileName) - if err != nil { - h.logger.Error("unable to get source file", "file", fileName, "err", err) - return http.StatusInternalServerError, components.RenderStatusComponent(w, "internal error") } - // XXX: we should either do this on the front or in the markdown parsing side - fileLines := strings.Count(string(source), "\n") - fileSizeKb := float64(len(source)) / 1024.0 - fileSizeStr := fmt.Sprintf("%.2f Kb", fileSizeKb) - - // Highlight code source - hsource, err := h.highlightSource(fileName, source) + var source bytes.Buffer + meta, err := h.Client.SourceFile(&source, pkgPath, fileName) if err != nil { - h.logger.Error("unable to highlight source file", "file", fileName, "err", err) - return http.StatusInternalServerError, components.RenderStatusComponent(w, "internal error") + h.Logger.Error("unable to get source file", "file", fileName, "err", err) + return renderClientErrorStatusPage(w, gnourl, err) } + fileSizeStr := fmt.Sprintf("%.2f Kb", meta.SizeKb) err = components.RenderSourceComponent(w, components.SourceData{ PkgPath: gnourl.Path, Files: files, FileName: fileName, FileCounter: len(files), - FileLines: fileLines, + FileLines: meta.Lines, FileSize: fileSizeStr, - FileSource: template.HTML(hsource), //nolint:gosec + FileSource: template.HTML(source.String()), //nolint:gosec }) if err != nil { - h.logger.Error("unable to render helper", "err", err) + h.Logger.Error("unable to render helper", "err", err) return http.StatusInternalServerError, components.RenderStatusComponent(w, "internal error") } return http.StatusOK, nil } -func (h *WebHandler) renderRealmDirectory(w io.Writer, gnourl *GnoURL) (status int, err error) { - pkgPath := gnourl.Path +// GetDirectoryPage renders the directory page. +func (h *WebHandler) GetDirectoryPage(w io.Writer, gnourl *GnoURL) (int, error) { + pkgPath := strings.TrimSuffix(gnourl.Path, "/") - files, err := h.webcli.Sources(pkgPath) + files, err := h.Client.Sources(pkgPath) if err != nil { - h.logger.Error("unable to list sources file", "path", gnourl.Path, "err", err) - return http.StatusInternalServerError, components.RenderStatusComponent(w, "internal error") + h.Logger.Error("unable to list sources file", "path", gnourl.Path, "err", err) + return renderClientErrorStatusPage(w, gnourl, err) } if len(files) == 0 { - h.logger.Debug("no files available", "path", gnourl.Path) + h.Logger.Debug("no files available", "path", gnourl.Path) return http.StatusOK, components.RenderStatusComponent(w, "no files available") } @@ -316,49 +310,38 @@ func (h *WebHandler) renderRealmDirectory(w io.Writer, gnourl *GnoURL) (status i FileCounter: len(files), }) if err != nil { - h.logger.Error("unable to render directory", "err", err) - return http.StatusInternalServerError, components.RenderStatusComponent(w, "internal error") + h.Logger.Error("unable to render directory", "err", err) + return http.StatusInternalServerError, components.RenderStatusComponent(w, "not found") } return http.StatusOK, nil } -func (h *WebHandler) highlightSource(fileName string, src []byte) ([]byte, error) { - var lexer chroma.Lexer - - switch strings.ToLower(filepath.Ext(fileName)) { - case ".gno": - lexer = lexers.Get("go") - case ".md": - lexer = lexers.Get("markdown") - default: - lexer = lexers.Get("txt") // file kind not supported, fallback on `.txt` - } - - if lexer == nil { - return nil, fmt.Errorf("unsupported lexer for file %q", fileName) +func renderClientErrorStatusPage(w io.Writer, _ *GnoURL, err error) (int, error) { + if err == nil { + return http.StatusOK, nil } - iterator, err := lexer.Tokenise(nil, string(src)) - if err != nil { - h.logger.Error("unable to ", "fileName", fileName, "err", err) - } - - var buff bytes.Buffer - if err := h.formatter.Format(&buff, iterator); err != nil { - return nil, fmt.Errorf("unable to format source file %q: %w", fileName, err) + switch { + case errors.Is(err, ErrClientPathNotFound): + return http.StatusNotFound, components.RenderStatusComponent(w, err.Error()) + case errors.Is(err, ErrClientBadRequest): + return http.StatusInternalServerError, components.RenderStatusComponent(w, "bad request") + case errors.Is(err, ErrClientResponse): + fallthrough // XXX: for now fallback as internal error + default: + return http.StatusInternalServerError, components.RenderStatusComponent(w, "internal error") } - - return buff.Bytes(), nil } +// generateBreadcrumbPaths creates breadcrumb paths from a given path. +// XXX: This should probably be a template helper function. func generateBreadcrumbPaths(path string) []components.BreadcrumbPart { split := strings.Split(path, "/") - parts := []components.BreadcrumbPart{} + parts := make([]components.BreadcrumbPart, 0, len(split)) - var name string - for i := range split { - if name = split[i]; name == "" { + for i, name := range split { + if name == "" { continue } @@ -370,10 +353,3 @@ func generateBreadcrumbPaths(path string) []components.BreadcrumbPart { return parts } - -// IsFile checks if the last element of the path is a file (has an extension) -func isFile(path string) bool { - base := filepath.Base(path) - ext := filepath.Ext(base) - return ext != "" -} diff --git a/gno.land/pkg/gnoweb/handler_test.go b/gno.land/pkg/gnoweb/handler_test.go new file mode 100644 index 00000000000..2b86d41e2a9 --- /dev/null +++ b/gno.land/pkg/gnoweb/handler_test.go @@ -0,0 +1,98 @@ +package gnoweb_test + +import ( + "log/slog" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/gnolang/gno/gno.land/pkg/gnoweb" + "github.com/gnolang/gno/gno.land/pkg/sdk/vm" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type testingLogger struct { + *testing.T +} + +func (t *testingLogger) Write(b []byte) (n int, err error) { + t.T.Log(strings.TrimSpace(string(b))) + return len(b), nil +} + +// TestWebHandler_Get tests the Get method of WebHandler using table-driven tests. +func TestWebHandler_Get(t *testing.T) { + // Set up a mock package with some files and functions + mockPackage := &gnoweb.MockPackage{ + Domain: "example.com", + Path: "/r/mock/path", + Files: map[string]string{ + "render.gno": `package main; func Render(path string) { return "one more time" }`, + }, + Functions: []vm.FunctionSignature{ + {FuncName: "SuperRenderFunction", Params: []vm.NamedType{ + {Name: "my_super_arg", Type: "string"}, + }}, + }, + } + + // Create a mock web client with the mock package + webclient := gnoweb.NewMockWebClient(mockPackage) + + // Create a WebHandlerConfig with the mock web client and static metadata + config := gnoweb.WebHandlerConfig{ + WebClient: webclient, + } + + // Define test cases + cases := []struct { + Path string + Status int + Body string + }{ + // Found + {Path: "/r/mock/path", Status: http.StatusOK, Body: "[example.com]/r/mock/path"}, + {Path: "/r/mock/path/", Status: http.StatusOK, Body: "render.gno"}, + {Path: "/r/mock/path/render.gno", Status: http.StatusOK, Body: "one more time"}, + {Path: "/r/mock/path$source&file=render.gno", Status: http.StatusOK, Body: "one more time"}, + {Path: "/r/mock/path/$source", Status: http.StatusOK, Body: "one more time"}, // `render.gno` by default + {Path: "/r/mock/path$help", Status: http.StatusOK, Body: "SuperRenderFunction"}, + {Path: "/r/mock/path$help", Status: http.StatusOK, Body: "my_super_arg"}, + + // Package not found + {Path: "/r/invalid/path", Status: http.StatusNotFound, Body: "not found"}, + + // Invalid path + {Path: "/r", Status: http.StatusNotFound, Body: "invalid path"}, + {Path: "/r/~!1337", Status: http.StatusNotFound, Body: "invalid path"}, + } + + for _, tc := range cases { + t.Run(strings.TrimPrefix(tc.Path, "/"), func(t *testing.T) { + t.Logf("input: %+v", tc) + + // Initialize testing logger + logger := slog.New(slog.NewTextHandler(&testingLogger{t}, &slog.HandlerOptions{})) + + // Create a new WebHandler + handler, err := gnoweb.NewWebHandler(logger, config) + require.NoError(t, err) + + // Create a new HTTP request for each test case + req, err := http.NewRequest(http.MethodGet, tc.Path, nil) + require.NoError(t, err) + + // Create a ResponseRecorder to capture the response + rr := httptest.NewRecorder() + + // Invoke serve method + handler.ServeHTTP(rr, req) + + // Assert result + assert.Equal(t, tc.Status, rr.Code) + assert.Containsf(t, rr.Body.String(), tc.Body, "rendered body should contain: %q", tc.Body) + }) + } +} diff --git a/gno.land/pkg/gnoweb/markdown/toc.go b/gno.land/pkg/gnoweb/markdown/toc.go index 59d4941fabf..ceafbd7cc96 100644 --- a/gno.land/pkg/gnoweb/markdown/toc.go +++ b/gno.land/pkg/gnoweb/markdown/toc.go @@ -45,7 +45,7 @@ type TocOptions struct { MinDepth, MaxDepth int } -func TocInspect(n ast.Node, src []byte, opts TocOptions) (*Toc, error) { +func TocInspect(n ast.Node, src []byte, opts TocOptions) (Toc, error) { // Appends an empty subitem to the given node // and returns a reference to it. appendChild := func(n *TocItem) *TocItem { @@ -114,7 +114,7 @@ func TocInspect(n ast.Node, src []byte, opts TocOptions) (*Toc, error) { root.Items = compactItems(root.Items) - return &Toc{Items: root.Items}, err + return Toc{Items: root.Items}, err } // compactItems removes items with no titles diff --git a/gno.land/pkg/gnoweb/url.go b/gno.land/pkg/gnoweb/url.go index bc03f2182d9..37368fed8c9 100644 --- a/gno.land/pkg/gnoweb/url.go +++ b/gno.land/pkg/gnoweb/url.go @@ -4,18 +4,27 @@ import ( "errors" "fmt" "net/url" + "path/filepath" "regexp" "strings" ) +var ( + ErrURLMalformedPath = errors.New("malformed path") + ErrURLInvalidPathKind = errors.New("invalid path kind") +) + type PathKind byte const ( - KindInvalid PathKind = 0 + KindUnknown PathKind = 0 KindRealm PathKind = 'r' KindPure PathKind = 'p' ) +// rePkgOrRealmPath matches and validates a realm or package path. +var rePkgOrRealmPath = regexp.MustCompile(`^/[a-z]/[a-zA-Z0-9_/.]*$`) + // GnoURL decomposes the parts of an URL to query a realm. type GnoURL struct { // Example full path: @@ -28,114 +37,103 @@ type GnoURL struct { Query url.Values // c=d } -func (url GnoURL) EncodeArgs() string { +// EncodeArgs encodes the arguments and query parameters into a string. +func (gnoURL GnoURL) EncodeArgs() string { var urlstr strings.Builder - if url.Args != "" { - urlstr.WriteString(url.Args) + if gnoURL.Args != "" { + urlstr.WriteString(gnoURL.Args) } - - if len(url.Query) > 0 { - urlstr.WriteString("?" + url.Query.Encode()) + if len(gnoURL.Query) > 0 { + urlstr.WriteString("?" + gnoURL.Query.Encode()) } - return urlstr.String() } -func (url GnoURL) EncodePath() string { +// EncodePath encodes the path, arguments, and query parameters into a string. +func (gnoURL GnoURL) EncodePath() string { var urlstr strings.Builder - urlstr.WriteString(url.Path) - if url.Args != "" { - urlstr.WriteString(":" + url.Args) + urlstr.WriteString(gnoURL.Path) + if gnoURL.Args != "" { + urlstr.WriteString(":" + gnoURL.Args) } - - if len(url.Query) > 0 { - urlstr.WriteString("?" + url.Query.Encode()) + if len(gnoURL.Query) > 0 { + urlstr.WriteString("?" + gnoURL.Query.Encode()) } - return urlstr.String() } -func (url GnoURL) EncodeWebPath() string { +// EncodeWebPath encodes the path, arguments, and both web and query parameters into a string. +func (gnoURL GnoURL) EncodeWebPath() string { var urlstr strings.Builder - urlstr.WriteString(url.Path) - if url.Args != "" { - pathEscape := escapeDollarSign(url.Args) + urlstr.WriteString(gnoURL.Path) + if gnoURL.Args != "" { + pathEscape := escapeDollarSign(gnoURL.Args) urlstr.WriteString(":" + pathEscape) } - - if len(url.WebQuery) > 0 { - urlstr.WriteString("$" + url.WebQuery.Encode()) + if len(gnoURL.WebQuery) > 0 { + urlstr.WriteString("$" + gnoURL.WebQuery.Encode()) } - - if len(url.Query) > 0 { - urlstr.WriteString("?" + url.Query.Encode()) + if len(gnoURL.Query) > 0 { + urlstr.WriteString("?" + gnoURL.Query.Encode()) } - return urlstr.String() } -func (url GnoURL) Kind() PathKind { - if len(url.Path) < 2 { - return KindInvalid - } - pk := PathKind(url.Path[1]) - switch pk { - case KindPure, KindRealm: - return pk +// Kind determines the kind of path (invalid, realm, or pure) based on the path structure. +func (gnoURL GnoURL) Kind() PathKind { + if len(gnoURL.Path) > 2 && gnoURL.Path[0] == '/' && gnoURL.Path[2] == '/' { + switch k := PathKind(gnoURL.Path[1]); k { + case KindPure, KindRealm: + return k + } } - return KindInvalid + return KindUnknown } -var ( - ErrURLMalformedPath = errors.New("malformed URL path") - ErrURLInvalidPathKind = errors.New("invalid path kind") -) +// IsDir checks if the URL path represents a directory. +func (gnoURL GnoURL) IsDir() bool { + return len(gnoURL.Path) > 0 && gnoURL.Path[len(gnoURL.Path)-1] == '/' +} -// reRealName match a realm path -// - matches[1]: path -// - matches[2]: path args -var reRealmPath = regexp.MustCompile(`^` + - `(/(?:[a-zA-Z0-9_-]+)/` + // path kind - `[a-zA-Z][a-zA-Z0-9_-]*` + // First path segment - `(?:/[a-zA-Z][.a-zA-Z0-9_-]*)*/?)` + // Additional path segments - `([:$](?:.*))?$`, // Remaining portions args, separate by `$` or `:` -) +// IsFile checks if the URL path represents a file. +func (gnoURL GnoURL) IsFile() bool { + return filepath.Ext(gnoURL.Path) != "" +} +// ParseGnoURL parses a URL into a GnoURL structure, extracting and validating its components. func ParseGnoURL(u *url.URL) (*GnoURL, error) { - matches := reRealmPath.FindStringSubmatch(u.EscapedPath()) - if len(matches) != 3 { - return nil, fmt.Errorf("%w: %s", ErrURLMalformedPath, u.Path) + var webargs string + path, args, found := strings.Cut(u.EscapedPath(), ":") + if found { + args, webargs, _ = strings.Cut(args, "$") + } else { + path, webargs, _ = strings.Cut(path, "$") } - path := matches[1] - args := matches[2] + upath, err := url.PathUnescape(path) + if err != nil { + return nil, fmt.Errorf("unable to unescape path %q: %w", path, err) + } - if len(args) > 0 { - switch args[0] { - case ':': - args = args[1:] - case '$': - default: - return nil, fmt.Errorf("%w: %s", ErrURLMalformedPath, u.Path) - } + if !rePkgOrRealmPath.MatchString(upath) { + return nil, fmt.Errorf("%w: %q", ErrURLMalformedPath, upath) } - var err error webquery := url.Values{} - args, webargs, found := strings.Cut(args, "$") - if found { - if webquery, err = url.ParseQuery(webargs); err != nil { - return nil, fmt.Errorf("unable to parse webquery %q: %w ", webquery, err) + if len(webargs) > 0 { + var parseErr error + if webquery, parseErr = url.ParseQuery(webargs); parseErr != nil { + return nil, fmt.Errorf("unable to parse webquery %q: %w", webargs, parseErr) } } uargs, err := url.PathUnescape(args) if err != nil { - return nil, fmt.Errorf("unable to unescape path %q: %w", args, err) + return nil, fmt.Errorf("unable to unescape args %q: %w", args, err) } return &GnoURL{ - Path: path, + Path: upath, Args: uargs, WebQuery: webquery, Query: u.Query(), @@ -143,6 +141,7 @@ func ParseGnoURL(u *url.URL) (*GnoURL, error) { }, nil } +// escapeDollarSign replaces dollar signs with their URL-encoded equivalent. func escapeDollarSign(s string) string { return strings.ReplaceAll(s, "$", "%24") } diff --git a/gno.land/pkg/gnoweb/url_test.go b/gno.land/pkg/gnoweb/url_test.go index 73cfdda69bd..b15f578b69e 100644 --- a/gno.land/pkg/gnoweb/url_test.go +++ b/gno.land/pkg/gnoweb/url_test.go @@ -21,6 +21,7 @@ func TestParseGnoURL(t *testing.T) { Expected: nil, Err: ErrURLMalformedPath, }, + { Name: "simple", Input: "https://gno.land/r/simple/test", @@ -30,8 +31,8 @@ func TestParseGnoURL(t *testing.T) { WebQuery: url.Values{}, Query: url.Values{}, }, - Err: nil, }, + { Name: "webquery + query", Input: "https://gno.land/r/demo/foo$help&func=Bar&name=Baz", @@ -46,7 +47,6 @@ func TestParseGnoURL(t *testing.T) { Query: url.Values{}, Domain: "gno.land", }, - Err: nil, }, { @@ -61,7 +61,6 @@ func TestParseGnoURL(t *testing.T) { Query: url.Values{}, Domain: "gno.land", }, - Err: nil, }, { @@ -78,7 +77,6 @@ func TestParseGnoURL(t *testing.T) { }, Domain: "gno.land", }, - Err: nil, }, { @@ -93,7 +91,6 @@ func TestParseGnoURL(t *testing.T) { }, Domain: "gno.land", }, - Err: nil, }, { @@ -108,22 +105,113 @@ func TestParseGnoURL(t *testing.T) { Query: url.Values{}, Domain: "gno.land", }, - Err: nil, }, - // XXX: more tests + { + Name: "unknown path kind", + Input: "https://gno.land/x/demo/foo", + Expected: &GnoURL{ + Path: "/x/demo/foo", + Args: "", + WebQuery: url.Values{}, + Query: url.Values{}, + Domain: "gno.land", + }, + }, + + { + Name: "empty path", + Input: "https://gno.land/r/", + Expected: &GnoURL{ + Path: "/r/", + Args: "", + WebQuery: url.Values{}, + Query: url.Values{}, + Domain: "gno.land", + }, + }, + + { + Name: "complex query", + Input: "https://gno.land/r/demo/foo$help?func=Bar&name=Baz&age=30", + Expected: &GnoURL{ + Path: "/r/demo/foo", + Args: "", + WebQuery: url.Values{ + "help": []string{""}, + }, + Query: url.Values{ + "func": []string{"Bar"}, + "name": []string{"Baz"}, + "age": []string{"30"}, + }, + Domain: "gno.land", + }, + }, + + { + Name: "multiple web queries", + Input: "https://gno.land/r/demo/foo$help&func=Bar$test=123", + Expected: &GnoURL{ + Path: "/r/demo/foo", + Args: "", + WebQuery: url.Values{ + "help": []string{""}, + "func": []string{"Bar$test=123"}, + }, + Query: url.Values{}, + Domain: "gno.land", + }, + }, + + { + Name: "webquery-args-webquery", + Input: "https://gno.land/r/demo/AAA$BBB:CCC&DDD$EEE", + Err: ErrURLMalformedPath, // `/r/demo/AAA$BBB` is an invalid path + }, + + { + Name: "args-webquery-args", + Input: "https://gno.land/r/demo/AAA:BBB$CCC&DDD:EEE", + Expected: &GnoURL{ + Domain: "gno.land", + Path: "/r/demo/AAA", + Args: "BBB", + WebQuery: url.Values{ + "CCC": []string{""}, + "DDD:EEE": []string{""}, + }, + Query: url.Values{}, + }, + }, + + { + Name: "escaped characters in args", + Input: "https://gno.land/r/demo/foo:example%20with%20spaces$tz=Europe/Paris", + Expected: &GnoURL{ + Path: "/r/demo/foo", + Args: "example with spaces", + WebQuery: url.Values{ + "tz": []string{"Europe/Paris"}, + }, + Query: url.Values{}, + Domain: "gno.land", + }, + }, } for _, tc := range testCases { t.Run(tc.Name, func(t *testing.T) { + t.Logf("testing input: %q", tc.Input) + u, err := url.Parse(tc.Input) require.NoError(t, err) result, err := ParseGnoURL(u) if tc.Err == nil { require.NoError(t, err) - t.Logf("parsed: %s", result.EncodePath()) - t.Logf("parsed web: %s", result.EncodeWebPath()) + t.Logf("encoded path: %q", result.EncodePath()) + t.Logf("encoded web path: %q", result.EncodeWebPath()) } else { require.Error(t, err) require.ErrorIs(t, err, tc.Err) diff --git a/gno.land/pkg/gnoweb/webclient.go b/gno.land/pkg/gnoweb/webclient.go index a1005baa0a5..3dcda69d670 100644 --- a/gno.land/pkg/gnoweb/webclient.go +++ b/gno.land/pkg/gnoweb/webclient.go @@ -2,126 +2,44 @@ package gnoweb import ( "errors" - "fmt" "io" - "log/slog" - "path/filepath" - "strings" md "github.com/gnolang/gno/gno.land/pkg/gnoweb/markdown" - "github.com/gnolang/gno/gno.land/pkg/sdk/vm" // for error types - "github.com/gnolang/gno/tm2/pkg/amino" - "github.com/gnolang/gno/tm2/pkg/bft/rpc/client" - "github.com/yuin/goldmark" - "github.com/yuin/goldmark/parser" - "github.com/yuin/goldmark/text" + "github.com/gnolang/gno/gno.land/pkg/sdk/vm" ) -type WebClient struct { - logger *slog.Logger - client *client.RPCClient - md goldmark.Markdown -} - -func NewWebClient(log *slog.Logger, cl *client.RPCClient, m goldmark.Markdown) *WebClient { - m.Parser().AddOptions(parser.WithAutoHeadingID()) - return &WebClient{ - logger: log, - client: cl, - md: m, - } -} - -func (s *WebClient) Functions(pkgPath string) ([]vm.FunctionSignature, error) { - const qpath = "vm/qfuncs" - - args := fmt.Sprintf("gno.land/%s", strings.Trim(pkgPath, "/")) - res, err := s.query(qpath, []byte(args)) - if err != nil { - return nil, fmt.Errorf("unable query funcs list: %w", err) - } - - var fsigs vm.FunctionSignatures - if err := amino.UnmarshalJSON(res, &fsigs); err != nil { - s.logger.Warn("unable to unmarshal fsigs, client is probably outdated ?") - return nil, fmt.Errorf("unable to unamarshal fsigs: %w", err) - } - - return fsigs, nil -} - -func (s *WebClient) SourceFile(path, fileName string) ([]byte, error) { - const qpath = "vm/qfile" - - fileName = strings.TrimSpace(fileName) // sanitize filename - if fileName == "" { - return nil, errors.New("empty filename given") // XXX -> ErrXXX - } - - // XXX: move this into gnoclient ? - path = fmt.Sprintf("gno.land/%s", strings.Trim(path, "/")) - path = filepath.Join(path, fileName) - return s.query(qpath, []byte(path)) -} - -func (s *WebClient) Sources(path string) ([]string, error) { - const qpath = "vm/qfile" - - // XXX: move this into gnoclient - path = fmt.Sprintf("gno.land/%s", strings.Trim(path, "/")) - res, err := s.query(qpath, []byte(path)) - if err != nil { - return nil, err - } +var ( + ErrClientPathNotFound = errors.New("package path not found") + ErrClientBadRequest = errors.New("bad request") + ErrClientResponse = errors.New("node response error") +) - files := strings.Split(string(res), "\n") - return files, nil +type FileMeta struct { + Lines int + SizeKb float64 } -type Metadata struct { - *md.Toc +type RealmMeta struct { + Toc md.Toc } -func (s *WebClient) Render(w io.Writer, pkgPath string, args string) (*Metadata, error) { - const qpath = "vm/qrender" - - data := []byte(gnoPath(pkgPath, args)) - rawres, err := s.query(qpath, data) - if err != nil { - return nil, err - } - - doc := s.md.Parser().Parse(text.NewReader(rawres)) - if err := s.md.Renderer().Render(w, rawres, doc); err != nil { - return nil, fmt.Errorf("unable render real %q: %w", data, err) - } +// WebClient is an interface for interacting with package and node ressources. +type WebClient interface { + // RenderRealm renders the content of a realm from a given path and + // arguments into the giver `writer`. The method should ensures the rendered + // content is safely handled and formatted. + RenderRealm(w io.Writer, path string, args string) (*RealmMeta, error) - var meta Metadata - meta.Toc, err = md.TocInspect(doc, rawres, md.TocOptions{MaxDepth: 6, MinDepth: 2}) - if err != nil { - s.logger.Warn("unable to inspect for toc elements", "err", err) - } + // SourceFile fetches and writes the source file from a given + // package path and file name. The method should ensures the source + // file's content is safely handled and formatted. + SourceFile(w io.Writer, pkgPath, fileName string) (*FileMeta, error) - return &meta, nil -} - -func (s *WebClient) query(qpath string, data []byte) ([]byte, error) { - s.logger.Info("query", "qpath", qpath, "data", string(data)) - - qres, err := s.client.ABCIQuery(qpath, data) - if err != nil { - s.logger.Error("request error", "path", qpath, "data", string(data), "error", err) - return nil, fmt.Errorf("unable to query path %q: %w", qpath, err) - } - if qres.Response.Error != nil { - s.logger.Error("response error", "path", qpath, "log", qres.Response.Log) - return nil, qres.Response.Error - } - - return qres.Response.Data, nil -} + // Functions retrieves a list of function signatures from a + // specified package path. + Functions(path string) ([]vm.FunctionSignature, error) -func gnoPath(pkgPath, args string) string { - pkgPath = strings.Trim(pkgPath, "/") - return fmt.Sprintf("gno.land/%s:%s", pkgPath, args) + // Sources lists all source files available in a specified + // package path. + Sources(path string) ([]string, error) } diff --git a/gno.land/pkg/gnoweb/webclient_html.go b/gno.land/pkg/gnoweb/webclient_html.go new file mode 100644 index 00000000000..ffe2238df98 --- /dev/null +++ b/gno.land/pkg/gnoweb/webclient_html.go @@ -0,0 +1,190 @@ +package gnoweb + +import ( + "errors" + "fmt" + "io" + "log/slog" + "path/filepath" + "strings" + + md "github.com/gnolang/gno/gno.land/pkg/gnoweb/markdown" + "github.com/gnolang/gno/gno.land/pkg/sdk/vm" // for error types + "github.com/gnolang/gno/tm2/pkg/amino" + "github.com/gnolang/gno/tm2/pkg/bft/rpc/client" + "github.com/yuin/goldmark" + "github.com/yuin/goldmark/parser" + "github.com/yuin/goldmark/text" +) + +type HTMLWebClientConfig struct { + Domain string + UnsafeHTML bool + RPCClient *client.RPCClient + Highlighter FormatSource + Markdown goldmark.Markdown +} + +// NewDefaultHTMLWebClientConfig initializes a WebClientConfig with default settings. +// It sets up goldmark Markdown parsing options and default domain and highlighter. +func NewDefaultHTMLWebClientConfig(client *client.RPCClient) *HTMLWebClientConfig { + mdopts := []goldmark.Option{goldmark.WithParserOptions(parser.WithAutoHeadingID())} + return &HTMLWebClientConfig{ + Domain: "gno.land", + Highlighter: &noopFormat{}, + Markdown: goldmark.New(mdopts...), + RPCClient: client, + } +} + +type HTMLWebClient struct { + domain string + logger *slog.Logger + client *client.RPCClient + md goldmark.Markdown + highlighter FormatSource +} + +// NewHTMLClient creates a new instance of WebClient. +// It requires a configured logger and WebClientConfig. +func NewHTMLClient(log *slog.Logger, cfg *HTMLWebClientConfig) *HTMLWebClient { + return &HTMLWebClient{ + logger: log, + domain: cfg.Domain, + client: cfg.RPCClient, + md: cfg.Markdown, + highlighter: cfg.Highlighter, + } +} + +// Functions retrieves a list of function signatures from a +// specified package path. +func (s *HTMLWebClient) Functions(pkgPath string) ([]vm.FunctionSignature, error) { + const qpath = "vm/qfuncs" + + args := fmt.Sprintf("%s/%s", s.domain, strings.Trim(pkgPath, "/")) + res, err := s.query(qpath, []byte(args)) + if err != nil { + return nil, fmt.Errorf("unable to query func list: %w", err) + } + + var fsigs vm.FunctionSignatures + if err := amino.UnmarshalJSON(res, &fsigs); err != nil { + s.logger.Warn("unable to unmarshal function signatures, client is probably outdated") + return nil, fmt.Errorf("unable to unmarshal function signatures: %w", err) + } + + return fsigs, nil +} + +// SourceFile fetches and writes the source file from a given +// package path and file name to the provided writer. It uses +// Chroma for syntax highlighting source. +func (s *HTMLWebClient) SourceFile(w io.Writer, path, fileName string) (*FileMeta, error) { + const qpath = "vm/qfile" + + fileName = strings.TrimSpace(fileName) + if fileName == "" { + return nil, errors.New("empty filename given") // XXX: Consider creating a specific error variable + } + + // XXX: Consider moving this into gnoclient + fullPath := filepath.Join(s.domain, strings.Trim(path, "/"), fileName) + + source, err := s.query(qpath, []byte(fullPath)) + if err != nil { + // XXX: this is a bit ugly, we should make the keeper return an + // assertable error. + if strings.Contains(err.Error(), "not available") { + return nil, ErrClientPathNotFound + } + + return nil, err + } + + fileMeta := FileMeta{ + Lines: strings.Count(string(source), "\n"), + SizeKb: float64(len(source)) / 1024.0, + } + + // Use Chroma for syntax highlighting + if err := s.highlighter.Format(w, fileName, source); err != nil { + return nil, err + } + + return &fileMeta, nil +} + +// Sources lists all source files available in a specified +// package path by querying the RPC client. +func (s *HTMLWebClient) Sources(path string) ([]string, error) { + const qpath = "vm/qfile" + + // XXX: Consider moving this into gnoclient + pkgPath := strings.Trim(path, "/") + fullPath := fmt.Sprintf("%s/%s", s.domain, pkgPath) + res, err := s.query(qpath, []byte(fullPath)) + if err != nil { + // XXX: this is a bit ugly, we should make the keeper return an + // assertable error. + if strings.Contains(err.Error(), "not available") { + return nil, ErrClientPathNotFound + } + + return nil, err + } + + files := strings.Split(strings.TrimSpace(string(res)), "\n") + return files, nil +} + +// RenderRealm renders the content of a realm from a given path +// and arguments into the provided writer. It uses Goldmark for +// Markdown processing to generate HTML content. +func (s *HTMLWebClient) RenderRealm(w io.Writer, pkgPath string, args string) (*RealmMeta, error) { + const qpath = "vm/qrender" + + pkgPath = strings.Trim(pkgPath, "/") + data := fmt.Sprintf("%s/%s:%s", s.domain, pkgPath, args) + rawres, err := s.query(qpath, []byte(data)) + if err != nil { + return nil, err + } + + // Use Goldmark for Markdown parsing + doc := s.md.Parser().Parse(text.NewReader(rawres)) + if err := s.md.Renderer().Render(w, rawres, doc); err != nil { + return nil, fmt.Errorf("unable to render realm %q: %w", data, err) + } + + var meta RealmMeta + meta.Toc, err = md.TocInspect(doc, rawres, md.TocOptions{MaxDepth: 6, MinDepth: 2}) + if err != nil { + s.logger.Warn("unable to inspect for TOC elements", "error", err) + } + + return &meta, nil +} + +// query sends a query to the RPC client and returns the response +// data. +func (s *HTMLWebClient) query(qpath string, data []byte) ([]byte, error) { + s.logger.Info("query", "path", qpath, "data", string(data)) + + qres, err := s.client.ABCIQuery(qpath, data) + if err != nil { + s.logger.Debug("request error", "path", qpath, "data", string(data), "error", err) + return nil, fmt.Errorf("%w: %s", ErrClientBadRequest, err.Error()) + } + + if err = qres.Response.Error; err != nil { + if errors.Is(err, vm.InvalidPkgPathError{}) { + return nil, ErrClientPathNotFound + } + + s.logger.Error("response error", "path", qpath, "log", qres.Response.Log) + return nil, fmt.Errorf("%w: %s", ErrClientResponse, err.Error()) + } + + return qres.Response.Data, nil +} diff --git a/gno.land/pkg/gnoweb/webclient_mock.go b/gno.land/pkg/gnoweb/webclient_mock.go new file mode 100644 index 00000000000..261b0b02814 --- /dev/null +++ b/gno.land/pkg/gnoweb/webclient_mock.go @@ -0,0 +1,91 @@ +package gnoweb + +import ( + "bytes" + "fmt" + "io" + "sort" + + "github.com/gnolang/gno/gno.land/pkg/sdk/vm" +) + +// MockPackage represents a mock package with files and function signatures. +type MockPackage struct { + Path string + Domain string + Files map[string] /* filename */ string /* body */ + Functions []vm.FunctionSignature +} + +// MockWebClient is a mock implementation of the Client interface. +type MockWebClient struct { + Packages map[string] /* path */ *MockPackage /* package */ +} + +func NewMockWebClient(pkgs ...*MockPackage) *MockWebClient { + mpkgs := make(map[string]*MockPackage) + for _, pkg := range pkgs { + mpkgs[pkg.Path] = pkg + } + + return &MockWebClient{Packages: mpkgs} +} + +// Render simulates rendering a package by writing its content to the writer. +func (m *MockWebClient) RenderRealm(w io.Writer, path string, args string) (*RealmMeta, error) { + pkg, exists := m.Packages[path] + if !exists { + return nil, ErrClientPathNotFound + } + + fmt.Fprintf(w, "[%s]%s:", pkg.Domain, pkg.Path) + + // Return a dummy RealmMeta for simplicity + return &RealmMeta{}, nil +} + +// SourceFile simulates retrieving a source file's metadata. +func (m *MockWebClient) SourceFile(w io.Writer, pkgPath, fileName string) (*FileMeta, error) { + pkg, exists := m.Packages[pkgPath] + if !exists { + return nil, ErrClientPathNotFound + } + + if body, ok := pkg.Files[fileName]; ok { + w.Write([]byte(body)) + return &FileMeta{ + Lines: len(bytes.Split([]byte(body), []byte("\n"))), + SizeKb: float64(len(body)) / 1024.0, + }, nil + } + + return nil, ErrClientPathNotFound +} + +// Functions simulates retrieving function signatures from a package. +func (m *MockWebClient) Functions(path string) ([]vm.FunctionSignature, error) { + pkg, exists := m.Packages[path] + if !exists { + return nil, ErrClientPathNotFound + } + + return pkg.Functions, nil +} + +// Sources simulates listing all source files in a package. +func (m *MockWebClient) Sources(path string) ([]string, error) { + pkg, exists := m.Packages[path] + if !exists { + return nil, ErrClientPathNotFound + } + + fileNames := make([]string, 0, len(pkg.Files)) + for file := range pkg.Files { + fileNames = append(fileNames, file) + } + + // Sort for consistency + sort.Strings(fileNames) + + return fileNames, nil +}