forked from
whitequark.org/git-pages
fork of whitequark.org/git-pages with mods for tangled
1package git_pages
2
3import (
4 "fmt"
5 "net/http"
6 "net/url"
7 "slices"
8 "strings"
9
10 "github.com/tj/go-redirects"
11 "google.golang.org/protobuf/proto"
12)
13
14const RedirectsFileName string = "_redirects"
15
16func unparseRule(rule redirects.Rule) string {
17 var statusPart string
18 if rule.Force {
19 statusPart = fmt.Sprintf("%d!", rule.Status)
20 } else {
21 statusPart = fmt.Sprintf("%d", rule.Status)
22 }
23 parts := []string{
24 rule.From,
25 rule.To,
26 statusPart,
27 }
28 for name, value := range rule.Params {
29 parts = append(parts, fmt.Sprintf("%s=%s", name, value))
30 }
31 return strings.Join(parts, " ")
32}
33
34var validRedirectHTTPStatuses []int = []int{
35 http.StatusOK,
36 http.StatusMovedPermanently,
37 http.StatusFound,
38 http.StatusSeeOther,
39 http.StatusTemporaryRedirect,
40 http.StatusPermanentRedirect,
41 http.StatusForbidden,
42 http.StatusNotFound,
43 http.StatusGone,
44 http.StatusTeapot,
45 http.StatusUnavailableForLegalReasons,
46}
47
48func Is3xxHTTPStatus(status int) bool {
49 return status >= 300 && status <= 399
50}
51
52func validateRedirectRule(rule redirects.Rule) error {
53 if len(rule.Params) > 0 {
54 return fmt.Errorf("rules with parameters are not supported")
55 }
56 if !slices.Contains(validRedirectHTTPStatuses, rule.Status) {
57 return fmt.Errorf("rule cannot use status %d: must be %v",
58 rule.Status, validRedirectHTTPStatuses)
59 }
60 fromURL, err := url.Parse(rule.From)
61 if err != nil {
62 return fmt.Errorf("malformed 'from' URL")
63 }
64 if fromURL.Scheme != "" {
65 return fmt.Errorf("'from' URL path must not contain a scheme")
66 }
67 if !strings.HasPrefix(fromURL.Path, "/") {
68 return fmt.Errorf("'from' URL path must start with a /")
69 }
70 if strings.Contains(fromURL.Path, "*") && !strings.HasSuffix(fromURL.Path, "/*") {
71 return fmt.Errorf("splat * must be its own final segment of the path")
72 }
73 toURL, err := url.Parse(rule.To)
74 if err != nil {
75 return fmt.Errorf("malformed 'to' URL")
76 }
77 if !Is3xxHTTPStatus(rule.Status) {
78 if !strings.HasPrefix(toURL.Path, "/") {
79 return fmt.Errorf("'to' URL path must start with a / for non-3xx status rules")
80 }
81 if toURL.Host != "" {
82 return fmt.Errorf("'to' URL may only include a hostname for 3xx status rules")
83 }
84 }
85 return nil
86}
87
88// Parses redirects file and injects rules into the manifest.
89func ProcessRedirectsFile(manifest *Manifest) error {
90 redirectsEntry := manifest.Contents[RedirectsFileName]
91 delete(manifest.Contents, RedirectsFileName)
92 if redirectsEntry == nil {
93 return nil
94 } else if redirectsEntry.GetType() != Type_InlineFile {
95 return AddProblem(manifest, RedirectsFileName,
96 "not a regular file")
97 }
98
99 rules, err := redirects.ParseString(string(redirectsEntry.GetData()))
100 if err != nil {
101 return AddProblem(manifest, RedirectsFileName,
102 "syntax error: %s", err)
103 }
104
105 for index, rule := range rules {
106 if err := validateRedirectRule(rule); err != nil {
107 AddProblem(manifest, RedirectsFileName,
108 "rule #%d %q: %s", index+1, unparseRule(rule), err)
109 continue
110 }
111 manifest.Redirects = append(manifest.Redirects, &RedirectRule{
112 From: proto.String(rule.From),
113 To: proto.String(rule.To),
114 Status: proto.Uint32(uint32(rule.Status)),
115 Force: proto.Bool(rule.Force),
116 })
117 }
118 return nil
119}
120
121func CollectRedirectsFile(manifest *Manifest) string {
122 var rules []string
123 for _, rule := range manifest.GetRedirects() {
124 rules = append(rules, unparseRule(redirects.Rule{
125 From: rule.GetFrom(),
126 To: rule.GetTo(),
127 Status: int(rule.GetStatus()),
128 Force: rule.GetForce(),
129 })+"\n")
130 }
131 return strings.Join(rules, "")
132}
133
134func pathSegments(path string) []string {
135 return strings.Split(strings.TrimPrefix(path, "/"), "/")
136}
137
138func toOrFromComponent(to, from string) string {
139 if to == "" {
140 return from
141 } else {
142 return to
143 }
144}
145
146type RedirectKind int
147
148const (
149 RedirectAny RedirectKind = iota
150 RedirectForce
151)
152
153func ApplyRedirectRules(
154 manifest *Manifest, fromURL *url.URL, kind RedirectKind,
155) (
156 toURL *url.URL, status int,
157) {
158 fromSegments := pathSegments(fromURL.Path)
159next:
160 for _, rule := range manifest.Redirects {
161 if kind == RedirectForce && !*rule.Force {
162 continue
163 }
164 // check if the rule matches fromURL
165 ruleFromURL, _ := url.Parse(*rule.From) // pre-validated in `validateRedirectRule`
166 if ruleFromURL.Scheme != "" && fromURL.Scheme != ruleFromURL.Scheme {
167 continue
168 }
169 if ruleFromURL.Host != "" && fromURL.Hostname() != ruleFromURL.Host {
170 continue
171 }
172 ruleFromSegments := pathSegments(ruleFromURL.Path)
173 splatSegments := []string{}
174 if ruleFromSegments[len(ruleFromSegments)-1] != "*" {
175 if len(ruleFromSegments) < len(fromSegments) {
176 continue
177 }
178 }
179 for index, ruleFromSegment := range ruleFromSegments {
180 if ruleFromSegment == "*" {
181 splatSegments = fromSegments[index:]
182 break
183 }
184 if len(fromSegments) <= index {
185 continue next
186 }
187 if fromSegments[index] != ruleFromSegment {
188 continue next
189 }
190 }
191 // the rule has matched fromURL, figure out where to redirect
192 ruleToURL, _ := url.Parse(*rule.To) // pre-validated in `validateRule`
193 toSegments := []string{}
194 for _, ruleToSegment := range pathSegments(ruleToURL.Path) {
195 if ruleToSegment == ":splat" {
196 toSegments = append(toSegments, splatSegments...)
197 } else {
198 toSegments = append(toSegments, ruleToSegment)
199 }
200 }
201 toURL = &url.URL{
202 Scheme: toOrFromComponent(ruleToURL.Scheme, fromURL.Scheme),
203 Host: toOrFromComponent(ruleToURL.Host, fromURL.Host),
204 Path: "/" + strings.Join(toSegments, "/"),
205 RawQuery: fromURL.RawQuery,
206 }
207 status = int(*rule.Status)
208 break
209 }
210 // no redirect found
211 return
212}