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..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,26 +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, - strings.HasSuffix(gnourl.Path, "/"), - isFile(gnourl.Path): - i := strings.LastIndexByte(gnourl.Path, '/') - if i < 0 { - return http.StatusInternalServerError, fmt.Errorf("unable to get ending slash for %q", gnourl.Path) - } - + case gnourl.IsFile(): // Fill webquery with file infos - gnourl.WebQuery.Set("source", "") // set source - - file := gnourl.Path[i+1:] - if file == "" { - return h.renderRealmDirectory(w, gnourl) - } - - 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 @@ -250,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") @@ -370,10 +357,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..105ac382800 100644 --- a/gno.land/pkg/gnoweb/url.go +++ b/gno.land/pkg/gnoweb/url.go @@ -4,145 +4,212 @@ import ( "errors" "fmt" "net/url" + "path/filepath" "regexp" "strings" ) -type PathKind byte +var ErrURLInvalidPath = errors.New("invalid path") -const ( - KindInvalid PathKind = 0 - KindRealm PathKind = 'r' - KindPure PathKind = 'p' -) +// 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 } -func (url GnoURL) EncodeArgs() string { +// EncodeFlag is used to specify which URL components to encode. +type EncodeFlag int + +const ( + 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 +) + +// 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 - if url.Args != "" { - urlstr.WriteString(url.Args) + + if encodeFlags.Has(EncodePath) { + path := gnoURL.Path + if !encodeFlags.Has(EncodeNoEscape) { + path = url.PathEscape(path) + } + + urlstr.WriteString(gnoURL.Path) } - if len(url.Query) > 0 { - urlstr.WriteString("?" + url.Query.Encode()) + if len(gnoURL.File) > 0 { + urlstr.WriteRune('/') + urlstr.WriteString(gnoURL.File) } - return urlstr.String() -} + if encodeFlags.Has(EncodeArgs) && gnoURL.Args != "" { + if encodeFlags.Has(EncodePath) { + urlstr.WriteRune(':') + } -func (url GnoURL) EncodePath() string { - var urlstr strings.Builder - urlstr.WriteString(url.Path) - if url.Args != "" { - urlstr.WriteString(":" + url.Args) + // 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 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) EncodeWebPath() string { - var urlstr strings.Builder - urlstr.WriteString(url.Path) - if url.Args != "" { - pathEscape := escapeDollarSign(url.Args) - urlstr.WriteString(":" + pathEscape) - } +// Has checks if the EncodeFlag contains all the specified flags. +func (f EncodeFlag) Has(flags EncodeFlag) bool { + return f&flags != 0 +} - if len(url.WebQuery) > 0 { - urlstr.WriteString("$" + url.WebQuery.Encode()) - } +func escapeDollarSign(s string) string { + return strings.ReplaceAll(s, "$", "%24") +} - if len(url.Query) > 0 { - urlstr.WriteString("?" + url.Query.Encode()) - } +// 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) +} - return urlstr.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) EncodeURL() string { + return gnoURL.Encode(EncodePath | EncodeArgs | EncodeQuery) } -func (url GnoURL) Kind() PathKind { - if len(url.Path) < 2 { - return KindInvalid - } - pk := PathKind(url.Path[1]) - switch pk { - case KindPure, KindRealm: - return pk - } - return KindInvalid +// 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) } -var ( - ErrURLMalformedPath = errors.New("malformed URL path") - ErrURLInvalidPathKind = errors.New("invalid path kind") -) +// IsPure checks if the URL path represents a pure path. +func (gnoURL GnoURL) IsPure() bool { + return strings.HasPrefix(gnoURL.Path, "/p/") +} -// 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 `:` -) +// 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 !gnoURL.IsFile() && + len(gnoURL.Path) > 0 && gnoURL.Path[len(gnoURL.Path)-1] == '/' +} + +func (gnoURL GnoURL) IsValid() bool { + return rePkgOrRealmPath.MatchString(gnoURL.Path) +} +// ParseGnoURL parses a URL into a GnoURL structure, extracting and validating its components. func ParseGnoURL(u *url.URL) (*GnoURL, error) { - matches := reRealmPath.FindStringSubmatch(u.EscapedPath()) - if len(matches) != 3 { - return nil, fmt.Errorf("%w: %s", ErrURLMalformedPath, u.Path) + var webargs string + path, args, found := strings.Cut(u.EscapedPath(), ":") + if found { + args, webargs, _ = strings.Cut(args, "$") + } else { + path, webargs, _ = strings.Cut(path, "$") + } + + upath, err := url.PathUnescape(path) + if err != nil { + return nil, fmt.Errorf("unable to unescape path %q: %w", path, err) } - path := matches[1] - args := matches[2] + var file string + + // 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) - if len(args) > 0 { - switch args[0] { - case ':': - args = args[1:] - case '$': - default: - return nil, fmt.Errorf("%w: %s", ErrURLMalformedPath, u.Path) + // Trim last slash if any + if i := strings.LastIndexByte(upath, '/'); i > 0 { + upath = upath[:i] } } - var err error + if !rePkgOrRealmPath.MatchString(upath) { + return nil, fmt.Errorf("%w: %q", ErrURLInvalidPath, upath) + } + 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(), Domain: u.Hostname(), + File: file, }, nil } - -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..b4e901d4f10 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,32 @@ func TestParseGnoURL(t *testing.T) { WebQuery: url.Values{}, Query: url.Values{}, }, - Err: nil, }, + + { + 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", @@ -46,7 +71,6 @@ func TestParseGnoURL(t *testing.T) { Query: url.Values{}, Domain: "gno.land", }, - Err: nil, }, { @@ -61,7 +85,6 @@ func TestParseGnoURL(t *testing.T) { Query: url.Values{}, Domain: "gno.land", }, - Err: nil, }, { @@ -78,7 +101,6 @@ func TestParseGnoURL(t *testing.T) { }, Domain: "gno.land", }, - Err: nil, }, { @@ -93,7 +115,6 @@ func TestParseGnoURL(t *testing.T) { }, Domain: "gno.land", }, - Err: nil, }, { @@ -108,22 +129,140 @@ 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", + }, + }, + + { + 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", + }, + }, + + { + 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 { 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 +272,157 @@ 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 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{ + 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) + }) + } +}