Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix(gnoweb): simplify url parsing system #3366

Merged
merged 23 commits into from
Jan 8, 2025
Merged
Show file tree
Hide file tree
Changes from 20 commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
f82ec43
fix: simplify url system
gfanton Dec 17, 2024
e28401a
wip: more tests
gfanton Dec 18, 2024
4c6535a
fix: improve url
gfanton Dec 18, 2024
5d855d7
feat: improve url tests
gfanton Dec 18, 2024
039b9ed
Merge branch 'master' into fix/fgnoweb/simplify-url
gfanton Dec 18, 2024
9f7b111
fix: simplify more and add comments
gfanton Dec 18, 2024
d44ea6b
chore: lint
gfanton Dec 18, 2024
d809304
Merge branch 'master' into fix/fgnoweb/simplify-url
gfanton Dec 18, 2024
7db64ce
Merge branch 'master' into fix/fgnoweb/simplify-url
gfanton Dec 19, 2024
6a07ed2
feat: add bitwise flags to encode url
gfanton Dec 19, 2024
ed3709c
fix: improve url encoding + tests
gfanton Dec 20, 2024
3a5a71c
feat(url): add IsValid method
gfanton Dec 20, 2024
6ffc310
chore: lint
gfanton Dec 20, 2024
0c41f48
Merge branch 'master' into fix/fgnoweb/simplify-url
gfanton Dec 20, 2024
c88c186
Merge branch 'master' into fix/fgnoweb/simplify-url
gfanton Dec 20, 2024
bff4a0b
Update gno.land/pkg/gnoweb/url.go - typo
gfanton Dec 20, 2024
90318c7
Merge branch 'master' into fix/fgnoweb/simplify-url
gfanton Dec 23, 2024
8f93987
feat: improve url
gfanton Dec 23, 2024
c51ec01
Merge branch 'master' into fix/fgnoweb/simplify-url
gfanton Jan 6, 2025
62b005a
fix: check for upcase string as file
gfanton Jan 6, 2025
b3c9463
fix: add some documentation on Encode function
gfanton Jan 7, 2025
05d7810
Merge branch 'master' into fix/fgnoweb/simplify-url
gfanton Jan 7, 2025
1e0366a
Merge branch 'master' into fix/fgnoweb/simplify-url
gfanton Jan 8, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion gno.land/pkg/gnoweb/app_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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")
})
}
Expand All @@ -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")
})
}
Expand Down
48 changes: 14 additions & 34 deletions gno.land/pkg/gnoweb/handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -99,11 +99,11 @@
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")
}
}
Expand All @@ -129,37 +129,20 @@
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)
}

// Display package source page?
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
Expand Down Expand Up @@ -250,12 +233,16 @@
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

Check warning on line 243 in gno.land/pkg/gnoweb/handler.go

View check run for this annotation

Codecov / codecov/patch

gno.land/pkg/gnoweb/handler.go#L243

Added line #L243 was not covered by tests
} 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")
Expand Down Expand Up @@ -370,10 +357,3 @@

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 != ""
}
207 changes: 127 additions & 80 deletions gno.land/pkg/gnoweb/url.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,145 +4,192 @@
"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 {
var urlstr strings.Builder
if url.Args != "" {
urlstr.WriteString(url.Args)
}
// EncodeFlag is used to specify which URL components to encode.
type EncodeFlag int

if len(url.Query) > 0 {
urlstr.WriteString("?" + url.Query.Encode())
}
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
)

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.
func (gnoURL GnoURL) Encode(encodeFlags EncodeFlag) string {
var urlstr strings.Builder
urlstr.WriteString(url.Path)
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) 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
}
return KindInvalid
func escapeDollarSign(s string) string {
return strings.ReplaceAll(s, "$", "%24")
}

var (
ErrURLMalformedPath = errors.New("malformed URL path")
ErrURLInvalidPathKind = errors.New("invalid path kind")
)
// 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)
}

// 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 `:`
)
// 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)

Check warning on line 105 in gno.land/pkg/gnoweb/url.go

View check run for this annotation

Codecov / codecov/patch

gno.land/pkg/gnoweb/url.go#L104-L105

Added lines #L104 - L105 were not covered by tests
}

// 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)
}

// IsPure checks if the URL path represents a pure path.
func (gnoURL GnoURL) IsPure() bool {
return strings.HasPrefix(gnoURL.Path, "/p/")
}

// 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)

Check warning on line 151 in gno.land/pkg/gnoweb/url.go

View check run for this annotation

Codecov / codecov/patch

gno.land/pkg/gnoweb/url.go#L151

Added line #L151 was not covered by tests
}

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)

Check warning on line 178 in gno.land/pkg/gnoweb/url.go

View check run for this annotation

Codecov / codecov/patch

gno.land/pkg/gnoweb/url.go#L178

Added line #L178 was not covered by tests
}
}

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)

Check warning on line 184 in gno.land/pkg/gnoweb/url.go

View check run for this annotation

Codecov / codecov/patch

gno.land/pkg/gnoweb/url.go#L184

Added line #L184 was not covered by tests
}

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")
}
Loading
Loading