[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.

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