repos / pico

pico services mono repo
git clone https://github.com/picosh/pico.git

pico / pkg / apps / pgs
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}