[mirror] Scalable static site server for Git forges (like GitHub Pages)

Report "dead" redirects as site issues.

Using a non-forced redirect with a URL matching a manifest entry turns
out to be a common and confusing mistake.

Changed files
+66 -19
src
+7 -4
src/manifest.go
··· 257 257 // At the moment, there isn't a good way to report errors except to log them on the terminal. 258 258 // (Perhaps in the future they could be exposed at `.git-pages/status.txt`?) 259 259 func PrepareManifest(ctx context.Context, manifest *Manifest) error { 260 - // Parse Netlify-style `_redirects` 260 + // Parse Netlify-style `_redirects`. 261 261 if err := ProcessRedirectsFile(manifest); err != nil { 262 262 logc.Printf(ctx, "redirects err: %s\n", err) 263 263 } else if len(manifest.Redirects) > 0 { 264 264 logc.Printf(ctx, "redirects ok: %d rules\n", len(manifest.Redirects)) 265 265 } 266 266 267 - // Parse Netlify-style `_headers` 267 + // Check if any redirects are unreachable. 268 + LintRedirects(manifest) 269 + 270 + // Parse Netlify-style `_headers`. 268 271 if err := ProcessHeadersFile(manifest); err != nil { 269 272 logc.Printf(ctx, "headers err: %s\n", err) 270 273 } else if len(manifest.Headers) > 0 { 271 274 logc.Printf(ctx, "headers ok: %d rules\n", len(manifest.Headers)) 272 275 } 273 276 274 - // Sniff content type like `http.ServeContent` 277 + // Sniff content type like `http.ServeContent`. 275 278 DetectContentType(manifest) 276 279 277 - // Opportunistically compress blobs (must be done last) 280 + // Opportunistically compress blobs (must be done last). 278 281 CompressFiles(ctx, manifest) 279 282 280 283 return nil
+1 -1
src/pages.go
··· 262 262 redirectKind = RedirectForce 263 263 } 264 264 originalURL := (&url.URL{Host: r.Host}).ResolveReference(r.URL) 265 - redirectURL, redirectStatus := ApplyRedirectRules(manifest, originalURL, redirectKind) 265 + _, redirectURL, redirectStatus := ApplyRedirectRules(manifest, originalURL, redirectKind) 266 266 if Is3xxHTTPStatus(redirectStatus) { 267 267 writeRedirect(w, redirectStatus, redirectURL.String()) 268 268 return nil
+58 -14
src/redirects.go
··· 13 13 14 14 const RedirectsFileName string = "_redirects" 15 15 16 - func unparseRule(rule redirects.Rule) string { 16 + // Converts our Protobuf representation to tj/go-redirects. 17 + func 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 + 26 + func unparseRedirectRule(rule *redirects.Rule) string { 17 27 var statusPart string 18 28 if rule.Force { 19 29 statusPart = fmt.Sprintf("%d!", rule.Status) ··· 49 59 return status >= 300 && status <= 399 50 60 } 51 61 52 - func validateRedirectRule(rule redirects.Rule) error { 62 + func validateRedirectRule(rule *redirects.Rule) error { 53 63 if len(rule.Params) > 0 { 54 64 return fmt.Errorf("rules with parameters are not supported") 55 65 } ··· 103 113 } 104 114 105 115 for index, rule := range rules { 106 - if err := validateRedirectRule(rule); err != nil { 116 + if err := validateRedirectRule(&rule); err != nil { 107 117 AddProblem(manifest, RedirectsFileName, 108 - "rule #%d %q: %s", index+1, unparseRule(rule), err) 118 + "rule #%d %q: %s", index+1, unparseRedirectRule(&rule), err) 109 119 continue 110 120 } 111 121 manifest.Redirects = append(manifest.Redirects, &RedirectRule{ ··· 121 131 func CollectRedirectsFile(manifest *Manifest) string { 122 132 var rules []string 123 133 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") 134 + rules = append(rules, unparseRedirectRule(exportRedirectRule(rule))+"\n") 130 135 } 131 136 return strings.Join(rules, "") 132 137 } ··· 147 152 148 153 const ( 149 154 RedirectAny RedirectKind = iota 155 + RedirectNormal 150 156 RedirectForce 151 157 ) 152 158 153 159 func ApplyRedirectRules( 154 160 manifest *Manifest, fromURL *url.URL, kind RedirectKind, 155 161 ) ( 156 - toURL *url.URL, status int, 162 + rule *RedirectRule, toURL *url.URL, status int, 157 163 ) { 158 164 fromSegments := pathSegments(fromURL.Path) 159 165 next: 160 - for _, rule := range manifest.Redirects { 161 - if kind == RedirectForce && !*rule.Force { 166 + for _, rule = range manifest.Redirects { 167 + switch { 168 + case kind == RedirectNormal && *rule.Force: 169 + continue 170 + case kind == RedirectForce && !*rule.Force: 162 171 continue 163 172 } 164 173 // check if the rule matches fromURL ··· 205 214 RawQuery: fromURL.RawQuery, 206 215 } 207 216 status = int(*rule.Status) 208 - break 217 + return 209 218 } 210 219 // no redirect found 220 + rule = nil 211 221 return 212 222 } 223 + 224 + func 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 + 230 + func 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 + }