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

feat!: Matches required query parameters #21

Closed
wants to merge 2 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
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
8 changes: 6 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,10 @@ This is a parser for the IPFS Web Gateway's `_redirects` file format.
Follow specification work at https://github.com/ipfs/specs/pull/290

## Format
Currently only supports `from`, `to` and `status`.
Currently only supports `from`, `fromQuery`, `to` and `status`.

```
from to [status]
from [fromQuery [fromQuery]] to [status]
```

## Example
Expand Down Expand Up @@ -39,6 +39,10 @@ from to [status]

# Single page app rewrite (SPA, PWA)
/* /index.html 200

# Query parameter rewrite
/thing type=:type /thing-:type.html 200
/thing /things.html 200
```

## Notes for contributors
Expand Down
149 changes: 130 additions & 19 deletions redirects.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"fmt"
"io"
"net/url"
"regexp"
"strconv"
"strings"

Expand All @@ -21,6 +22,12 @@ type Rule struct {
// From is the path which is matched to perform the rule.
From string

// FromQuery is the set of required query parameters which
// must be present to perform the rule.
// A string without a preceding colon requires that query parameter is this exact value.
// A string with a preceding colon will match any value, and provide it as a placeholder.
FromQuery map[string]string

// To is the destination which may be relative, or absolute
// in order to proxy the request to another URL.
To string
Expand Down Expand Up @@ -51,37 +58,70 @@ func (r *Rule) IsProxy() bool {

// MatchAndExpandPlaceholders expands placeholders in `r.To` and returns true if the provided path matches.
// Otherwise it returns false.
func (r *Rule) MatchAndExpandPlaceholders(urlPath string) bool {
func (r *Rule) MatchAndExpandPlaceholders(urlPath string, urlParams url.Values) bool {
// get rule.From, trim trailing slash, ...
fromPath := urlpath.New(strings.TrimSuffix(r.From, "/"))
match, ok := fromPath.Match(urlPath)

if !ok {
return false
}

// We have a match! Perform substitution and return the updated rule
placeholders := match.Params
placeholders["splat"] = match.Trailing
if !matchParams(r.FromQuery, urlParams, placeholders) {
return false
}

// We have a match! Perform substitution and return the updated rule
toPath := r.To
toPath = replacePlaceholders(toPath, match)
toPath = replaceSplat(toPath, match)
toPath = replacePlaceholders(toPath, placeholders)

// There's a placeholder unsupplied somewhere
if strings.Contains(toPath, ":") {
return false
}

r.To = toPath

return true
}

func replacePlaceholders(to string, match urlpath.Match) string {
if len(match.Params) > 0 {
for key, value := range match.Params {
to = strings.ReplaceAll(to, ":"+key, value)
}
func replacePlaceholders(to string, placeholders map[string]string) string {
if len(placeholders) == 0 {
return to
}

for key, value := range placeholders {
to = strings.ReplaceAll(to, ":"+key, value)
}

return to
}

func replaceSplat(to string, match urlpath.Match) string {
return strings.ReplaceAll(to, ":splat", match.Trailing)
func replaceSplat(to string, splat string) string {
return strings.ReplaceAll(to, ":splat", splat)
}

func matchParams(fromQuery map[string]string, urlParams url.Values, placeholders map[string]string) bool {
for neededK, neededV := range fromQuery {
haveVs, ok := urlParams[neededK]
if !ok {
return false
}

if isPlaceholder(neededV) {
if _, ok := placeholders[neededV[1:]]; !ok {
placeholders[neededV[1:]] = haveVs[0]
}
continue
}

if !contains(haveVs, neededV) {
return false
}
}

return true
}

// Must parse utility.
Expand Down Expand Up @@ -124,10 +164,6 @@ func Parse(r io.Reader) (rules []Rule, err error) {
return nil, fmt.Errorf("missing 'to' path")
}

if len(fields) > 3 {
return nil, fmt.Errorf("must match format 'from to [status]'")
}

// implicit status
rule := Rule{Status: 301}

Expand All @@ -138,23 +174,42 @@ func Parse(r io.Reader) (rules []Rule, err error) {
}
rule.From = from

hasStatus := isLikelyStatusCode(fields[len(fields)-1])
toIndex := len(fields) - 1
if hasStatus {
toIndex = len(fields) - 2
}

// to (must parse as an absolute path or an URL)
to, err := parseTo(fields[1])
to, err := parseTo(fields[toIndex])
if err != nil {
return nil, errors.Wrapf(err, "parsing 'to'")
}
rule.To = to

// status
if len(fields) > 2 {
code, err := parseStatus(fields[2])
if hasStatus {
code, err := parseStatus(fields[len(fields)-1])
if err != nil {
return nil, errors.Wrapf(err, "parsing status %q", fields[2])
}

rule.Status = code
}

// from query
if toIndex > 1 {
rule.FromQuery = make(map[string]string)

for i := 1; i < toIndex; i++ {
key, value, err := parseFromQuery(fields[i])
if err != nil {
return nil, errors.Wrapf(err, "parsing 'fromQuery'")
}
rule.FromQuery[key] = value
}
}

rules = append(rules, rule)
}

Expand Down Expand Up @@ -194,6 +249,46 @@ func parseFrom(s string) (string, error) {
return s, nil
}

func parseFromQuery(s string) (string, string, error) {
params, err := url.ParseQuery(s)
if err != nil {
return "", "", err
}
if len(params) != 1 {
return "", "", fmt.Errorf("separate different fromQuery arguments with a space")
}

var key string
var val []string
// We know there's only 1, but we don't know the key to access it
for k, v := range params {
key = k
val = v
}

if url.QueryEscape(key) != key {
return "", "", fmt.Errorf("fromQuery key must be URL encoded")
}

if len(val) > 1 {
return "", "", fmt.Errorf("separate different fromQuery arguments with a space")
}

ignorePlaceholders := val[0]
if isPlaceholder(val[0]) {
ignorePlaceholders = ignorePlaceholders[1:]
}

if url.QueryEscape(ignorePlaceholders) != ignorePlaceholders {
return "", "", fmt.Errorf("fromQuery val must be URL encoded")
}
return key, val[0], nil
}

func isPlaceholder(s string) bool {
return strings.HasPrefix(s, ":")
}

func parseTo(s string) (string, error) {
// confirm value is within URL path spec
u, err := url.Parse(s)
Expand All @@ -211,6 +306,13 @@ func parseTo(s string) (string, error) {
return s, nil
}

var likeStatusCode = regexp.MustCompile(`^\d{1,3}!?$`)

// isLikelyStatusCode returns true if the given string is likely to be a status code.
func isLikelyStatusCode(s string) bool {
return likeStatusCode.MatchString(s)
}

// parseStatus returns the status code.
func parseStatus(s string) (code int, err error) {
if strings.HasSuffix(s, "!") {
Expand All @@ -237,3 +339,12 @@ func isValidStatusCode(status int) bool {
}
return false
}

func contains(arr []string, s string) bool {
for _, a := range arr {
if a == s {
return true
}
}
return false
}
70 changes: 70 additions & 0 deletions redirects_example_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,18 @@ func Example() {

# Proxying
/api/* https://api.example.com/:splat 200

# Query parameters
/things type=photos /photos.html 200
/things type= /empty.html 200
/things type=:thing /thing-:thing.html 200
/things /things.html 200

# Multiple query parameters
/stuff type=lost name=:name other=:ignore /other-stuff/:name.html 200

# Query parameters with implicit 301
/items id=:id /items/:id.html
`))

enc := json.NewEncoder(os.Stdout)
Expand All @@ -39,53 +51,111 @@ func Example() {
// [
// {
// "From": "/home",
// "FromQuery": null,
// "To": "/",
// "Status": 301
// },
// {
// "From": "/blog/my-post.php",
// "FromQuery": null,
// "To": "/blog/my-post",
// "Status": 301
// },
// {
// "From": "/news",
// "FromQuery": null,
// "To": "/blog",
// "Status": 301
// },
// {
// "From": "/google",
// "FromQuery": null,
// "To": "https://www.google.com",
// "Status": 301
// },
// {
// "From": "/home",
// "FromQuery": null,
// "To": "/",
// "Status": 301
// },
// {
// "From": "/my-redirect",
// "FromQuery": null,
// "To": "/",
// "Status": 302
// },
// {
// "From": "/pass-through",
// "FromQuery": null,
// "To": "/index.html",
// "Status": 200
// },
// {
// "From": "/ecommerce",
// "FromQuery": null,
// "To": "/store-closed",
// "Status": 404
// },
// {
// "From": "/*",
// "FromQuery": null,
// "To": "/index.html",
// "Status": 200
// },
// {
// "From": "/api/*",
// "FromQuery": null,
// "To": "https://api.example.com/:splat",
// "Status": 200
// },
// {
// "From": "/things",
// "FromQuery": {
// "type": "photos"
// },
// "To": "/photos.html",
// "Status": 200
// },
// {
// "From": "/things",
// "FromQuery": {
// "type": ""
// },
// "To": "/empty.html",
// "Status": 200
// },
// {
// "From": "/things",
// "FromQuery": {
// "type": ":thing"
// },
// "To": "/thing-:thing.html",
// "Status": 200
// },
// {
// "From": "/things",
// "FromQuery": null,
// "To": "/things.html",
// "Status": 200
// },
// {
// "From": "/stuff",
// "FromQuery": {
// "name": ":name",
// "other": ":ignore",
// "type": "lost"
// },
// "To": "/other-stuff/:name.html",
// "Status": 200
// },
// {
// "From": "/items",
// "FromQuery": {
// "id": ":id"
// },
// "To": "/items/:id.html",
// "Status": 301
// }
// ]
}
Loading