[mirror] Scalable static site server for Git forges (like GitHub Pages)
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
16// Converts our Protobuf representation to tj/go-redirects.
17func exportRedirectRule(rule *RedirectRule) *redirects.Rule {
18 return &redirects.Rule{
19 From: rule.GetFrom(),
20 To: rule.GetTo(),
21 Status: int(rule.GetStatus()),
22 Force: rule.GetForce(),
23 }
24}
25
26func unparseRedirectRule(rule *redirects.Rule) string {
27 var statusPart string
28 if rule.Force {
29 statusPart = fmt.Sprintf("%d!", rule.Status)
30 } else {
31 statusPart = fmt.Sprintf("%d", rule.Status)
32 }
33 parts := []string{
34 rule.From,
35 rule.To,
36 statusPart,
37 }
38 for name, value := range rule.Params {
39 parts = append(parts, fmt.Sprintf("%s=%s", name, value))
40 }
41 return strings.Join(parts, " ")
42}
43
44var validRedirectHTTPStatuses []int = []int{
45 http.StatusOK,
46 http.StatusMovedPermanently,
47 http.StatusFound,
48 http.StatusSeeOther,
49 http.StatusTemporaryRedirect,
50 http.StatusPermanentRedirect,
51 http.StatusForbidden,
52 http.StatusNotFound,
53 http.StatusGone,
54 http.StatusTeapot,
55 http.StatusUnavailableForLegalReasons,
56}
57
58func Is3xxHTTPStatus(status int) bool {
59 return status >= 300 && status <= 399
60}
61
62func validateRedirectRule(rule *redirects.Rule) error {
63 if len(rule.Params) > 0 {
64 return fmt.Errorf("rules with parameters are not supported")
65 }
66 if !slices.Contains(validRedirectHTTPStatuses, rule.Status) {
67 return fmt.Errorf("rule cannot use status %d: must be %v",
68 rule.Status, validRedirectHTTPStatuses)
69 }
70 fromURL, err := url.Parse(rule.From)
71 if err != nil {
72 return fmt.Errorf("malformed 'from' URL")
73 }
74 if fromURL.Scheme != "" {
75 return fmt.Errorf("'from' URL path must not contain a scheme")
76 }
77 if !strings.HasPrefix(fromURL.Path, "/") {
78 return fmt.Errorf("'from' URL path must start with a /")
79 }
80 if strings.Contains(fromURL.Path, "*") && !strings.HasSuffix(fromURL.Path, "/*") {
81 return fmt.Errorf("splat * must be its own final segment of the path")
82 }
83 toURL, err := url.Parse(rule.To)
84 if err != nil {
85 return fmt.Errorf("malformed 'to' URL")
86 }
87 if !Is3xxHTTPStatus(rule.Status) {
88 if !strings.HasPrefix(toURL.Path, "/") {
89 return fmt.Errorf("'to' URL path must start with a / for non-3xx status rules")
90 }
91 if toURL.Host != "" {
92 return fmt.Errorf("'to' URL may only include a hostname for 3xx status rules")
93 }
94 }
95 return nil
96}
97
98// Parses redirects file and injects rules into the manifest.
99func ProcessRedirectsFile(manifest *Manifest) error {
100 redirectsEntry := manifest.Contents[RedirectsFileName]
101 delete(manifest.Contents, RedirectsFileName)
102 if redirectsEntry == nil {
103 return nil
104 } else if redirectsEntry.GetType() != Type_InlineFile {
105 return AddProblem(manifest, RedirectsFileName,
106 "not a regular file")
107 }
108
109 rules, err := redirects.ParseString(string(redirectsEntry.GetData()))
110 if err != nil {
111 return AddProblem(manifest, RedirectsFileName,
112 "syntax error: %s", err)
113 }
114
115 for index, rule := range rules {
116 if err := validateRedirectRule(&rule); err != nil {
117 AddProblem(manifest, RedirectsFileName,
118 "rule #%d %q: %s", index+1, unparseRedirectRule(&rule), err)
119 continue
120 }
121 manifest.Redirects = append(manifest.Redirects, &RedirectRule{
122 From: proto.String(rule.From),
123 To: proto.String(rule.To),
124 Status: proto.Uint32(uint32(rule.Status)),
125 Force: proto.Bool(rule.Force),
126 })
127 }
128 return nil
129}
130
131func CollectRedirectsFile(manifest *Manifest) string {
132 var rules []string
133 for _, rule := range manifest.GetRedirects() {
134 rules = append(rules, unparseRedirectRule(exportRedirectRule(rule))+"\n")
135 }
136 return strings.Join(rules, "")
137}
138
139func pathSegments(path string) []string {
140 return strings.Split(strings.TrimPrefix(path, "/"), "/")
141}
142
143func toOrFromComponent(to, from string) string {
144 if to == "" {
145 return from
146 } else {
147 return to
148 }
149}
150
151type RedirectKind int
152
153const (
154 RedirectAny RedirectKind = iota
155 RedirectNormal
156 RedirectForce
157)
158
159func ApplyRedirectRules(
160 manifest *Manifest, fromURL *url.URL, kind RedirectKind,
161) (
162 rule *RedirectRule, toURL *url.URL, status int,
163) {
164 fromSegments := pathSegments(fromURL.Path)
165next:
166 for _, rule = range manifest.Redirects {
167 switch {
168 case kind == RedirectNormal && *rule.Force:
169 continue
170 case kind == RedirectForce && !*rule.Force:
171 continue
172 }
173 // check if the rule matches fromURL
174 ruleFromURL, _ := url.Parse(*rule.From) // pre-validated in `validateRedirectRule`
175 if ruleFromURL.Scheme != "" && fromURL.Scheme != ruleFromURL.Scheme {
176 continue
177 }
178 if ruleFromURL.Host != "" && fromURL.Hostname() != ruleFromURL.Host {
179 continue
180 }
181 ruleFromSegments := pathSegments(ruleFromURL.Path)
182 splatSegments := []string{}
183 if ruleFromSegments[len(ruleFromSegments)-1] != "*" {
184 if len(ruleFromSegments) < len(fromSegments) {
185 continue
186 }
187 }
188 for index, ruleFromSegment := range ruleFromSegments {
189 if ruleFromSegment == "*" {
190 splatSegments = fromSegments[index:]
191 break
192 }
193 if len(fromSegments) <= index {
194 continue next
195 }
196 if fromSegments[index] != ruleFromSegment {
197 continue next
198 }
199 }
200 // the rule has matched fromURL, figure out where to redirect
201 ruleToURL, _ := url.Parse(*rule.To) // pre-validated in `validateRule`
202 toSegments := []string{}
203 for _, ruleToSegment := range pathSegments(ruleToURL.Path) {
204 if ruleToSegment == ":splat" {
205 toSegments = append(toSegments, splatSegments...)
206 } else {
207 toSegments = append(toSegments, ruleToSegment)
208 }
209 }
210 toURL = &url.URL{
211 Scheme: toOrFromComponent(ruleToURL.Scheme, fromURL.Scheme),
212 Host: toOrFromComponent(ruleToURL.Host, fromURL.Host),
213 Path: "/" + strings.Join(toSegments, "/"),
214 RawQuery: fromURL.RawQuery,
215 }
216 status = int(*rule.Status)
217 return
218 }
219 // no redirect found
220 rule = nil
221 return
222}
223
224func redirectHasSplat(rule *RedirectRule) bool {
225 ruleFromURL, _ := url.Parse(*rule.From) // pre-validated in `validateRedirectRule`
226 ruleFromSegments := pathSegments(ruleFromURL.Path)
227 return slices.Contains(ruleFromSegments, "*")
228}
229
230func LintRedirects(manifest *Manifest) {
231 for name, entry := range manifest.GetContents() {
232 nameURL, err := url.Parse("/" + name)
233 if err != nil {
234 continue
235 }
236
237 // Check if the entry URL would trigger a non-forced redirect if the entry didn't exist.
238 // If the redirect matches exactly one URL (i.e. has no splat) then it will never be
239 // triggered and an issue is reported; if the rule has a splat, it will always be possible
240 // to trigger it, as it matches an infinite number of URLs.
241 rule, _, _ := ApplyRedirectRules(manifest, nameURL, RedirectNormal)
242 if rule != nil && !redirectHasSplat(rule) {
243 entryDesc := "file"
244 if entry.GetType() == Type_Directory {
245 entryDesc = "directory"
246 }
247 AddProblem(manifest, name,
248 "%s shadows redirect %q; remove the %s or use a %d! forced redirect instead",
249 entryDesc,
250 unparseRedirectRule(exportRedirectRule(rule)),
251 entryDesc,
252 rule.GetStatus(),
253 )
254 }
255 }
256}