Eric Bower
·
2026-02-16
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*/
80// isSelfReferentialRedirect checks if a redirect rule would redirect to itself.
81// This includes exact matches and wildcard patterns that would match the same path.
82func isSelfReferentialRedirect(from, to string) bool {
83 // External URLs are never self-referential
84 if isUrl(to) {
85 return false
86 }
87
88 // Exact match: /page redirects to /page
89 if from == to {
90 return true
91 }
92
93 // Wildcard match: /* redirects to /*
94 if from == to && strings.Contains(from, "*") {
95 return true
96 }
97
98 // Pattern with variable: /:path redirects to /:path
99 if from == to && strings.Contains(from, ":") {
100 return true
101 }
102
103 return false
104}
105
106func parseRedirectText(text string) ([]*RedirectRule, error) {
107 rules := []*RedirectRule{}
108 origLines := strings.Split(text, "\n")
109 for _, line := range origLines {
110 trimmed := strings.TrimSpace(line)
111 // ignore empty lines
112 if trimmed == "" {
113 continue
114 }
115
116 // ignore comments
117 if strings.HasPrefix(trimmed, "#") {
118 continue
119 }
120
121 parts := reSplitWhitespace.Split(trimmed, -1)
122 if len(parts) < 2 {
123 return rules, fmt.Errorf("missing destination path/URL")
124 }
125
126 from := parts[0]
127 rest := parts[1:]
128 status, forced := hasStatusCode(rest[0])
129 if status != 0 {
130 rules = append(rules, &RedirectRule{
131 Query: map[string]string{},
132 Status: status,
133 Force: forced,
134 })
135 } else {
136 toIndex := -1
137 for idx, part := range rest {
138 if isToPart(part) {
139 toIndex = idx
140 }
141 }
142
143 if toIndex == -1 {
144 return rules, fmt.Errorf("the destination path/URL must start with '/', 'http:' or 'https:'")
145 }
146
147 queryParts := rest[:toIndex]
148 to := rest[toIndex]
149 lastParts := rest[toIndex+1:]
150 conditions := map[string]string{}
151 sts := http.StatusMovedPermanently
152 frcd := false
153 if len(lastParts) > 0 {
154 sts, frcd = hasStatusCode(lastParts[0])
155 }
156 if len(lastParts) > 1 {
157 conditions = parsePairs(lastParts[1:])
158 }
159
160 // Validate that the redirect is not self-referential
161 if isSelfReferentialRedirect(from, to) {
162 return rules, fmt.Errorf("self-referential redirect: '%s' cannot redirect to itself", from)
163 }
164
165 rules = append(rules, &RedirectRule{
166 To: to,
167 From: from,
168 Status: sts,
169 Force: frcd,
170 Query: parsePairs(queryParts),
171 Conditions: conditions,
172 })
173 }
174 }
175
176 return rules, nil
177}