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/14] 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/14] 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/14] 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/14] 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/14] 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/14] 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/14] 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/14] 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/14] 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/14] 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/14] 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 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 12/14] 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{ From 62b005ac181ae7b61752918c4f84f0b0dc37e7f7 Mon Sep 17 00:00:00 2001 From: gfanton <8671905+gfanton@users.noreply.github.com> Date: Mon, 6 Jan 2025 10:59:35 +0100 Subject: [PATCH 13/14] fix: check for upcase string as file Signed-off-by: gfanton <8671905+gfanton@users.noreply.github.com> --- gno.land/pkg/gnoweb/url.go | 13 +++++++++---- gno.land/pkg/gnoweb/url_test.go | 13 +++++++++++++ 2 files changed, 22 insertions(+), 4 deletions(-) diff --git a/gno.land/pkg/gnoweb/url.go b/gno.land/pkg/gnoweb/url.go index 6ad218b9a36..cc37ae36887 100644 --- a/gno.land/pkg/gnoweb/url.go +++ b/gno.land/pkg/gnoweb/url.go @@ -152,11 +152,16 @@ func ParseGnoURL(u *url.URL) (*GnoURL, error) { } var file string - if ext := filepath.Ext(upath); ext != "" { - file = filepath.Base(upath) - upath = strings.TrimSuffix(upath, file) - // Trim last slash + // A file is considered as one that either ends with an extension or + // contains an uppercase rune + ext := filepath.Ext(upath) + base := filepath.Base(upath) + if ext != "" || strings.ToLower(base) != base { + file = base + upath = strings.TrimSuffix(upath, base) + + // Trim last slash if any if i := strings.LastIndexByte(upath, '/'); i > 0 { upath = upath[:i] } diff --git a/gno.land/pkg/gnoweb/url_test.go b/gno.land/pkg/gnoweb/url_test.go index f4729668d71..b4e901d4f10 100644 --- a/gno.land/pkg/gnoweb/url_test.go +++ b/gno.land/pkg/gnoweb/url_test.go @@ -237,6 +237,19 @@ func TestParseGnoURL(t *testing.T) { Domain: "gno.land", }, }, + + { + Name: "no extension file", + Input: "https://gno.land/r/demo/lIcEnSe", + Expected: &GnoURL{ + Path: "/r/demo", + File: "lIcEnSe", + Args: "", + WebQuery: url.Values{}, + Query: url.Values{}, + Domain: "gno.land", + }, + }, } for _, tc := range testCases { From b3c9463eaa5926cc0cb1eb3da0524d88d91c4614 Mon Sep 17 00:00:00 2001 From: gfanton <8671905+gfanton@users.noreply.github.com> Date: Tue, 7 Jan 2025 15:24:15 +0100 Subject: [PATCH 14/14] fix: add some documentation on Encode function Signed-off-by: gfanton <8671905+gfanton@users.noreply.github.com> --- gno.land/pkg/gnoweb/url.go | 32 ++++++++++++++++++++++++++------ 1 file changed, 26 insertions(+), 6 deletions(-) diff --git a/gno.land/pkg/gnoweb/url.go b/gno.land/pkg/gnoweb/url.go index cc37ae36887..105ac382800 100644 --- a/gno.land/pkg/gnoweb/url.go +++ b/gno.land/pkg/gnoweb/url.go @@ -38,12 +38,27 @@ const ( EncodeNoEscape // Disable escaping of arguments ) -// 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. +// Encode constructs a URL string from the components of a GnoURL struct, +// encoding the specified components based on the provided EncodeFlag bitmask. +// +// The function selectively encodes the URL's path, arguments, web query, and +// query parameters, depending on the flags set in encodeFlags. +// +// Returns a string representing the encoded URL. +// +// Example: +// +// gnoURL := GnoURL{ +// Domain: "gno.land", +// Path: "/r/demo/users", +// Args: "john", +// File: "render.gno", +// } +// +// encodedURL := gnoURL.Encode(EncodePath | EncodeArgs) +// fmt.Println(encodedURL) // Output: /r/demo/users/render.gno:john +// +// URL components are encoded using url.PathEscape unless EncodeNoEscape is specified. func (gnoURL GnoURL) Encode(encodeFlags EncodeFlag) string { var urlstr strings.Builder @@ -89,6 +104,11 @@ func (gnoURL GnoURL) Encode(encodeFlags EncodeFlag) string { return urlstr.String() } +// Has checks if the EncodeFlag contains all the specified flags. +func (f EncodeFlag) Has(flags EncodeFlag) bool { + return f&flags != 0 +} + func escapeDollarSign(s string) string { return strings.ReplaceAll(s, "$", "%24") }