Mac Chaffee
·
13 Oct 24
redirect.go
1package pgs
2
3import (
4 "fmt"
5 "net/http"
6 "regexp"
7 "strconv"
8 "strings"
9)
10
11type RedirectRule struct {
12 From string
13 To string
14 Status int
15 Query map[string]string
16 Conditions map[string]string
17 Force bool
18 Signed bool
19}
20
21var reSplitWhitespace = regexp.MustCompile(`\s+`)
22
23func isUrl(text string) bool {
24 return strings.HasPrefix(text, "http://") || strings.HasPrefix(text, "https://")
25}
26
27func isToPart(part string) bool {
28 return strings.HasPrefix(part, "/") || isUrl(part)
29}
30
31func hasStatusCode(part string) (int, bool) {
32 status := 0
33 forced := false
34 pt := part
35 if strings.HasSuffix(part, "!") {
36 pt = strings.TrimSuffix(part, "!")
37 forced = true
38 }
39
40 status, err := strconv.Atoi(pt)
41 if err != nil {
42 return 0, forced
43 }
44 return status, forced
45}
46
47func parsePairs(pairs []string) map[string]string {
48 mapper := map[string]string{}
49 for _, pair := range pairs {
50 val := strings.SplitN(pair, "=", 1)
51 if len(val) > 1 {
52 mapper[val[0]] = val[1]
53 }
54 }
55 return mapper
56}
57
58/*
59https://github.com/netlify/build/blob/main/packages/redirect-parser/src/line_parser.js#L9-L26
60Parse `_redirects` file to an array of objects.
61Each line in that file must be either:
62 - An empty line
63 - A comment starting with #
64 - A redirect line, optionally ended with a comment
65
66Each redirect line has the following format:
67
68 from [query] [to] [status[!]] [conditions]
69
70The parts are:
71 - "from": a path or a URL
72 - "query": a whitespace-separated list of "key=value"
73 - "to": a path or a URL
74 - "status": an HTTP status integer
75 - "!": an optional exclamation mark appended to "status" meant to indicate
76 "forced"
77 - "conditions": a whitespace-separated list of "key=value"
78 - "Sign" is a special condition
79*/
80func parseRedirectText(text string) ([]*RedirectRule, error) {
81 rules := []*RedirectRule{}
82 origLines := strings.Split(text, "\n")
83 for _, line := range origLines {
84 trimmed := strings.TrimSpace(line)
85 // ignore empty lines
86 if trimmed == "" {
87 continue
88 }
89
90 // ignore comments
91 if strings.HasPrefix(trimmed, "#") {
92 continue
93 }
94
95 parts := reSplitWhitespace.Split(trimmed, -1)
96 if len(parts) < 2 {
97 return rules, fmt.Errorf("missing destination path/URL")
98 }
99
100 from := parts[0]
101 rest := parts[1:]
102 status, forced := hasStatusCode(rest[0])
103 if status != 0 {
104 rules = append(rules, &RedirectRule{
105 Query: map[string]string{},
106 Status: status,
107 Force: forced,
108 })
109 } else {
110 toIndex := -1
111 for idx, part := range rest {
112 if isToPart(part) {
113 toIndex = idx
114 }
115 }
116
117 if toIndex == -1 {
118 return rules, fmt.Errorf("the destination path/URL must start with '/', 'http:' or 'https:'")
119 }
120
121 queryParts := rest[:toIndex]
122 to := rest[toIndex]
123 lastParts := rest[toIndex+1:]
124 conditions := map[string]string{}
125 sts := http.StatusOK
126 frcd := false
127 if len(lastParts) > 0 {
128 sts, frcd = hasStatusCode(lastParts[0])
129 }
130 if len(lastParts) > 1 {
131 conditions = parsePairs(lastParts[1:])
132 }
133
134 rules = append(rules, &RedirectRule{
135 To: to,
136 From: from,
137 Status: sts,
138 Force: frcd,
139 Query: parsePairs(queryParts),
140 Conditions: conditions,
141 })
142 }
143 }
144
145 return rules, nil
146}