[mirror] Scalable static site server for Git forges (like GitHub Pages)
at main 7.0 kB view raw
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}