From 8f9398787620d7305ca0bebd340f64ecfc27c7ab Mon Sep 17 00:00:00 2001 From: gfanton <8671905+gfanton@users.noreply.github.com> Date: Mon, 23 Dec 2024 01:17:29 +0100 Subject: [PATCH] feat: improve url - Removing the concept of "kind" within the structure while still providing helpers to check if the method is pure or a realm: - Adding a new File field, trimming any file from the path when parsing and adding it to the structure. - Refining the regex to define what a path can be, based on what we have in `gnovm/pkg/gnolang/helpers.go` "var rePkgOrRealmPath = regexp.MustCompile(`^/[a-z][a-z0-9_/]*$`)" Signed-off-by: gfanton <8671905+gfanton@users.noreply.github.com> --- gno.land/pkg/gnoweb/handler.go | 42 +++++---------- gno.land/pkg/gnoweb/url.go | 92 +++++++++++++++++++-------------- gno.land/pkg/gnoweb/url_test.go | 68 ++++++++++++++++++++++-- 3 files changed, 130 insertions(+), 72 deletions(-) diff --git a/gno.land/pkg/gnoweb/handler.go b/gno.land/pkg/gnoweb/handler.go index 53e3a52448a..0a0ee69c3f0 100644 --- a/gno.land/pkg/gnoweb/handler.go +++ b/gno.land/pkg/gnoweb/handler.go @@ -99,11 +99,11 @@ func (h *WebHandler) Get(w http.ResponseWriter, r *http.Request) { indexData.HeaderData.WebQuery = gnourl.WebQuery // Render - switch gnourl.Kind() { - case KindRealm, KindPure: + switch { + case gnourl.IsRealm(), gnourl.IsPure(): status, err = h.renderPackage(&body, gnourl) default: - h.logger.Debug("invalid page kind", "kind", gnourl.Kind) + h.logger.Debug("invalid path: path is neither a pure package or a realm") status, err = http.StatusNotFound, components.RenderStatusComponent(&body, "page not found") } } @@ -129,10 +129,8 @@ func (h *WebHandler) Get(w http.ResponseWriter, r *http.Request) { func (h *WebHandler) renderPackage(w io.Writer, gnourl *GnoURL) (status int, err error) { h.logger.Info("component render", "path", gnourl.Path, "args", gnourl.Args) - kind := gnourl.Kind() - // Display realm help page? - if kind == KindRealm && gnourl.WebQuery.Has("help") { + if gnourl.WebQuery.Has("help") { return h.renderRealmHelp(w, gnourl) } @@ -140,27 +138,11 @@ func (h *WebHandler) renderPackage(w io.Writer, gnourl *GnoURL) (status int, err switch { case gnourl.WebQuery.Has("source"): return h.renderRealmSource(w, gnourl) - case kind == KindPure, gnourl.IsFile(), gnourl.IsDir(): - i := strings.LastIndexByte(gnourl.Path, '/') - if i < 0 { - return http.StatusInternalServerError, fmt.Errorf("unable to get ending slash for %q", gnourl.Path) - } - + case gnourl.IsFile(): // Fill webquery with file infos - gnourl.WebQuery.Set("source", "") // set source - - file := gnourl.Path[i+1:] - // If there nothing after the last slash that mean its a - // directory ... - if file == "" { - return h.renderRealmDirectory(w, gnourl) - } - - // ... else, remaining part is a file - gnourl.WebQuery.Set("file", file) - gnourl.Path = gnourl.Path[:i] - return h.renderRealmSource(w, gnourl) + case gnourl.IsDir(), gnourl.IsPure(): + return h.renderRealmDirectory(w, gnourl) } // Render content into the content buffer @@ -251,12 +233,16 @@ func (h *WebHandler) renderRealmSource(w io.Writer, gnourl *GnoURL) (status int, return http.StatusOK, components.RenderStatusComponent(w, "no files available") } + file := gnourl.WebQuery.Get("file") // webquery override file + if file == "" { + file = gnourl.File + } + var fileName string - file := gnourl.WebQuery.Get("file") if file == "" { - fileName = files[0] + fileName = files[0] // Default to the first file if none specified } else if slices.Contains(files, file) { - fileName = file + fileName = file // Use specified file if it exists } else { h.logger.Error("unable to render source", "file", file, "err", "file does not exist") return http.StatusInternalServerError, components.RenderStatusComponent(w, "internal error") diff --git a/gno.land/pkg/gnoweb/url.go b/gno.land/pkg/gnoweb/url.go index 786be3227d6..6ad218b9a36 100644 --- a/gno.land/pkg/gnoweb/url.go +++ b/gno.land/pkg/gnoweb/url.go @@ -9,38 +9,33 @@ import ( "strings" ) -type PathKind byte +var ErrURLInvalidPath = errors.New("invalid path") -const ( - 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_/.]*$`) +// rePkgOrRealmPath matches and validates a flexible path. +var rePkgOrRealmPath = regexp.MustCompile(`^/[a-z][a-z0-9_/]*$`) // GnoURL decomposes the parts of an URL to query a realm. type GnoURL struct { // Example full path: - // gno.land/r/demo/users:jae$help&a=b?c=d + // gno.land/r/demo/users/render.gno:jae$help&a=b?c=d Domain string // gno.land Path string // /r/demo/users Args string // jae WebQuery url.Values // help&a=b Query url.Values // c=d + File string // render.gno } -// EncodeFlag is used to compose and encode URL components. +// EncodeFlag is used to specify which URL components to encode. type EncodeFlag int const ( - EncodePath EncodeFlag = 1 << iota - EncodeArgs - EncodeWebQuery - EncodeQuery - EncodeNoEscape // Disable escaping on arg + EncodePath EncodeFlag = 1 << iota // Encode the path component + EncodeArgs // Encode the arguments component + EncodeWebQuery // Encode the web query component + EncodeQuery // Encode the query component + EncodeNoEscape // Disable escaping of arguments ) // Has checks if the EncodeFlag contains all the specified flags. @@ -49,14 +44,23 @@ func (f EncodeFlag) Has(flags EncodeFlag) bool { } // Encode encodes the URL components based on the provided flags. -// Encode assumes the URL is valid. func (gnoURL GnoURL) Encode(encodeFlags EncodeFlag) string { var urlstr strings.Builder if encodeFlags.Has(EncodePath) { + path := gnoURL.Path + if !encodeFlags.Has(EncodeNoEscape) { + path = url.PathEscape(path) + } + urlstr.WriteString(gnoURL.Path) } + if len(gnoURL.File) > 0 { + urlstr.WriteRune('/') + urlstr.WriteString(gnoURL.File) + } + if encodeFlags.Has(EncodeArgs) && gnoURL.Args != "" { if encodeFlags.Has(EncodePath) { urlstr.WriteRune(':') @@ -85,6 +89,10 @@ func (gnoURL GnoURL) Encode(encodeFlags EncodeFlag) string { return urlstr.String() } +func escapeDollarSign(s string) string { + return strings.ReplaceAll(s, "$", "%24") +} + // EncodeArgs encodes the arguments and query parameters into a string. // This function is intended to be passed as a realm `Render` argument. func (gnoURL GnoURL) EncodeArgs() string { @@ -103,33 +111,31 @@ func (gnoURL GnoURL) EncodeWebURL() string { return gnoURL.Encode(EncodePath | EncodeArgs | EncodeWebQuery | EncodeQuery) } -// 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 KindUnknown +// IsPure checks if the URL path represents a pure path. +func (gnoURL GnoURL) IsPure() bool { + return strings.HasPrefix(gnoURL.Path, "/p/") } -func (gnoURL GnoURL) IsValid() bool { - return rePkgOrRealmPath.MatchString(gnoURL.Path) +// IsRealm checks if the URL path represents a realm path. +func (gnoURL GnoURL) IsRealm() bool { + return strings.HasPrefix(gnoURL.Path, "/r/") +} + +// IsFile checks if the URL path represents a file. +func (gnoURL GnoURL) IsFile() bool { + return gnoURL.File != "" } // 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] == '/' + return !gnoURL.IsFile() && + len(gnoURL.Path) > 0 && gnoURL.Path[len(gnoURL.Path)-1] == '/' } -// IsFile checks if the URL path represents a file. -func (gnoURL GnoURL) IsFile() bool { - return filepath.Ext(gnoURL.Path) != "" +func (gnoURL GnoURL) IsValid() bool { + return rePkgOrRealmPath.MatchString(gnoURL.Path) } -var ErrURLInvalidPath = errors.New("invalid or malformed path") - // ParseGnoURL parses a URL into a GnoURL structure, extracting and validating its components. func ParseGnoURL(u *url.URL) (*GnoURL, error) { var webargs string @@ -140,12 +146,22 @@ func ParseGnoURL(u *url.URL) (*GnoURL, error) { path, webargs, _ = strings.Cut(path, "$") } - // NOTE: `PathUnescape` should already unescape dollar signs. upath, err := url.PathUnescape(path) if err != nil { return nil, fmt.Errorf("unable to unescape path %q: %w", path, err) } + var file string + if ext := filepath.Ext(upath); ext != "" { + file = filepath.Base(upath) + upath = strings.TrimSuffix(upath, file) + + // Trim last slash + if i := strings.LastIndexByte(upath, '/'); i > 0 { + upath = upath[:i] + } + } + if !rePkgOrRealmPath.MatchString(upath) { return nil, fmt.Errorf("%w: %q", ErrURLInvalidPath, upath) } @@ -169,10 +185,6 @@ func ParseGnoURL(u *url.URL) (*GnoURL, error) { WebQuery: webquery, Query: u.Query(), Domain: u.Hostname(), + File: file, }, 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 90b58e53278..f4729668d71 100644 --- a/gno.land/pkg/gnoweb/url_test.go +++ b/gno.land/pkg/gnoweb/url_test.go @@ -33,6 +33,30 @@ func TestParseGnoURL(t *testing.T) { }, }, + { + Name: "file", + Input: "https://gno.land/r/simple/test/encode.gno", + Expected: &GnoURL{ + Domain: "gno.land", + Path: "/r/simple/test", + WebQuery: url.Values{}, + Query: url.Values{}, + File: "encode.gno", + }, + }, + + { + Name: "complex file path", + Input: "https://gno.land/r/simple/test///...gno", + Expected: &GnoURL{ + Domain: "gno.land", + Path: "/r/simple/test//", + WebQuery: url.Values{}, + Query: url.Values{}, + File: "...gno", + }, + }, + { Name: "webquery + query", Input: "https://gno.land/r/demo/foo$help&func=Bar&name=Baz", @@ -166,16 +190,16 @@ func TestParseGnoURL(t *testing.T) { { Name: "webquery-args-webquery", - Input: "https://gno.land/r/demo/AAA$BBB:CCC&DDD$EEE", - Err: ErrURLInvalidPath, // `/r/demo/AAA$BBB` is an invalid path + Input: "https://gno.land/r/demo/aaa$bbb:CCC&DDD$EEE", + Err: ErrURLInvalidPath, // `/r/demo/aaa$bbb` is an invalid path }, { Name: "args-webquery-args", - Input: "https://gno.land/r/demo/AAA:BBB$CCC&DDD:EEE", + Input: "https://gno.land/r/demo/aaa:BBB$CCC&DDD:EEE", Expected: &GnoURL{ Domain: "gno.land", - Path: "/r/demo/AAA", + Path: "/r/demo/aaa", Args: "BBB", WebQuery: url.Values{ "CCC": []string{""}, @@ -198,6 +222,21 @@ func TestParseGnoURL(t *testing.T) { Domain: "gno.land", }, }, + + { + Name: "file in path + args + query", + Input: "https://gno.land/r/demo/foo/render.gno:example$tz=Europe/Paris", + Expected: &GnoURL{ + Path: "/r/demo/foo", + File: "render.gno", + Args: "example", + WebQuery: url.Values{ + "tz": []string{"Europe/Paris"}, + }, + Query: url.Values{}, + Domain: "gno.land", + }, + }, } for _, tc := range testCases { @@ -237,6 +276,27 @@ func TestEncode(t *testing.T) { Expected: "/r/demo/foo", }, + { + Name: "Encode Path and File", + GnoURL: GnoURL{ + Path: "/r/demo/foo", + File: "render.gno", + }, + EncodeFlags: EncodePath, + Expected: "/r/demo/foo/render.gno", + }, + + { + Name: "Encode Path, File, and Args", + GnoURL: GnoURL{ + Path: "/r/demo/foo", + File: "render.gno", + Args: "example", + }, + EncodeFlags: EncodePath | EncodeArgs, + Expected: "/r/demo/foo/render.gno:example", + }, + { Name: "Encode Path and Args", GnoURL: GnoURL{