diff --git a/gno.land/pkg/gnoweb/app_test.go b/gno.land/pkg/gnoweb/app_test.go index 78fe197a134..4fac6e0b971 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) @@ -125,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") }) } @@ -143,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 bc87f057e26..53e3a52448a 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] @@ -370,10 +371,3 @@ func generateBreadcrumbPaths(path string) []components.BreadcrumbPart { return parts } - -// IsFile checks if the last element of the path is a file (has an extension) -func isFile(path string) bool { - base := filepath.Base(path) - ext := filepath.Ext(base) - return ext != "" -} diff --git a/gno.land/pkg/gnoweb/url.go b/gno.land/pkg/gnoweb/url.go index bc03f2182d9..786be3227d6 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" ) @@ -11,11 +12,14 @@ import ( type PathKind byte const ( - KindInvalid PathKind = 0 + KindUnknown PathKind = 0 KindRealm PathKind = 'r' KindPure PathKind = 'p' ) +// rePkgOrRealmPath matches and validates a realm or package path. +var rePkgOrRealmPath = regexp.MustCompile(`^/[a-z][a-zA-Z0-9_/.]*$`) + // GnoURL decomposes the parts of an URL to query a realm. type GnoURL struct { // Example full path: @@ -28,114 +32,139 @@ type GnoURL struct { Query url.Values // c=d } -func (url GnoURL) EncodeArgs() string { - var urlstr strings.Builder - if url.Args != "" { - urlstr.WriteString(url.Args) - } +// EncodeFlag is used to compose and encode URL components. +type EncodeFlag int - if len(url.Query) > 0 { - urlstr.WriteString("?" + url.Query.Encode()) - } +const ( + EncodePath EncodeFlag = 1 << iota + EncodeArgs + EncodeWebQuery + EncodeQuery + EncodeNoEscape // Disable escaping on arg +) - 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 (url GnoURL) EncodePath() string { +// 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 - urlstr.WriteString(url.Path) - if url.Args != "" { - urlstr.WriteString(":" + url.Args) - } - if len(url.Query) > 0 { - urlstr.WriteString("?" + url.Query.Encode()) + if encodeFlags.Has(EncodePath) { + urlstr.WriteString(gnoURL.Path) } - return urlstr.String() -} + if encodeFlags.Has(EncodeArgs) && gnoURL.Args != "" { + if encodeFlags.Has(EncodePath) { + urlstr.WriteRune(':') + } -func (url GnoURL) EncodeWebPath() string { - var urlstr strings.Builder - urlstr.WriteString(url.Path) - if url.Args != "" { - pathEscape := escapeDollarSign(url.Args) - urlstr.WriteString(":" + pathEscape) + // 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(args) } - if len(url.WebQuery) > 0 { - urlstr.WriteString("$" + url.WebQuery.Encode()) + if encodeFlags.Has(EncodeWebQuery) && len(gnoURL.WebQuery) > 0 { + urlstr.WriteRune('$') + urlstr.WriteString(gnoURL.WebQuery.Encode()) } - if len(url.Query) > 0 { - urlstr.WriteString("?" + url.Query.Encode()) + if encodeFlags.Has(EncodeQuery) && len(gnoURL.Query) > 0 { + urlstr.WriteRune('?') + urlstr.WriteString(gnoURL.Query.Encode()) } return urlstr.String() } -func (url GnoURL) Kind() PathKind { - if len(url.Path) < 2 { - return KindInvalid - } - pk := PathKind(url.Path[1]) - switch pk { - case KindPure, KindRealm: - return pk +// 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 { + return gnoURL.Encode(EncodeArgs | EncodeQuery | EncodeNoEscape) +} + +// 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) EncodeURL() string { + return gnoURL.Encode(EncodePath | EncodeArgs | EncodeQuery) +} + +// 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) 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 KindInvalid + return KindUnknown } -var ( - ErrURLMalformedPath = errors.New("malformed URL path") - ErrURLInvalidPathKind = errors.New("invalid path kind") -) +func (gnoURL GnoURL) IsValid() bool { + return rePkgOrRealmPath.MatchString(gnoURL.Path) +} -// 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 `:` -) +// 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] == '/' +} +// IsFile checks if the URL path represents a file. +func (gnoURL GnoURL) IsFile() bool { + return filepath.Ext(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) { - 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] + // 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 len(args) > 0 { - switch args[0] { - case ':': - args = args[1:] - case '$': - default: - return nil, fmt.Errorf("%w: %s", ErrURLMalformedPath, u.Path) - } + if !rePkgOrRealmPath.MatchString(upath) { + return nil, fmt.Errorf("%w: %q", ErrURLInvalidPath, upath) } - var err error webquery := url.Values{} - args, webargs, found := strings.Cut(args, "$") - if found { - if webquery, err = url.ParseQuery(webargs); err != nil { - return nil, fmt.Errorf("unable to parse webquery %q: %w ", webquery, err) + if len(webargs) > 0 { + var parseErr error + if webquery, parseErr = url.ParseQuery(webargs); parseErr != nil { + return nil, fmt.Errorf("unable to parse webquery %q: %w", webargs, parseErr) } } uargs, err := url.PathUnescape(args) if err != nil { - return nil, fmt.Errorf("unable to unescape path %q: %w", args, err) + return nil, fmt.Errorf("unable to unescape args %q: %w", args, err) } return &GnoURL{ - Path: path, + Path: upath, Args: uargs, WebQuery: webquery, Query: u.Query(), @@ -143,6 +172,7 @@ func ParseGnoURL(u *url.URL) (*GnoURL, error) { }, nil } +// escapeDollarSign replaces dollar signs with their URL-encoded equivalent. func escapeDollarSign(s string) string { return strings.ReplaceAll(s, "$", "%24") } diff --git a/gno.land/pkg/gnoweb/url_test.go b/gno.land/pkg/gnoweb/url_test.go index 73cfdda69bd..90b58e53278 100644 --- a/gno.land/pkg/gnoweb/url_test.go +++ b/gno.land/pkg/gnoweb/url_test.go @@ -19,8 +19,9 @@ func TestParseGnoURL(t *testing.T) { Name: "malformed url", Input: "https://gno.land/r/dem)o:$?", Expected: nil, - Err: ErrURLMalformedPath, + Err: ErrURLInvalidPath, }, + { Name: "simple", Input: "https://gno.land/r/simple/test", @@ -30,8 +31,8 @@ func TestParseGnoURL(t *testing.T) { WebQuery: url.Values{}, Query: url.Values{}, }, - Err: nil, }, + { Name: "webquery + query", Input: "https://gno.land/r/demo/foo$help&func=Bar&name=Baz", @@ -46,7 +47,6 @@ func TestParseGnoURL(t *testing.T) { Query: url.Values{}, Domain: "gno.land", }, - Err: nil, }, { @@ -61,7 +61,6 @@ func TestParseGnoURL(t *testing.T) { Query: url.Values{}, Domain: "gno.land", }, - Err: nil, }, { @@ -78,7 +77,6 @@ func TestParseGnoURL(t *testing.T) { }, Domain: "gno.land", }, - Err: nil, }, { @@ -93,7 +91,6 @@ func TestParseGnoURL(t *testing.T) { }, Domain: "gno.land", }, - Err: nil, }, { @@ -108,22 +105,112 @@ func TestParseGnoURL(t *testing.T) { Query: url.Values{}, Domain: "gno.land", }, - Err: nil, }, - // XXX: more tests + { + Name: "unknown path kind", + Input: "https://gno.land/x/demo/foo", + Expected: &GnoURL{ + Path: "/x/demo/foo", + Args: "", + WebQuery: url.Values{}, + Query: url.Values{}, + Domain: "gno.land", + }, + }, + + { + Name: "empty path", + Input: "https://gno.land/r/", + Expected: &GnoURL{ + Path: "/r/", + Args: "", + WebQuery: url.Values{}, + Query: url.Values{}, + Domain: "gno.land", + }, + }, + + { + Name: "complex query", + Input: "https://gno.land/r/demo/foo$help?func=Bar&name=Baz&age=30", + Expected: &GnoURL{ + Path: "/r/demo/foo", + Args: "", + WebQuery: url.Values{ + "help": []string{""}, + }, + Query: url.Values{ + "func": []string{"Bar"}, + "name": []string{"Baz"}, + "age": []string{"30"}, + }, + Domain: "gno.land", + }, + }, + + { + Name: "multiple web queries", + Input: "https://gno.land/r/demo/foo$help&func=Bar$test=123", + Expected: &GnoURL{ + Path: "/r/demo/foo", + Args: "", + WebQuery: url.Values{ + "help": []string{""}, + "func": []string{"Bar$test=123"}, + }, + Query: url.Values{}, + Domain: "gno.land", + }, + }, + + { + Name: "webquery-args-webquery", + Input: "https://gno.land/r/demo/AAA$BBB:CCC&DDD$EEE", + Err: ErrURLInvalidPath, // `/r/demo/AAA$BBB` is an invalid path + }, + + { + Name: "args-webquery-args", + Input: "https://gno.land/r/demo/AAA:BBB$CCC&DDD:EEE", + Expected: &GnoURL{ + Domain: "gno.land", + Path: "/r/demo/AAA", + Args: "BBB", + WebQuery: url.Values{ + "CCC": []string{""}, + "DDD:EEE": []string{""}, + }, + Query: url.Values{}, + }, + }, + + { + Name: "escaped characters in args", + Input: "https://gno.land/r/demo/foo:example%20with%20spaces$tz=Europe/Paris", + Expected: &GnoURL{ + Path: "/r/demo/foo", + Args: "example with spaces", + WebQuery: url.Values{ + "tz": []string{"Europe/Paris"}, + }, + Query: url.Values{}, + Domain: "gno.land", + }, + }, } for _, tc := range testCases { t.Run(tc.Name, func(t *testing.T) { + t.Logf("testing input: %q", tc.Input) + u, err := url.Parse(tc.Input) require.NoError(t, err) result, err := ParseGnoURL(u) if tc.Err == nil { require.NoError(t, err) - t.Logf("parsed: %s", result.EncodePath()) - t.Logf("parsed web: %s", result.EncodeWebPath()) + t.Logf("encoded web path: %q", result.EncodeWebURL()) } else { require.Error(t, err) require.ErrorIs(t, err, tc.Err) @@ -133,3 +220,136 @@ 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) + require.True(t, tc.GnoURL.IsValid(), "gno url is not valid") + assert.Equal(t, tc.Expected, result) + }) + } +}