From f82ec4398fa96cb6edf87b370a1849efcff60766 Mon Sep 17 00:00:00 2001 From: gfanton <8671905+gfanton@users.noreply.github.com> Date: Wed, 18 Dec 2024 00:54:06 +0100 Subject: [PATCH 01/11] fix: simplify url system Signed-off-by: gfanton <8671905+gfanton@users.noreply.github.com> --- gno.land/pkg/gnoweb/url.go | 57 +++++++++++++-------------------- gno.land/pkg/gnoweb/url_test.go | 2 ++ 2 files changed, 25 insertions(+), 34 deletions(-) diff --git a/gno.land/pkg/gnoweb/url.go b/gno.land/pkg/gnoweb/url.go index bc03f2182d9..8097e808b2e 100644 --- a/gno.land/pkg/gnoweb/url.go +++ b/gno.land/pkg/gnoweb/url.go @@ -16,6 +16,9 @@ const ( KindPure PathKind = 'p' ) +// reRealmPath match and validate 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: @@ -75,55 +78,41 @@ func (url GnoURL) EncodeWebPath() 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 + // Check if the first and third character is '/' and extract the next character + if len(url.Path) > 2 && url.Path[0] == '/' && url.Path[2] == '/' { + switch k := PathKind(url.Path[1]); k { + case KindPure, KindRealm: + return k + } } + return KindInvalid } var ( - ErrURLMalformedPath = errors.New("malformed URL path") + ErrURLMalformedPath = errors.New("malformed path") ErrURLInvalidPathKind = errors.New("invalid path kind") ) -// 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 `:` -) - 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] + // XXX: should we lower case the path ? - if len(args) > 0 { - switch args[0] { - case ':': - args = args[1:] - case '$': - default: - return nil, fmt.Errorf("%w: %s", ErrURLMalformedPath, u.Path) - } + // Validate path format + if !rePkgOrRealmPath.MatchString(path) { + return nil, fmt.Errorf("%w: %q", ErrURLMalformedPath, path) } - var err error webquery := url.Values{} - args, webargs, found := strings.Cut(args, "$") - if found { + if len(webargs) > 0 { + var err error if webquery, err = url.ParseQuery(webargs); err != nil { return nil, fmt.Errorf("unable to parse webquery %q: %w ", webquery, err) } diff --git a/gno.land/pkg/gnoweb/url_test.go b/gno.land/pkg/gnoweb/url_test.go index 73cfdda69bd..3b8bf1eeb06 100644 --- a/gno.land/pkg/gnoweb/url_test.go +++ b/gno.land/pkg/gnoweb/url_test.go @@ -116,6 +116,8 @@ func TestParseGnoURL(t *testing.T) { for _, tc := range testCases { t.Run(tc.Name, func(t *testing.T) { + t.Logf("testing: %s", tc.Input) + u, err := url.Parse(tc.Input) require.NoError(t, err) From e28401a89c2e23d9e13a8e874376f61daab7a8fc Mon Sep 17 00:00:00 2001 From: gfanton <8671905+gfanton@users.noreply.github.com> Date: Wed, 18 Dec 2024 13:34:14 +0100 Subject: [PATCH 02/11] wip: more tests Signed-off-by: gfanton <8671905+gfanton@users.noreply.github.com> --- gno.land/pkg/gnoweb/url_test.go | 78 +++++++++++++++++++++++++++++---- 1 file changed, 69 insertions(+), 9 deletions(-) diff --git a/gno.land/pkg/gnoweb/url_test.go b/gno.land/pkg/gnoweb/url_test.go index 3b8bf1eeb06..f6e81f9cbe1 100644 --- a/gno.land/pkg/gnoweb/url_test.go +++ b/gno.land/pkg/gnoweb/url_test.go @@ -48,7 +48,6 @@ func TestParseGnoURL(t *testing.T) { }, Err: nil, }, - { Name: "path args + webquery", Input: "https://gno.land/r/demo/foo:example$tz=Europe/Paris", @@ -63,7 +62,6 @@ func TestParseGnoURL(t *testing.T) { }, Err: nil, }, - { Name: "path args + webquery + query", Input: "https://gno.land/r/demo/foo:example$tz=Europe/Paris?hello=42", @@ -80,7 +78,6 @@ func TestParseGnoURL(t *testing.T) { }, Err: nil, }, - { Name: "webquery inside query", Input: "https://gno.land/r/demo/foo:example?value=42$tz=Europe/Paris", @@ -95,7 +92,6 @@ func TestParseGnoURL(t *testing.T) { }, Err: nil, }, - { Name: "webquery escaped $", Input: "https://gno.land/r/demo/foo:example%24hello=43$hello=42", @@ -110,13 +106,77 @@ func TestParseGnoURL(t *testing.T) { }, Err: nil, }, - - // XXX: more tests + { + Name: "invalid path kind", + Input: "https://gno.land/x/demo/foo", + Expected: nil, + Err: ErrURLMalformedPath, + }, + { + Name: "empty path", + Input: "https://gno.land/r/", + Expected: &GnoURL{ + Path: "/r/", + Args: "", + WebQuery: url.Values{}, + Query: url.Values{}, + Domain: "gno.land", + }, + Err: nil, + }, + { + 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", + }, + Err: nil, + }, + { + 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": []string{"123"}, + }, + Query: url.Values{}, + Domain: "gno.land", + }, + Err: nil, + }, + { + 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", + }, + Err: nil, + }, } for _, tc := range testCases { t.Run(tc.Name, func(t *testing.T) { - t.Logf("testing: %s", tc.Input) + t.Logf("testing input: %q", tc.Input) u, err := url.Parse(tc.Input) require.NoError(t, err) @@ -124,8 +184,8 @@ func TestParseGnoURL(t *testing.T) { 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) From 4c6535af121d021fdd478ef91dad81a3dd46f7f6 Mon Sep 17 00:00:00 2001 From: gfanton <8671905+gfanton@users.noreply.github.com> Date: Wed, 18 Dec 2024 14:48:48 +0100 Subject: [PATCH 03/11] fix: improve url Signed-off-by: gfanton <8671905+gfanton@users.noreply.github.com> --- gno.land/pkg/gnoweb/handler.go | 7 ++++--- gno.land/pkg/gnoweb/url.go | 12 ++++++++++++ 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/gno.land/pkg/gnoweb/handler.go b/gno.land/pkg/gnoweb/handler.go index b3a9fcd143c..7235a0687ae 100644 --- a/gno.land/pkg/gnoweb/handler.go +++ b/gno.land/pkg/gnoweb/handler.go @@ -140,9 +140,7 @@ 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, - strings.HasSuffix(gnourl.Path, "/"), - isFile(gnourl.Path): + 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) @@ -152,10 +150,13 @@ func (h *WebHandler) renderPackage(w io.Writer, gnourl *GnoURL) (status int, err 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] diff --git a/gno.land/pkg/gnoweb/url.go b/gno.land/pkg/gnoweb/url.go index 8097e808b2e..30cadf1e21a 100644 --- a/gno.land/pkg/gnoweb/url.go +++ b/gno.land/pkg/gnoweb/url.go @@ -89,6 +89,18 @@ func (url GnoURL) Kind() PathKind { return KindInvalid } +func (url GnoURL) IsDir() bool { + if pathlen := len(url.Path); pathlen > 0 { + return url.Path[pathlen-1] == '/' + } + + return false +} + +func (url GnoURL) IsFile() bool { + return filepath.Ext(url.Path) != "" +} + var ( ErrURLMalformedPath = errors.New("malformed path") ErrURLInvalidPathKind = errors.New("invalid path kind") From 5d855d71333009d759d3fc47260c634b3e71ba63 Mon Sep 17 00:00:00 2001 From: gfanton <8671905+gfanton@users.noreply.github.com> Date: Wed, 18 Dec 2024 14:50:32 +0100 Subject: [PATCH 04/11] feat: improve url tests Signed-off-by: gfanton <8671905+gfanton@users.noreply.github.com> --- gno.land/pkg/gnoweb/app_test.go | 1 + gno.land/pkg/gnoweb/url.go | 15 ++++++--- gno.land/pkg/gnoweb/url_test.go | 58 ++++++++++++++++++++++++--------- 3 files changed, 53 insertions(+), 21 deletions(-) diff --git a/gno.land/pkg/gnoweb/app_test.go b/gno.land/pkg/gnoweb/app_test.go index 78fe197a134..5459d6215c6 100644 --- a/gno.land/pkg/gnoweb/app_test.go +++ b/gno.land/pkg/gnoweb/app_test.go @@ -73,6 +73,7 @@ func TestRoutes(t *testing.T) { 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) diff --git a/gno.land/pkg/gnoweb/url.go b/gno.land/pkg/gnoweb/url.go index 30cadf1e21a..12b5a3220ac 100644 --- a/gno.land/pkg/gnoweb/url.go +++ b/gno.land/pkg/gnoweb/url.go @@ -4,6 +4,7 @@ import ( "errors" "fmt" "net/url" + "path/filepath" "regexp" "strings" ) @@ -17,7 +18,7 @@ const ( ) // reRealmPath match and validate a realm or package path -var rePkgOrRealmPath = regexp.MustCompile(`^/[a-z]/[a-zA-Z0-9_/]*$`) +var rePkgOrRealmPath = regexp.MustCompile(`^/[a-z]/[a-zA-Z0-9_/.]*$`) // GnoURL decomposes the parts of an URL to query a realm. type GnoURL struct { @@ -116,10 +117,14 @@ func ParseGnoURL(u *url.URL) (*GnoURL, error) { } // XXX: should we lower case the path ? + upath, err := url.PathUnescape(path) + if err != nil { + return nil, fmt.Errorf("unable to unescape path %q: %w", args, err) + } // Validate path format - if !rePkgOrRealmPath.MatchString(path) { - return nil, fmt.Errorf("%w: %q", ErrURLMalformedPath, path) + if !rePkgOrRealmPath.MatchString(upath) { + return nil, fmt.Errorf("%w: %q", ErrURLMalformedPath, upath) } webquery := url.Values{} @@ -132,11 +137,11 @@ func ParseGnoURL(u *url.URL) (*GnoURL, error) { 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(), diff --git a/gno.land/pkg/gnoweb/url_test.go b/gno.land/pkg/gnoweb/url_test.go index f6e81f9cbe1..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,8 +47,8 @@ func TestParseGnoURL(t *testing.T) { Query: url.Values{}, Domain: "gno.land", }, - Err: nil, }, + { Name: "path args + webquery", Input: "https://gno.land/r/demo/foo:example$tz=Europe/Paris", @@ -60,8 +61,8 @@ func TestParseGnoURL(t *testing.T) { Query: url.Values{}, Domain: "gno.land", }, - Err: nil, }, + { Name: "path args + webquery + query", Input: "https://gno.land/r/demo/foo:example$tz=Europe/Paris?hello=42", @@ -76,8 +77,8 @@ func TestParseGnoURL(t *testing.T) { }, Domain: "gno.land", }, - Err: nil, }, + { Name: "webquery inside query", Input: "https://gno.land/r/demo/foo:example?value=42$tz=Europe/Paris", @@ -90,8 +91,8 @@ func TestParseGnoURL(t *testing.T) { }, Domain: "gno.land", }, - Err: nil, }, + { Name: "webquery escaped $", Input: "https://gno.land/r/demo/foo:example%24hello=43$hello=42", @@ -104,14 +105,20 @@ func TestParseGnoURL(t *testing.T) { Query: url.Values{}, Domain: "gno.land", }, - Err: nil, }, + { - Name: "invalid path kind", - Input: "https://gno.land/x/demo/foo", - Expected: nil, - Err: ErrURLMalformedPath, + 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/", @@ -122,8 +129,8 @@ func TestParseGnoURL(t *testing.T) { Query: url.Values{}, Domain: "gno.land", }, - Err: nil, }, + { Name: "complex query", Input: "https://gno.land/r/demo/foo$help?func=Bar&name=Baz&age=30", @@ -140,8 +147,8 @@ func TestParseGnoURL(t *testing.T) { }, Domain: "gno.land", }, - Err: nil, }, + { Name: "multiple web queries", Input: "https://gno.land/r/demo/foo$help&func=Bar$test=123", @@ -150,14 +157,34 @@ func TestParseGnoURL(t *testing.T) { Args: "", WebQuery: url.Values{ "help": []string{""}, - "func": []string{"Bar"}, - "test": []string{"123"}, + "func": []string{"Bar$test=123"}, }, Query: url.Values{}, Domain: "gno.land", }, - Err: nil, }, + + { + 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", @@ -170,7 +197,6 @@ func TestParseGnoURL(t *testing.T) { Query: url.Values{}, Domain: "gno.land", }, - Err: nil, }, } From 9f7b1111645084b0f11ce5c839ec517e0d1e9f7e Mon Sep 17 00:00:00 2001 From: gfanton <8671905+gfanton@users.noreply.github.com> Date: Wed, 18 Dec 2024 15:15:31 +0100 Subject: [PATCH 05/11] fix: simplify more and add comments Signed-off-by: gfanton <8671905+gfanton@users.noreply.github.com> --- gno.land/pkg/gnoweb/url.go | 89 ++++++++++++++++++-------------------- 1 file changed, 41 insertions(+), 48 deletions(-) diff --git a/gno.land/pkg/gnoweb/url.go b/gno.land/pkg/gnoweb/url.go index 12b5a3220ac..2130d7332bd 100644 --- a/gno.land/pkg/gnoweb/url.go +++ b/gno.land/pkg/gnoweb/url.go @@ -12,12 +12,12 @@ import ( type PathKind byte const ( - KindInvalid PathKind = 0 + KindUnknown PathKind = 0 KindRealm PathKind = 'r' KindPure PathKind = 'p' ) -// reRealmPath match and validate a realm or package path +// 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. @@ -32,74 +32,67 @@ 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 { - // Check if the first and third character is '/' and extract the next character - if len(url.Path) > 2 && url.Path[0] == '/' && url.Path[2] == '/' { - switch k := PathKind(url.Path[1]); k { +// 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 } -func (url GnoURL) IsDir() bool { - if pathlen := len(url.Path); pathlen > 0 { - return url.Path[pathlen-1] == '/' - } - - return false +// 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] == '/' } -func (url GnoURL) IsFile() bool { - return filepath.Ext(url.Path) != "" +// IsFile checks if the URL path represents a file. +func (gnoURL GnoURL) IsFile() bool { + return filepath.Ext(gnoURL.Path) != "" } var ( @@ -107,6 +100,7 @@ var ( ErrURLInvalidPathKind = errors.New("invalid path kind") ) +// ParseGnoURL parses a URL into a GnoURL structure, extracting and validating its components. func ParseGnoURL(u *url.URL) (*GnoURL, error) { var webargs string path, args, found := strings.Cut(u.EscapedPath(), ":") @@ -116,22 +110,20 @@ func ParseGnoURL(u *url.URL) (*GnoURL, error) { path, webargs, _ = strings.Cut(path, "$") } - // XXX: should we lower case the path ? upath, err := url.PathUnescape(path) if err != nil { - return nil, fmt.Errorf("unable to unescape path %q: %w", args, err) + return nil, fmt.Errorf("unable to unescape path %q: %w", path, err) } - // Validate path format if !rePkgOrRealmPath.MatchString(upath) { return nil, fmt.Errorf("%w: %q", ErrURLMalformedPath, upath) } webquery := url.Values{} if len(webargs) > 0 { - var err error - if webquery, err = url.ParseQuery(webargs); err != nil { - return nil, fmt.Errorf("unable to parse webquery %q: %w ", webquery, err) + var parseErr error + if webquery, parseErr = url.ParseQuery(webargs); parseErr != nil { + return nil, fmt.Errorf("unable to parse webquery %q: %w", webargs, parseErr) } } @@ -149,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") } From d44ea6b543914604d50eff9cda34e58f12838d1a Mon Sep 17 00:00:00 2001 From: gfanton <8671905+gfanton@users.noreply.github.com> Date: Wed, 18 Dec 2024 17:14:17 +0100 Subject: [PATCH 06/11] chore: lint Signed-off-by: gfanton <8671905+gfanton@users.noreply.github.com> --- gno.land/pkg/gnoweb/handler.go | 7 ------- 1 file changed, 7 deletions(-) diff --git a/gno.land/pkg/gnoweb/handler.go b/gno.land/pkg/gnoweb/handler.go index 7235a0687ae..4c6826defa4 100644 --- a/gno.land/pkg/gnoweb/handler.go +++ b/gno.land/pkg/gnoweb/handler.go @@ -373,10 +373,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 != "" -} From 6a07ed2d2e98e457ffff2282d1eb96579b048331 Mon Sep 17 00:00:00 2001 From: gfanton <8671905+gfanton@users.noreply.github.com> Date: Thu, 19 Dec 2024 21:24:30 +0100 Subject: [PATCH 07/11] feat: add bitwise flags to encode url Signed-off-by: gfanton <8671905+gfanton@users.noreply.github.com> --- gno.land/pkg/gnoweb/handler.go | 2 +- gno.land/pkg/gnoweb/url.go | 69 +++++++++++++++++++-------------- gno.land/pkg/gnoweb/url_test.go | 1 - 3 files changed, 41 insertions(+), 31 deletions(-) diff --git a/gno.land/pkg/gnoweb/handler.go b/gno.land/pkg/gnoweb/handler.go index 4c6826defa4..8ba258bc309 100644 --- a/gno.land/pkg/gnoweb/handler.go +++ b/gno.land/pkg/gnoweb/handler.go @@ -165,7 +165,7 @@ func (h *WebHandler) renderPackage(w io.Writer, gnourl *GnoURL) (status int, err // Render content into the content buffer var content bytes.Buffer - meta, err := h.webcli.Render(&content, gnourl.Path, gnourl.EncodeArgs()) + meta, err := h.webcli.Render(&content, gnourl.Path, gnourl.EncodeArgsQuery()) if err != nil { if errors.Is(err, vm.InvalidPkgPathError{}) { return http.StatusNotFound, components.RenderStatusComponent(w, "not found") diff --git a/gno.land/pkg/gnoweb/url.go b/gno.land/pkg/gnoweb/url.go index 2130d7332bd..70275f550bf 100644 --- a/gno.land/pkg/gnoweb/url.go +++ b/gno.land/pkg/gnoweb/url.go @@ -32,46 +32,57 @@ type GnoURL struct { Query url.Values // c=d } -// EncodeArgs encodes the arguments and query parameters into a string. -func (gnoURL GnoURL) EncodeArgs() string { +type EncodeFlag int + +const ( + EncodePath EncodeFlag = 1 << iota + EncodeArgs + EncodeWebQuery + EncodeQuery +) + +// Encode encodes the URL components based on the provided flags. +func (gnoURL GnoURL) Encode(encodeFlags EncodeFlag) string { var urlstr strings.Builder - if gnoURL.Args != "" { - urlstr.WriteString(gnoURL.Args) + + if encodeFlags&EncodePath != 0 { + urlstr.WriteString(gnoURL.Path) } - if len(gnoURL.Query) > 0 { - urlstr.WriteString("?" + gnoURL.Query.Encode()) + + if encodeFlags&EncodeArgs != 0 && gnoURL.Args != "" { + if encodeFlags&EncodePath != 0 { + urlstr.WriteString(":") + } + urlstr.WriteString(gnoURL.Args) } - return urlstr.String() -} -// EncodePath encodes the path, arguments, and query parameters into a string. -func (gnoURL GnoURL) EncodePath() string { - var urlstr strings.Builder - urlstr.WriteString(gnoURL.Path) - if gnoURL.Args != "" { - urlstr.WriteString(":" + gnoURL.Args) + if encodeFlags&EncodeWebQuery != 0 && len(gnoURL.WebQuery) > 0 { + urlstr.WriteString("$" + gnoURL.WebQuery.Encode()) } - if len(gnoURL.Query) > 0 { + + if encodeFlags&EncodeQuery != 0 && len(gnoURL.Query) > 0 { urlstr.WriteString("?" + gnoURL.Query.Encode()) } + return urlstr.String() } -// EncodeWebPath encodes the path, arguments, and both web and query parameters into a string. +// EncodeArgsQuery encodes the arguments and query parameters into a string. +// This function is intended to be passed as a realm `Render` argument. +func (gnoURL GnoURL) EncodeArgsQuery() string { + return gnoURL.Encode(EncodeArgs | EncodeQuery) +} + +// EncodePathArgsQuery encodes the path, arguments, and query parameters into a string. +// This function provides the full representation of the URL without the web query. +func (gnoURL GnoURL) EncodePathArgsQuery() string { + return gnoURL.Encode(EncodePath | EncodeArgs | EncodeQuery) +} + +// EncodeWebPath encodes the path, package arguments, web query, and query into a string. +// This function provides the full representation of the URL. func (gnoURL GnoURL) EncodeWebPath() string { - var urlstr strings.Builder - urlstr.WriteString(gnoURL.Path) - if gnoURL.Args != "" { - pathEscape := escapeDollarSign(gnoURL.Args) - urlstr.WriteString(":" + pathEscape) - } - if len(gnoURL.WebQuery) > 0 { - urlstr.WriteString("$" + gnoURL.WebQuery.Encode()) - } - if len(gnoURL.Query) > 0 { - urlstr.WriteString("?" + gnoURL.Query.Encode()) - } - return urlstr.String() + return gnoURL.Encode(EncodePath | EncodeArgs | EncodeWebQuery | EncodeQuery) } // Kind determines the kind of path (invalid, realm, or pure) based on the path structure. diff --git a/gno.land/pkg/gnoweb/url_test.go b/gno.land/pkg/gnoweb/url_test.go index b15f578b69e..085d253acf0 100644 --- a/gno.land/pkg/gnoweb/url_test.go +++ b/gno.land/pkg/gnoweb/url_test.go @@ -210,7 +210,6 @@ func TestParseGnoURL(t *testing.T) { result, err := ParseGnoURL(u) if tc.Err == nil { require.NoError(t, err) - t.Logf("encoded path: %q", result.EncodePath()) t.Logf("encoded web path: %q", result.EncodeWebPath()) } else { require.Error(t, err) From ed3709c70cb4d6ffe79d0f526e7d8cb19be2339a Mon Sep 17 00:00:00 2001 From: gfanton <8671905+gfanton@users.noreply.github.com> Date: Fri, 20 Dec 2024 13:37:09 +0100 Subject: [PATCH 08/11] fix: improve url encoding + tests Signed-off-by: gfanton <8671905+gfanton@users.noreply.github.com> --- gno.land/pkg/gnoweb/app_test.go | 3 +- gno.land/pkg/gnoweb/handler.go | 2 +- gno.land/pkg/gnoweb/url.go | 60 +++++++++----- gno.land/pkg/gnoweb/url_test.go | 138 +++++++++++++++++++++++++++++++- 4 files changed, 176 insertions(+), 27 deletions(-) diff --git a/gno.land/pkg/gnoweb/app_test.go b/gno.land/pkg/gnoweb/app_test.go index 5459d6215c6..4fac6e0b971 100644 --- a/gno.land/pkg/gnoweb/app_test.go +++ b/gno.land/pkg/gnoweb/app_test.go @@ -126,7 +126,7 @@ 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") }) } @@ -144,6 +144,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/handler.go b/gno.land/pkg/gnoweb/handler.go index 8ba258bc309..4c6826defa4 100644 --- a/gno.land/pkg/gnoweb/handler.go +++ b/gno.land/pkg/gnoweb/handler.go @@ -165,7 +165,7 @@ func (h *WebHandler) renderPackage(w io.Writer, gnourl *GnoURL) (status int, err // Render content into the content buffer var content bytes.Buffer - meta, err := h.webcli.Render(&content, gnourl.Path, gnourl.EncodeArgsQuery()) + meta, err := h.webcli.Render(&content, gnourl.Path, gnourl.EncodeArgs()) if err != nil { if errors.Is(err, vm.InvalidPkgPathError{}) { return http.StatusNotFound, components.RenderStatusComponent(w, "not found") diff --git a/gno.land/pkg/gnoweb/url.go b/gno.land/pkg/gnoweb/url.go index 70275f550bf..cc494612358 100644 --- a/gno.land/pkg/gnoweb/url.go +++ b/gno.land/pkg/gnoweb/url.go @@ -18,7 +18,7 @@ const ( ) // rePkgOrRealmPath matches and validates a realm or package path. -var rePkgOrRealmPath = regexp.MustCompile(`^/[a-z]/[a-zA-Z0-9_/.]*$`) +var rePkgOrRealmPath = regexp.MustCompile(`^/[a-z][a-zA-Z0-9_/.]*$`) // GnoURL decomposes the parts of an URL to query a realm. type GnoURL struct { @@ -32,6 +32,7 @@ type GnoURL struct { Query url.Values // c=d } +// EncodeFlag is used to compose and encode URL components. type EncodeFlag int const ( @@ -39,49 +40,66 @@ const ( EncodeArgs EncodeWebQuery EncodeQuery + EncodeNoEscape // Disable escaping on arg ) +// Has checks if the EncodeFlag contains all the specified flags. +func (f EncodeFlag) Has(flags EncodeFlag) bool { + return f&flags != 0 +} + // Encode encodes the URL components based on the provided flags. func (gnoURL GnoURL) Encode(encodeFlags EncodeFlag) string { var urlstr strings.Builder - if encodeFlags&EncodePath != 0 { + if encodeFlags.Has(EncodePath) { urlstr.WriteString(gnoURL.Path) } - if encodeFlags&EncodeArgs != 0 && gnoURL.Args != "" { - if encodeFlags&EncodePath != 0 { - urlstr.WriteString(":") + if encodeFlags.Has(EncodeArgs) && gnoURL.Args != "" { + if encodeFlags.Has(EncodePath) { + urlstr.WriteRune(':') + } + + // XXX: Arguments should ideally always be escaped, + // but this may require changes in some realms. + args := gnoURL.Args + if !encodeFlags.Has(EncodeNoEscape) { + args = escapeDollarSign(url.PathEscape(args)) } - urlstr.WriteString(gnoURL.Args) + + urlstr.WriteString(args) } - if encodeFlags&EncodeWebQuery != 0 && len(gnoURL.WebQuery) > 0 { - urlstr.WriteString("$" + gnoURL.WebQuery.Encode()) + if encodeFlags.Has(EncodeWebQuery) && len(gnoURL.WebQuery) > 0 { + urlstr.WriteRune('$') + urlstr.WriteString(gnoURL.WebQuery.Encode()) } - if encodeFlags&EncodeQuery != 0 && len(gnoURL.Query) > 0 { - urlstr.WriteString("?" + gnoURL.Query.Encode()) + if encodeFlags.Has(EncodeQuery) && len(gnoURL.Query) > 0 { + urlstr.WriteRune('?') + urlstr.WriteString(gnoURL.Query.Encode()) + } return urlstr.String() } -// EncodeArgsQuery encodes the arguments and query parameters into a string. +// 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) EncodeArgsQuery() string { - return gnoURL.Encode(EncodeArgs | EncodeQuery) +func (gnoURL GnoURL) EncodeArgs() string { + return gnoURL.Encode(EncodeArgs | EncodeQuery | EncodeNoEscape) } -// EncodePathArgsQuery encodes the path, arguments, and query parameters into a string. +// EncodeURL encodes the path, arguments, and query parameters into a string. // This function provides the full representation of the URL without the web query. -func (gnoURL GnoURL) EncodePathArgsQuery() string { +func (gnoURL GnoURL) EncodeURL() string { return gnoURL.Encode(EncodePath | EncodeArgs | EncodeQuery) } -// EncodeWebPath encodes the path, package arguments, web query, and query into a string. +// EncodeWebURL encodes the path, package arguments, web query, and query into a string. // This function provides the full representation of the URL. -func (gnoURL GnoURL) EncodeWebPath() string { +func (gnoURL GnoURL) EncodeWebURL() string { return gnoURL.Encode(EncodePath | EncodeArgs | EncodeWebQuery | EncodeQuery) } @@ -106,10 +124,7 @@ func (gnoURL GnoURL) IsFile() bool { return filepath.Ext(gnoURL.Path) != "" } -var ( - ErrURLMalformedPath = errors.New("malformed path") - ErrURLInvalidPathKind = errors.New("invalid path kind") -) +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) { @@ -121,13 +136,14 @@ 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) } if !rePkgOrRealmPath.MatchString(upath) { - return nil, fmt.Errorf("%w: %q", ErrURLMalformedPath, upath) + return nil, fmt.Errorf("%w: %q", ErrURLInvalidPath, upath) } webquery := url.Values{} diff --git a/gno.land/pkg/gnoweb/url_test.go b/gno.land/pkg/gnoweb/url_test.go index 085d253acf0..06dc1908642 100644 --- a/gno.land/pkg/gnoweb/url_test.go +++ b/gno.land/pkg/gnoweb/url_test.go @@ -19,7 +19,7 @@ func TestParseGnoURL(t *testing.T) { Name: "malformed url", Input: "https://gno.land/r/dem)o:$?", Expected: nil, - Err: ErrURLMalformedPath, + Err: ErrURLInvalidPath, }, { @@ -167,7 +167,7 @@ func TestParseGnoURL(t *testing.T) { { 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 + Err: ErrURLInvalidPath, // `/r/demo/AAA$BBB` is an invalid path }, { @@ -210,7 +210,7 @@ func TestParseGnoURL(t *testing.T) { result, err := ParseGnoURL(u) if tc.Err == nil { require.NoError(t, err) - t.Logf("encoded web path: %q", result.EncodeWebPath()) + t.Logf("encoded web path: %q", result.EncodeWebURL()) } else { require.Error(t, err) require.ErrorIs(t, err, tc.Err) @@ -220,3 +220,135 @@ func TestParseGnoURL(t *testing.T) { }) } } + +func TestEncode(t *testing.T) { + testCases := []struct { + Name string + GnoURL GnoURL + EncodeFlags EncodeFlag + Expected string + }{ + { + Name: "Encode Path Only", + GnoURL: GnoURL{ + Path: "/r/demo/foo", + }, + EncodeFlags: EncodePath, + Expected: "/r/demo/foo", + }, + + { + Name: "Encode Path and Args", + GnoURL: GnoURL{ + Path: "/r/demo/foo", + Args: "example", + }, + EncodeFlags: EncodePath | EncodeArgs, + Expected: "/r/demo/foo:example", + }, + + { + Name: "Encode Path, Args, and WebQuery", + GnoURL: GnoURL{ + Path: "/r/demo/foo", + Args: "example", + WebQuery: url.Values{ + "tz": []string{"Europe/Paris"}, + }, + }, + EncodeFlags: EncodePath | EncodeArgs | EncodeWebQuery, + Expected: "/r/demo/foo:example$tz=Europe%2FParis", + }, + + { + Name: "Encode Full URL", + GnoURL: GnoURL{ + Path: "/r/demo/foo", + Args: "example", + WebQuery: url.Values{ + "tz": []string{"Europe/Paris"}, + }, + Query: url.Values{ + "hello": []string{"42"}, + }, + }, + EncodeFlags: EncodePath | EncodeArgs | EncodeWebQuery | EncodeQuery, + Expected: "/r/demo/foo:example$tz=Europe%2FParis?hello=42", + }, + + { + Name: "Encode Args and Query", + GnoURL: GnoURL{ + Path: "/r/demo/foo", + Args: "hello Jo$ny", + Query: url.Values{ + "hello": []string{"42"}, + }, + }, + EncodeFlags: EncodeArgs | EncodeQuery, + Expected: "hello%20Jo%24ny?hello=42", + }, + + { + Name: "Encode Args and Query (No Escape)", + GnoURL: GnoURL{ + Path: "/r/demo/foo", + Args: "hello Jo$ny", + Query: url.Values{ + "hello": []string{"42"}, + }, + }, + EncodeFlags: EncodeArgs | EncodeQuery | EncodeNoEscape, + Expected: "hello Jo$ny?hello=42", + }, + + { + Name: "Encode Args and Query", + GnoURL: GnoURL{ + Path: "/r/demo/foo", + Args: "example", + Query: url.Values{ + "hello": []string{"42"}, + }, + }, + EncodeFlags: EncodeArgs | EncodeQuery, + Expected: "example?hello=42", + }, + + { + Name: "Encode with Escaped Characters", + GnoURL: GnoURL{ + Path: "/r/demo/foo", + Args: "example with spaces", + WebQuery: url.Values{ + "tz": []string{"Europe/Paris"}, + }, + Query: url.Values{ + "hello": []string{"42"}, + }, + }, + EncodeFlags: EncodePath | EncodeArgs | EncodeWebQuery | EncodeQuery, + Expected: "/r/demo/foo:example%20with%20spaces$tz=Europe%2FParis?hello=42", + }, + + { + Name: "Encode Path, Args, and Query", + GnoURL: GnoURL{ + Path: "/r/demo/foo", + Args: "example", + Query: url.Values{ + "hello": []string{"42"}, + }, + }, + EncodeFlags: EncodePath | EncodeArgs | EncodeQuery, + Expected: "/r/demo/foo:example?hello=42", + }, + } + + for _, tc := range testCases { + t.Run(tc.Name, func(t *testing.T) { + result := tc.GnoURL.Encode(tc.EncodeFlags) + assert.Equal(t, tc.Expected, result) + }) + } +} From 3a5a71cf50ddce4a70e4c24bda3089127bcf9255 Mon Sep 17 00:00:00 2001 From: gfanton <8671905+gfanton@users.noreply.github.com> Date: Fri, 20 Dec 2024 14:45:35 +0100 Subject: [PATCH 09/11] feat(url): add IsValid method Signed-off-by: gfanton <8671905+gfanton@users.noreply.github.com> --- gno.land/pkg/gnoweb/url.go | 5 +++++ gno.land/pkg/gnoweb/url_test.go | 1 + 2 files changed, 6 insertions(+) diff --git a/gno.land/pkg/gnoweb/url.go b/gno.land/pkg/gnoweb/url.go index cc494612358..06226720b54 100644 --- a/gno.land/pkg/gnoweb/url.go +++ b/gno.land/pkg/gnoweb/url.go @@ -49,6 +49,7 @@ func (f EncodeFlag) Has(flags EncodeFlag) bool { } // Encode encodes the URL components based on the provided flags. +// Encode assums the URL is valid. func (gnoURL GnoURL) Encode(encodeFlags EncodeFlag) string { var urlstr strings.Builder @@ -114,6 +115,10 @@ func (gnoURL GnoURL) Kind() PathKind { return KindUnknown } +func (gnoURL GnoURL) IsValid() bool { + return rePkgOrRealmPath.MatchString(gnoURL.Path) +} + // 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] == '/' diff --git a/gno.land/pkg/gnoweb/url_test.go b/gno.land/pkg/gnoweb/url_test.go index 06dc1908642..90b58e53278 100644 --- a/gno.land/pkg/gnoweb/url_test.go +++ b/gno.land/pkg/gnoweb/url_test.go @@ -348,6 +348,7 @@ func TestEncode(t *testing.T) { for _, tc := range testCases { t.Run(tc.Name, func(t *testing.T) { result := tc.GnoURL.Encode(tc.EncodeFlags) + require.True(t, tc.GnoURL.IsValid(), "gno url is not valid") assert.Equal(t, tc.Expected, result) }) } From 6ffc3106cb520e0a5ba959dff3f7994beebfd1f5 Mon Sep 17 00:00:00 2001 From: gfanton <8671905+gfanton@users.noreply.github.com> Date: Fri, 20 Dec 2024 15:30:39 +0100 Subject: [PATCH 10/11] chore: lint Signed-off-by: gfanton <8671905+gfanton@users.noreply.github.com> --- gno.land/pkg/gnoweb/url.go | 1 - 1 file changed, 1 deletion(-) diff --git a/gno.land/pkg/gnoweb/url.go b/gno.land/pkg/gnoweb/url.go index 06226720b54..66c74a349c8 100644 --- a/gno.land/pkg/gnoweb/url.go +++ b/gno.land/pkg/gnoweb/url.go @@ -80,7 +80,6 @@ func (gnoURL GnoURL) Encode(encodeFlags EncodeFlag) string { if encodeFlags.Has(EncodeQuery) && len(gnoURL.Query) > 0 { urlstr.WriteRune('?') urlstr.WriteString(gnoURL.Query.Encode()) - } return urlstr.String() From bff4a0b008b54c67ba837f1ec1d0753660c24fdc Mon Sep 17 00:00:00 2001 From: Guilhem Fanton <8671905+gfanton@users.noreply.github.com> Date: Fri, 20 Dec 2024 22:57:16 +0100 Subject: [PATCH 11/11] Update gno.land/pkg/gnoweb/url.go - typo Co-authored-by: Morgan --- gno.land/pkg/gnoweb/url.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gno.land/pkg/gnoweb/url.go b/gno.land/pkg/gnoweb/url.go index 66c74a349c8..786be3227d6 100644 --- a/gno.land/pkg/gnoweb/url.go +++ b/gno.land/pkg/gnoweb/url.go @@ -49,7 +49,7 @@ func (f EncodeFlag) Has(flags EncodeFlag) bool { } // Encode encodes the URL components based on the provided flags. -// Encode assums the URL is valid. +// Encode assumes the URL is valid. func (gnoURL GnoURL) Encode(encodeFlags EncodeFlag) string { var urlstr strings.Builder