carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

1// Redirect and rewrite rules for static sites: a `_redirects` file at the root
2// of a shipped tree, in the format Netlify introduced and Cloudflare Pages
3// adopted. One rule per line, "source destination [status]":
4//
5// /old /new 301
6// /blog/* /posts/:splat
7// /docs/:slug /guide/:slug 302
8// /app/* /app/index.html 200
9// https://www.example.com/* https://example.com/:splat 301!
10//
11// The subset served here:
12//
13// - A source is a path, or an http(s) URL whose host must match the
14// request's (the scheme is not compared: the edge serves https only).
15// A segment ":name" captures one path segment; a final "*" captures the
16// rest of the path, "" included, so "/blog/*" also matches "/blog"; a
17// non-empty splat keeps the request's trailing slash.
18// Matching is case-sensitive and ignores a trailing slash.
19// - A destination is a path, or for a redirect an http(s) URL. ":name" and
20// ":splat" are replaced with what the source captured; placeholders in a
21// URL's scheme or host are not. A request's query string is carried over
22// when the destination names none of its own.
23// - The status is 301 (the default), 302, 303, 307 or 308 for a redirect;
24// 200 serves the destination's file at the request's URL (a rewrite) and
25// 404 serves it with a 404 (a custom not-found page). A rewrite's
26// destination is always a file in the same tree, never another host.
27// - A file at the request path wins over every rule (Netlify's
28// "shadowing"), unless the status carries a trailing "!".
29// - The first matching rule applies. A line whose first field starts with
30// "#" is a comment, and so is everything from a field starting with "#".
31//
32// Netlify's conditions (Country=, Language=, Role=...), proxying to another
33// host and the netlify.toml form are not supported. `carlos ship` refuses a
34// tree whose _redirects has a line it cannot use (PackDir), so a site never
35// ships rules the edge would ignore; the edge itself skips such a line and
36// logs it, which only a tree packed by something else can reach.
37package edge
38
39import (
40 "errors"
41 "fmt"
42 "net/url"
43 "path"
44 "strconv"
45 "strings"
46)
47
48// redirectsFile is the rules file's name at the root of a static tree. The
49// file is configuration, not content: the edge never serves it.
50const redirectsFile = "_redirects"
51
52// Limits on one _redirects file. Matching is a scan of every rule on each
53// request the tree cannot answer, so the count is bounded the way Cloudflare
54// Pages bounds it.
55const (
56 redirectsMaxBytes = 256 << 10
57 redirectsMaxRules = 2000
58 redirectsMaxLine = 2048
59)
60
61// redirectRule is one parsed line of a _redirects file.
62type redirectRule struct {
63 line int // 1-based line number, for logs
64 host string // lowercased; "" matches any host
65 segs []string // source path segments; ":name" captures one
66 splat bool // the source ends in "*"
67 prefix string // a URL destination's "scheme://host", never substituted
68 to string // the destination template after prefix
69 status int
70 force bool // "!": applies even when a file exists at the request path
71}
72
73// redirectRules is a parsed _redirects file, immutable once built.
74type redirectRules struct {
75 rules []redirectRule
76}
77
78// isRedirect reports whether status is one a redirect rule may answer with.
79func isRedirect(status int) bool {
80 switch status {
81 case 301, 302, 303, 307, 308:
82 return true
83 }
84 return false
85}
86
87// parseRedirects parses a _redirects file. It returns the rules it could use
88// and an error naming every line it could not; callers decide which matters.
89// Ship refuses on any error, and the edge serves the usable rules.
90func parseRedirects(data []byte) (*redirectRules, error) {
91 if len(data) > redirectsMaxBytes {
92 return nil, fmt.Errorf("%s is %d bytes; the limit is %d", redirectsFile, len(data), redirectsMaxBytes)
93 }
94 rs := &redirectRules{}
95 var errs []error
96 // A byte-order mark is what some editors save at the front; it is not
97 // part of the first rule.
98 text := strings.TrimPrefix(string(data), "\ufeff")
99 for i, line := range strings.Split(text, "\n") {
100 n := i + 1
101 line = strings.TrimSpace(strings.TrimSuffix(line, "\r"))
102 if line == "" || strings.HasPrefix(line, "#") {
103 continue
104 }
105 if len(line) > redirectsMaxLine {
106 errs = append(errs, fmt.Errorf("line %d: longer than %d characters", n, redirectsMaxLine))
107 continue
108 }
109 rule, err := parseRedirectLine(line)
110 if err != nil {
111 errs = append(errs, fmt.Errorf("line %d: %v", n, err))
112 continue
113 }
114 if len(rs.rules) == redirectsMaxRules {
115 errs = append(errs, fmt.Errorf("line %d: more than %d rules", n, redirectsMaxRules))
116 continue
117 }
118 rule.line = n
119 rs.rules = append(rs.rules, rule)
120 }
121 return rs, errors.Join(errs...)
122}
123
124func parseRedirectLine(line string) (redirectRule, error) {
125 var fields []string
126 for _, f := range strings.Fields(line) {
127 if strings.HasPrefix(f, "#") {
128 break // the rest of the line is a comment
129 }
130 fields = append(fields, f)
131 }
132 var rule redirectRule
133 switch {
134 case len(fields) < 2:
135 return rule, errors.New("want a source and a destination")
136 case len(fields) > 3:
137 if strings.Contains(fields[3], "=") {
138 return rule, fmt.Errorf("conditions such as %q are not supported", fields[3])
139 }
140 return rule, fmt.Errorf("unexpected %q after the status", fields[3])
141 }
142
143 rule.status = 301
144 if len(fields) == 3 {
145 st := fields[2]
146 if s, ok := strings.CutSuffix(st, "!"); ok {
147 rule.force, st = true, s
148 }
149 code, err := strconv.Atoi(st)
150 if err != nil || !(isRedirect(code) || code == 200 || code == 404) {
151 return rule, fmt.Errorf("status %q: want 301, 302, 303, 307, 308, 200 or 404, optionally followed by !", fields[2])
152 }
153 rule.status = code
154 }
155
156 src := fields[0]
157 if strings.HasPrefix(src, "http://") || strings.HasPrefix(src, "https://") {
158 u, err := url.Parse(src)
159 if err != nil || u.Host == "" || u.User != nil || u.Port() != "" || u.RawQuery != "" || u.Fragment != "" {
160 return rule, fmt.Errorf("source %q: want a URL with only a host and a path", src)
161 }
162 rule.host = serverName(u.Hostname()) // as requestHost normalizes the request's
163 src = u.Path
164 if src == "" {
165 src = "/"
166 }
167 }
168 if !strings.HasPrefix(src, "/") {
169 return rule, fmt.Errorf("source %q: want a path starting with / or an http(s) URL", fields[0])
170 }
171 if strings.ContainsAny(src, "?#") {
172 return rule, fmt.Errorf("source %q: query strings and fragments are not matched", fields[0])
173 }
174 names := map[string]bool{}
175 trimmed := strings.Trim(src, "/")
176 for _, seg := range strings.Split(trimmed, "/") {
177 switch {
178 case seg == "":
179 if trimmed != "" {
180 return rule, fmt.Errorf("source %q: empty path segment", fields[0])
181 }
182 case rule.splat:
183 return rule, fmt.Errorf("source %q: * must be the last segment", fields[0])
184 case seg == "*":
185 rule.splat = true
186 case strings.Contains(seg, "*"):
187 return rule, fmt.Errorf("source %q: * must be a whole segment", fields[0])
188 case strings.HasPrefix(seg, ":"):
189 name := seg[1:]
190 if !validPlaceholder(name) || name == "splat" {
191 return rule, fmt.Errorf("source %q: bad placeholder %q", fields[0], seg)
192 }
193 if names[name] {
194 return rule, fmt.Errorf("source %q: placeholder %q appears twice", fields[0], seg)
195 }
196 names[name] = true
197 rule.segs = append(rule.segs, seg)
198 default:
199 // Requests match decoded, as files are found, so a literal
200 // segment is decoded here to meet them: "/caf%C3%A9" matches a
201 // request for /café. A decoded "/" would be two segments, and
202 // no request path has one inside a segment.
203 dec, err := url.PathUnescape(seg)
204 if err != nil || strings.Contains(dec, "/") {
205 return rule, fmt.Errorf("source %q: bad percent-encoding in %q", fields[0], seg)
206 }
207 if strings.HasPrefix(dec, ":") {
208 return rule, fmt.Errorf("source %q: %q would read as a placeholder", fields[0], seg)
209 }
210 if dec == "." || dec == ".." {
211 return rule, fmt.Errorf("source %q: dot segments never match", fields[0])
212 }
213 rule.segs = append(rule.segs, dec)
214 }
215 }
216
217 dst := fields[1]
218 switch {
219 case strings.HasPrefix(dst, "http://") || strings.HasPrefix(dst, "https://"):
220 if !isRedirect(rule.status) {
221 return rule, fmt.Errorf("destination %q: a %d serves a file from this site, so it takes a path, not a URL", dst, rule.status)
222 }
223 u, err := url.Parse(dst)
224 if err != nil || u.Host == "" || u.User != nil {
225 return rule, fmt.Errorf("destination %q: not a usable URL", dst)
226 }
227 i := len(u.Scheme) + len("://") + len(u.Host)
228 if !strings.EqualFold(dst[:i], u.Scheme+"://"+u.Host) {
229 return rule, fmt.Errorf("destination %q: not a usable URL", dst)
230 }
231 rule.prefix, rule.to = dst[:i], dst[i:]
232 if rule.to != "" && !strings.ContainsAny(rule.to[:1], "/?#") {
233 return rule, fmt.Errorf("destination %q: not a usable URL", dst)
234 }
235 case strings.HasPrefix(dst, "//") || strings.HasPrefix(dst, "/\\"):
236 return rule, fmt.Errorf("destination %q: name another host with https://", dst)
237 case strings.HasPrefix(dst, "/"):
238 rule.to = dst
239 default:
240 return rule, fmt.Errorf("destination %q: want a path starting with / or an http(s) URL", dst)
241 }
242 return rule, nil
243}
244
245// validPlaceholder: a placeholder name is letters, digits and underscores.
246func validPlaceholder(name string) bool {
247 if name == "" {
248 return false
249 }
250 for _, c := range name {
251 if !(c == '_' || c >= '0' && c <= '9' || c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z') {
252 return false
253 }
254 }
255 return true
256}
257
258// match reports whether the rule applies to a request for host whose cleaned
259// path splits into reqSegs, and returns what the source captured.
260func (rule *redirectRule) match(host string, reqSegs []string) (map[string]string, bool) {
261 if rule.host != "" && rule.host != host {
262 return nil, false
263 }
264 if len(reqSegs) < len(rule.segs) || !rule.splat && len(reqSegs) != len(rule.segs) {
265 return nil, false
266 }
267 var caps map[string]string
268 for i, seg := range rule.segs {
269 if strings.HasPrefix(seg, ":") {
270 if caps == nil {
271 caps = map[string]string{}
272 }
273 caps[seg[1:]] = reqSegs[i]
274 } else if seg != reqSegs[i] {
275 return nil, false
276 }
277 }
278 if rule.splat {
279 if caps == nil {
280 caps = map[string]string{}
281 }
282 caps["splat"] = strings.Join(reqSegs[len(rule.segs):], "/")
283 }
284 return caps, true
285}
286
287// expand substitutes the captures into the destination template. With escape
288// set, values are percent-encoded for a Location header: per segment in the
289// path, as a query value after "?". Without it they go in raw, for a rewrite's
290// file lookup. A ":name" the source did not capture stays as written.
291func expand(t string, caps map[string]string, escape bool) string {
292 var b strings.Builder
293 inQuery := false
294 for i := 0; i < len(t); {
295 c := t[i]
296 if c == '?' || c == '#' {
297 inQuery = true
298 }
299 if c != ':' {
300 b.WriteByte(c)
301 i++
302 continue
303 }
304 j := i + 1
305 for j < len(t) && validPlaceholder(t[j:j+1]) {
306 j++
307 }
308 v, ok := caps[t[i+1:j]]
309 if !ok {
310 b.WriteString(t[i:j])
311 i = j
312 continue
313 }
314 switch {
315 case !escape:
316 b.WriteString(v)
317 case inQuery:
318 b.WriteString(url.QueryEscape(v))
319 default:
320 parts := strings.Split(v, "/")
321 for k, p := range parts {
322 parts[k] = url.PathEscape(p)
323 }
324 b.WriteString(strings.Join(parts, "/"))
325 }
326 i = j
327 }
328 return b.String()
329}
330
331// redirectAction is what a matched rule asks the handler to do.
332type redirectAction struct {
333 status int
334 location string // a redirect's Location
335 file string // a rewrite's cleaned, rooted path within the tree
336}
337
338// resolve finds the first rule that applies to r's host and the cleaned path
339// p. exists is whether the tree has a file at p, which shadows every rule not
340// forced. rawPath and rawQuery are the request's own, used to refuse a
341// redirect to the URL being requested, which would loop.
342func (rs *redirectRules) resolve(host, p, rawPath, rawQuery string, exists bool) (redirectAction, bool) {
343 if rs == nil || len(rs.rules) == 0 {
344 return redirectAction{}, false
345 }
346 var reqSegs []string
347 if p != "/" {
348 reqSegs = strings.Split(p[1:], "/")
349 }
350 for i := range rs.rules {
351 rule := &rs.rules[i]
352 if exists && !rule.force {
353 continue
354 }
355 caps, ok := rule.match(host, reqSegs)
356 if !ok {
357 continue
358 }
359 if sp := caps["splat"]; sp != "" && strings.HasSuffix(rawPath, "/") {
360 // The cleaned path lost it, and a page moved from /about/ to
361 // /about resolves its relative links against the wrong directory.
362 caps["splat"] = sp + "/"
363 }
364 if !isRedirect(rule.status) {
365 // The template's own query and fragment are cut BEFORE the
366 // captures go in: a decoded capture can hold "?" or "#", and it
367 // is part of the file's name, not the end of it.
368 to := rule.to
369 if k := strings.IndexAny(to, "?#"); k >= 0 {
370 to = to[:k]
371 }
372 return redirectAction{status: rule.status, file: path.Clean("/" + expand(to, caps, false))}, true
373 }
374 // loc is the destination after any scheme://host: path, query.
375 loc := expand(rule.to, caps, true)
376 frag := ""
377 if k := strings.IndexByte(loc, '#'); k >= 0 {
378 loc, frag = loc[:k], loc[k:]
379 }
380 if rule.prefix == "" {
381 // Cleaned here, as http.Redirect would clean it, so the loop
382 // check below sees the Location that is actually sent. Clean
383 // also folds the "//" an empty capture can leave at the front,
384 // which a browser would read as another host.
385 pth, q, hasQ := strings.Cut(loc, "?")
386 trailing := strings.HasSuffix(pth, "/")
387 pth = path.Clean("/" + pth)
388 if trailing && pth != "/" {
389 pth += "/"
390 }
391 loc = pth
392 if hasQ {
393 loc += "?" + q
394 }
395 }
396 if !strings.Contains(loc, "?") && rawQuery != "" {
397 loc += "?" + rawQuery
398 }
399 if rule.prefix == "" || hostOf(rule.prefix) == host {
400 pth, q, _ := strings.Cut(loc, "?")
401 if pth == "" {
402 pth = "/"
403 }
404 if u, err := url.Parse(pth); err == nil && u.Path == rawPath && q == rawQuery {
405 continue // a redirect to this very URL
406 }
407 }
408 return redirectAction{status: rule.status, location: rule.prefix + loc + frag}, true
409 }
410 return redirectAction{}, false
411}
412
413// hostOf is the host of a "scheme://host[:port]" prefix, normalized as
414// requestHost normalizes a request's.
415func hostOf(prefix string) string {
416 _, h, _ := strings.Cut(prefix, "://")
417 return requestHost(h)
418}
419