[mirror] Scalable static site server for Git forges (like GitHub Pages)
at main 28 kB view raw
1package git_pages 2 3import ( 4 "bytes" 5 "compress/gzip" 6 "context" 7 "encoding/json" 8 "errors" 9 "fmt" 10 "io" 11 "maps" 12 "net/http" 13 "net/url" 14 "os" 15 "path" 16 "slices" 17 "strconv" 18 "strings" 19 "time" 20 21 "github.com/klauspost/compress/zstd" 22 "github.com/pquerna/cachecontrol/cacheobject" 23 "github.com/prometheus/client_golang/prometheus" 24 "github.com/prometheus/client_golang/prometheus/promauto" 25 "google.golang.org/protobuf/proto" 26) 27 28const notFoundPage = "404.html" 29 30var ( 31 serveEncodingCount = promauto.NewCounterVec(prometheus.CounterOpts{ 32 Name: "git_pages_serve_encoding_count", 33 Help: "Count of blob transform vs negotiated encoding", 34 }, []string{"transform", "negotiated"}) 35 36 siteUpdatesCount = promauto.NewCounterVec(prometheus.CounterOpts{ 37 Name: "git_pages_site_updates", 38 Help: "Count of site updates in total", 39 }, []string{"via"}) 40 siteUpdateOkCount = promauto.NewCounterVec(prometheus.CounterOpts{ 41 Name: "git_pages_site_update_ok", 42 Help: "Count of successful site updates", 43 }, []string{"outcome"}) 44 siteUpdateErrorCount = promauto.NewCounterVec(prometheus.CounterOpts{ 45 Name: "git_pages_site_update_error", 46 Help: "Count of failed site updates", 47 }, []string{"cause"}) 48) 49 50func observeSiteUpdate(via string, result *UpdateResult) { 51 siteUpdatesCount.With(prometheus.Labels{"via": via}).Inc() 52 switch result.outcome { 53 case UpdateError: 54 siteUpdateErrorCount.With(prometheus.Labels{"cause": "other"}).Inc() 55 case UpdateTimeout: 56 siteUpdateErrorCount.With(prometheus.Labels{"cause": "timeout"}).Inc() 57 case UpdateNoChange: 58 siteUpdateOkCount.With(prometheus.Labels{"outcome": "no-change"}).Inc() 59 case UpdateCreated: 60 siteUpdateOkCount.With(prometheus.Labels{"outcome": "created"}).Inc() 61 case UpdateReplaced: 62 siteUpdateOkCount.With(prometheus.Labels{"outcome": "replaced"}).Inc() 63 case UpdateDeleted: 64 siteUpdateOkCount.With(prometheus.Labels{"outcome": "deleted"}).Inc() 65 } 66} 67 68func makeWebRoot(host string, projectName string) string { 69 return path.Join(strings.ToLower(host), projectName) 70} 71 72func getWebRoot(r *http.Request) (string, error) { 73 host, err := GetHost(r) 74 if err != nil { 75 return "", err 76 } 77 78 projectName, err := GetProjectName(r) 79 if err != nil { 80 return "", err 81 } 82 83 return makeWebRoot(host, projectName), nil 84} 85 86func writeRedirect(w http.ResponseWriter, code int, path string) { 87 w.Header().Set("Location", path) 88 w.WriteHeader(code) 89 fmt.Fprintf(w, "see %s\n", path) 90} 91 92// The `clauspost/compress/zstd` package recommends reusing a decompressor to avoid repeated 93// allocations of internal buffers. 94var zstdDecoder, _ = zstd.NewReader(nil) 95 96func getPage(w http.ResponseWriter, r *http.Request) error { 97 var err error 98 var sitePath string 99 var manifest *Manifest 100 var metadata ManifestMetadata 101 102 cacheControl, err := cacheobject.ParseRequestCacheControl(r.Header.Get("Cache-Control")) 103 if err != nil { 104 cacheControl = &cacheobject.RequestCacheDirectives{ 105 MaxAge: -1, 106 MaxStale: -1, 107 MinFresh: -1, 108 } 109 } 110 111 bypassCache := cacheControl.NoCache || cacheControl.MaxAge == 0 112 113 host, err := GetHost(r) 114 if err != nil { 115 return err 116 } 117 118 type indexManifestResult struct { 119 manifest *Manifest 120 metadata ManifestMetadata 121 err error 122 } 123 indexManifestCh := make(chan indexManifestResult, 1) 124 go func() { 125 manifest, metadata, err := backend.GetManifest( 126 r.Context(), makeWebRoot(host, ".index"), 127 GetManifestOptions{BypassCache: bypassCache}, 128 ) 129 indexManifestCh <- (indexManifestResult{manifest, metadata, err}) 130 }() 131 132 err = nil 133 sitePath = strings.TrimPrefix(r.URL.Path, "/") 134 if projectName, projectPath, hasProjectSlash := strings.Cut(sitePath, "/"); projectName != "" { 135 if IsValidProjectName(projectName) { 136 var projectManifest *Manifest 137 var projectMetadata ManifestMetadata 138 projectManifest, projectMetadata, err = backend.GetManifest( 139 r.Context(), makeWebRoot(host, projectName), 140 GetManifestOptions{BypassCache: bypassCache}, 141 ) 142 if err == nil { 143 if !hasProjectSlash { 144 writeRedirect(w, http.StatusFound, r.URL.Path+"/") 145 return nil 146 } 147 sitePath, manifest, metadata = projectPath, projectManifest, projectMetadata 148 } 149 } 150 } 151 if manifest == nil && (err == nil || errors.Is(err, ErrObjectNotFound)) { 152 result := <-indexManifestCh 153 manifest, metadata, err = result.manifest, result.metadata, result.err 154 if manifest == nil && errors.Is(err, ErrObjectNotFound) { 155 if fallback != nil { 156 logc.Printf(r.Context(), "fallback: %s via %s", host, config.Fallback.ProxyTo) 157 fallback.ServeHTTP(w, r) 158 return nil 159 } else { 160 w.WriteHeader(http.StatusNotFound) 161 fmt.Fprintf(w, "site not found\n") 162 return err 163 } 164 } 165 } 166 if err != nil { 167 ObserveError(err) // all storage errors must be reported 168 w.WriteHeader(http.StatusInternalServerError) 169 fmt.Fprintf(w, "internal server error (%s)\n", err) 170 return err 171 } 172 173 if r.Header.Get("Origin") != "" { 174 // allow JavaScript code to access responses (including errors) even across origins 175 w.Header().Set("Access-Control-Allow-Origin", "*") 176 } 177 178 if sitePath == ".git-pages" { 179 // metadata directory name shouldn't be served even if present in site manifest 180 w.WriteHeader(http.StatusNotFound) 181 fmt.Fprintf(w, "not found\n") 182 return nil 183 } 184 if metadataPath, found := strings.CutPrefix(sitePath, ".git-pages/"); found { 185 lastModified := metadata.LastModified.UTC().Format(http.TimeFormat) 186 switch { 187 case metadataPath == "health": 188 w.Header().Add("Last-Modified", lastModified) 189 w.Header().Add("ETag", fmt.Sprintf("\"%s\"", metadata.ETag)) 190 w.WriteHeader(http.StatusOK) 191 fmt.Fprintf(w, "ok\n") 192 return nil 193 194 case metadataPath == "manifest.json": 195 // metadata requests require authorization to avoid making pushes from private 196 // repositories enumerable 197 _, err := AuthorizeMetadataRetrieval(r) 198 if err != nil { 199 return err 200 } 201 202 w.Header().Add("Content-Type", "application/json; charset=utf-8") 203 w.Header().Add("Last-Modified", lastModified) 204 w.Header().Add("ETag", fmt.Sprintf("\"%s-manifest\"", metadata.ETag)) 205 w.WriteHeader(http.StatusOK) 206 w.Write(ManifestJSON(manifest)) 207 return nil 208 209 case metadataPath == "archive.tar": 210 // same as above 211 _, err := AuthorizeMetadataRetrieval(r) 212 if err != nil { 213 return err 214 } 215 216 // we only offer `/.git-pages/archive.tar` and not the `.tar.gz`/`.tar.zst` variants 217 // because HTTP can already request compression using the `Content-Encoding` mechanism 218 acceptedEncodings := ParseAcceptEncodingHeader(r.Header.Get("Accept-Encoding")) 219 w.Header().Add("Vary", "Accept-Encoding") 220 negotiated := acceptedEncodings.Negotiate("zstd", "gzip", "identity") 221 if negotiated != "" { 222 w.Header().Set("Content-Encoding", negotiated) 223 } 224 w.Header().Add("Content-Type", "application/x-tar") 225 w.Header().Add("Last-Modified", lastModified) 226 w.Header().Add("ETag", fmt.Sprintf("\"%s-archive\"", metadata.ETag)) 227 w.Header().Add("Transfer-Encoding", "chunked") 228 w.WriteHeader(http.StatusOK) 229 var iow io.Writer 230 switch negotiated { 231 case "", "identity": 232 iow = w 233 case "gzip": 234 iow = gzip.NewWriter(w) 235 case "zstd": 236 iow, _ = zstd.NewWriter(w) 237 } 238 return CollectTar(r.Context(), iow, manifest, metadata) 239 240 default: 241 w.WriteHeader(http.StatusNotFound) 242 fmt.Fprintf(w, "not found\n") 243 return nil 244 } 245 } 246 247 entryPath := sitePath 248 entry := (*Entry)(nil) 249 appliedRedirect := false 250 status := http.StatusOK 251 reader := io.ReadSeeker(nil) 252 mtime := time.Time{} 253 for { 254 entryPath, _ = strings.CutSuffix(entryPath, "/") 255 entryPath, err = ExpandSymlinks(manifest, entryPath) 256 if err != nil { 257 w.WriteHeader(http.StatusInternalServerError) 258 fmt.Fprintln(w, err) 259 return err 260 } 261 entry = manifest.Contents[entryPath] 262 if !appliedRedirect { 263 redirectKind := RedirectAny 264 if entry != nil && entry.GetType() != Type_InvalidEntry { 265 redirectKind = RedirectForce 266 } 267 originalURL := (&url.URL{Host: r.Host}).ResolveReference(r.URL) 268 _, redirectURL, redirectStatus := ApplyRedirectRules(manifest, originalURL, redirectKind) 269 if Is3xxHTTPStatus(redirectStatus) { 270 writeRedirect(w, redirectStatus, redirectURL.String()) 271 return nil 272 } else if redirectURL != nil { 273 entryPath = strings.TrimPrefix(redirectURL.Path, "/") 274 status = int(redirectStatus) 275 // Apply user redirects at most once; if something ends in a loop, it should be 276 // the user agent, not the pages server. 277 appliedRedirect = true 278 continue 279 } 280 } 281 if entry == nil || entry.GetType() == Type_InvalidEntry { 282 status = http.StatusNotFound 283 if entryPath != notFoundPage { 284 entryPath = notFoundPage 285 continue 286 } else { 287 reader = bytes.NewReader([]byte("not found\n")) 288 break 289 } 290 } else if entry.GetType() == Type_InlineFile { 291 reader = bytes.NewReader(entry.Data) 292 } else if entry.GetType() == Type_ExternalFile { 293 etag := fmt.Sprintf(`"%s"`, entry.Data) 294 if r.Header.Get("If-None-Match") == etag { 295 w.WriteHeader(http.StatusNotModified) 296 return nil 297 } else { 298 var metadata BlobMetadata 299 reader, metadata, err = backend.GetBlob(r.Context(), string(entry.Data)) 300 if err != nil { 301 ObserveError(err) // all storage errors must be reported 302 w.WriteHeader(http.StatusInternalServerError) 303 fmt.Fprintf(w, "internal server error: %s\n", err) 304 return err 305 } 306 mtime = metadata.LastModified 307 w.Header().Set("ETag", etag) 308 } 309 } else if entry.GetType() == Type_Directory { 310 if strings.HasSuffix(r.URL.Path, "/") { 311 entryPath = path.Join(entryPath, "index.html") 312 continue 313 } else { 314 // redirect from `dir` to `dir/`, otherwise when `dir/index.html` is served, 315 // links in it will have the wrong base URL 316 newPath := r.URL.Path + "/" 317 writeRedirect(w, http.StatusFound, newPath) 318 return nil 319 } 320 } else if entry.GetType() == Type_Symlink { 321 return fmt.Errorf("unexpected symlink") 322 } 323 break 324 } 325 if closer, ok := reader.(io.Closer); ok { 326 defer closer.Close() 327 } 328 329 var offeredEncodings []string 330 acceptedEncodings := ParseAcceptEncodingHeader(r.Header.Get("Accept-Encoding")) 331 w.Header().Add("Vary", "Accept-Encoding") 332 negotiatedEncoding := true 333 switch entry.GetTransform() { 334 case Transform_Identity: 335 offeredEncodings = []string{"identity"} 336 switch acceptedEncodings.Negotiate(offeredEncodings...) { 337 case "identity": 338 serveEncodingCount. 339 With(prometheus.Labels{"transform": "identity", "negotiated": "identity"}). 340 Inc() 341 default: 342 negotiatedEncoding = false 343 serveEncodingCount. 344 With(prometheus.Labels{"transform": "identity", "negotiated": "failure"}). 345 Inc() 346 } 347 case Transform_Zstd: 348 offeredEncodings = []string{"zstd", "identity"} 349 if entry.ContentType == nil { 350 // If Content-Type is unset, `http.ServeContent` will try to sniff 351 // the file contents. That won't work if it's compressed. 352 offeredEncodings = []string{"identity"} 353 } 354 switch acceptedEncodings.Negotiate(offeredEncodings...) { 355 case "zstd": 356 // Set Content-Length ourselves since `http.ServeContent` only sets 357 // it if Content-Encoding is unset or if it's a range request. 358 w.Header().Set("Content-Length", strconv.FormatInt(entry.GetCompressedSize(), 10)) 359 w.Header().Set("Content-Encoding", "zstd") 360 serveEncodingCount. 361 With(prometheus.Labels{"transform": "zstd", "negotiated": "zstd"}). 362 Inc() 363 case "identity": 364 compressedData, _ := io.ReadAll(reader) 365 decompressedData, err := zstdDecoder.DecodeAll(compressedData, []byte{}) 366 if err != nil { 367 w.WriteHeader(http.StatusInternalServerError) 368 fmt.Fprintf(w, "internal server error: %s\n", err) 369 return err 370 } 371 reader = bytes.NewReader(decompressedData) 372 serveEncodingCount. 373 With(prometheus.Labels{"transform": "zstd", "negotiated": "identity"}). 374 Inc() 375 default: 376 negotiatedEncoding = false 377 serveEncodingCount. 378 With(prometheus.Labels{"transform": "zstd", "negotiated": "failure"}). 379 Inc() 380 } 381 default: 382 return fmt.Errorf("unexpected transform") 383 } 384 if !negotiatedEncoding { 385 w.Header().Set("Accept-Encoding", strings.Join(offeredEncodings, ", ")) 386 w.WriteHeader(http.StatusNotAcceptable) 387 return fmt.Errorf("no supported content encodings (Accept-Encoding: %s)", 388 r.Header.Get("Accept-Encoding")) 389 } 390 391 if entry != nil && entry.ContentType != nil { 392 w.Header().Set("X-Content-Type-Options", "nosniff") 393 w.Header().Set("Content-Type", *entry.ContentType) 394 } 395 396 customHeaders, err := ApplyHeaderRules(manifest, &url.URL{Path: entryPath}) 397 if err != nil { 398 // This is an "internal server error" from an HTTP point of view, but also 399 // either an issue with the site or a misconfiguration from our point of view. 400 // Since it's not a problem with the server we don't observe the error. 401 // 402 // Note that this behavior is different from a site upload with a malformed 403 // `_headers` file (where it is semantically ignored); this is because a broken 404 // upload is something the uploader can notice and fix, but a change in server 405 // configuration is something they are unaware of and won't be notified of. 406 w.WriteHeader(http.StatusInternalServerError) 407 fmt.Fprintf(w, "%s\n", err) 408 return err 409 } else { 410 // If the header has passed all of our stringent, deny-by-default checks, it means 411 // it's good enough to overwrite whatever was our builtin option (if any). 412 maps.Copy(w.Header(), customHeaders) 413 } 414 415 // decide on the HTTP status 416 if status != 200 { 417 w.WriteHeader(status) 418 if reader != nil { 419 io.Copy(w, reader) 420 } 421 } else { 422 if _, hasCacheControl := w.Header()["Cache-Control"]; !hasCacheControl { 423 // consider content fresh for 60 seconds (the same as the freshness interval of 424 // manifests in the S3 backend), and use stale content anyway as long as it's not 425 // older than a hour; while it is cheap to handle If-Modified-Since queries 426 // server-side, on the client `max-age=0, must-revalidate` causes every resource 427 // to block the page load every time 428 w.Header().Set("Cache-Control", "max-age=60, stale-while-revalidate=3600") 429 // see https://web.dev/articles/stale-while-revalidate for details 430 } 431 432 // http.ServeContent handles conditional requests and range requests 433 http.ServeContent(w, r, entryPath, mtime, reader) 434 } 435 return nil 436} 437 438func checkDryRun(w http.ResponseWriter, r *http.Request) bool { 439 // "Dry run" requests are used to non-destructively check if the request would have 440 // successfully been authorized. 441 if r.Header.Get("Dry-Run") != "" { 442 fmt.Fprintln(w, "dry-run ok") 443 return true 444 } 445 return false 446} 447 448func putPage(w http.ResponseWriter, r *http.Request) error { 449 var result UpdateResult 450 451 for _, header := range []string{ 452 "If-Modified-Since", "If-Unmodified-Since", "If-Match", "If-None-Match", 453 } { 454 if r.Header.Get(header) != "" { 455 http.Error(w, fmt.Sprintf("unsupported precondition %s", header), http.StatusBadRequest) 456 return nil 457 } 458 } 459 460 webRoot, err := getWebRoot(r) 461 if err != nil { 462 return err 463 } 464 465 ctx, cancel := context.WithTimeout(r.Context(), time.Duration(config.Limits.UpdateTimeout)) 466 defer cancel() 467 468 contentType := getMediaType(r.Header.Get("Content-Type")) 469 switch contentType { 470 case "application/x-www-form-urlencoded": 471 auth, err := AuthorizeUpdateFromRepository(r) 472 if err != nil { 473 return err 474 } 475 476 // URLs have no length limit, but 64K seems enough for a repository URL 477 requestBody, err := io.ReadAll(http.MaxBytesReader(w, r.Body, 65536)) 478 if err != nil { 479 return fmt.Errorf("body read: %w", err) 480 } 481 482 repoURL := string(requestBody) 483 if err := AuthorizeRepository(repoURL, auth); err != nil { 484 return err 485 } 486 487 branch := "pages" 488 if customBranch := r.Header.Get("Branch"); customBranch != "" { 489 branch = customBranch 490 } 491 if err := AuthorizeBranch(branch, auth); err != nil { 492 return err 493 } 494 495 if checkDryRun(w, r) { 496 return nil 497 } 498 499 result = UpdateFromRepository(ctx, webRoot, repoURL, branch) 500 501 default: 502 _, err := AuthorizeUpdateFromArchive(r) 503 if err != nil { 504 return err 505 } 506 507 if checkDryRun(w, r) { 508 return nil 509 } 510 511 // request body contains archive 512 reader := http.MaxBytesReader(w, r.Body, int64(config.Limits.MaxSiteSize.Bytes())) 513 result = UpdateFromArchive(ctx, webRoot, contentType, reader) 514 } 515 516 return reportUpdateResult(w, r, result) 517} 518 519func patchPage(w http.ResponseWriter, r *http.Request) error { 520 for _, header := range []string{ 521 "If-Modified-Since", "If-Unmodified-Since", "If-Match", "If-None-Match", 522 } { 523 if r.Header.Get(header) != "" { 524 http.Error(w, fmt.Sprintf("unsupported precondition %s", header), http.StatusBadRequest) 525 return nil 526 } 527 } 528 529 webRoot, err := getWebRoot(r) 530 if err != nil { 531 return err 532 } 533 534 if _, err = AuthorizeUpdateFromArchive(r); err != nil { 535 return err 536 } 537 538 if checkDryRun(w, r) { 539 return nil 540 } 541 542 // Providing atomic compare-and-swap operations might be difficult or impossible depending 543 // on the backend in use and its configuration, but for applications where a mostly-atomic 544 // compare-and-swap operation is good enough (e.g. generating page previews) we don't want 545 // to prevent the use of partial updates. 546 wantAtomicCAS := r.Header.Get("Atomic") 547 hasAtomicCAS := backend.HasAtomicCAS(r.Context()) 548 switch { 549 case wantAtomicCAS == "yes" && hasAtomicCAS || wantAtomicCAS == "no": 550 // all good 551 case wantAtomicCAS == "yes": 552 http.Error(w, "atomic partial updates unsupported", http.StatusPreconditionFailed) 553 return nil 554 case wantAtomicCAS == "": 555 http.Error(w, "must provide \"Atomic: yes|no\" header", http.StatusPreconditionRequired) 556 return nil 557 default: 558 http.Error(w, "malformed Atomic: header", http.StatusBadRequest) 559 return nil 560 } 561 562 var parents CreateParentsMode 563 switch r.Header.Get("Create-Parents") { 564 case "", "no": 565 parents = RequireParents 566 case "yes": 567 parents = CreateParents 568 default: 569 http.Error(w, "malformed Create-Parents: header", http.StatusBadRequest) 570 return nil 571 } 572 573 ctx, cancel := context.WithTimeout(r.Context(), time.Duration(config.Limits.UpdateTimeout)) 574 defer cancel() 575 576 contentType := getMediaType(r.Header.Get("Content-Type")) 577 reader := http.MaxBytesReader(w, r.Body, int64(config.Limits.MaxSiteSize.Bytes())) 578 result := PartialUpdateFromArchive(ctx, webRoot, contentType, reader, parents) 579 return reportUpdateResult(w, r, result) 580} 581 582func reportUpdateResult(w http.ResponseWriter, r *http.Request, result UpdateResult) error { 583 var unresolvedRefErr UnresolvedRefError 584 if result.outcome == UpdateError && errors.As(result.err, &unresolvedRefErr) { 585 offeredContentTypes := []string{"text/plain", "application/vnd.git-pages.unresolved"} 586 acceptedContentTypes := ParseAcceptHeader(r.Header.Get("Accept")) 587 switch acceptedContentTypes.Negotiate(offeredContentTypes...) { 588 default: 589 w.Header().Set("Accept", strings.Join(offeredContentTypes, ", ")) 590 w.WriteHeader(http.StatusNotAcceptable) 591 return fmt.Errorf("no supported content types (Accept: %s)", r.Header.Get("Accept")) 592 case "application/vnd.git-pages.unresolved": 593 w.Header().Set("Content-Type", "application/vnd.git-pages.unresolved") 594 w.WriteHeader(http.StatusUnprocessableEntity) 595 for _, missingRef := range unresolvedRefErr.missing { 596 fmt.Fprintln(w, missingRef) 597 } 598 return nil 599 case "text/plain": 600 // handled below 601 } 602 } 603 604 switch result.outcome { 605 case UpdateError: 606 if errors.Is(result.err, ErrSiteTooLarge) { 607 w.WriteHeader(http.StatusUnprocessableEntity) 608 } else if errors.Is(result.err, ErrManifestTooLarge) { 609 w.WriteHeader(http.StatusUnprocessableEntity) 610 } else if errors.Is(result.err, errArchiveFormat) { 611 w.WriteHeader(http.StatusUnsupportedMediaType) 612 } else if errors.Is(result.err, ErrArchiveTooLarge) { 613 w.WriteHeader(http.StatusRequestEntityTooLarge) 614 } else if errors.Is(result.err, ErrRepositoryTooLarge) { 615 w.WriteHeader(http.StatusUnprocessableEntity) 616 } else if errors.Is(result.err, ErrMalformedPatch) { 617 w.WriteHeader(http.StatusUnprocessableEntity) 618 } else if errors.Is(result.err, ErrPreconditionFailed) { 619 w.WriteHeader(http.StatusPreconditionFailed) 620 } else if errors.Is(result.err, ErrWriteConflict) { 621 w.WriteHeader(http.StatusConflict) 622 } else if errors.Is(result.err, ErrDomainFrozen) { 623 w.WriteHeader(http.StatusForbidden) 624 } else if errors.As(result.err, &unresolvedRefErr) { 625 w.WriteHeader(http.StatusUnprocessableEntity) 626 } else { 627 w.WriteHeader(http.StatusServiceUnavailable) 628 } 629 case UpdateTimeout: 630 w.WriteHeader(http.StatusGatewayTimeout) 631 case UpdateNoChange: 632 w.Header().Add("Update-Result", "no-change") 633 case UpdateCreated: 634 w.Header().Add("Update-Result", "created") 635 case UpdateReplaced: 636 w.Header().Add("Update-Result", "replaced") 637 case UpdateDeleted: 638 w.Header().Add("Update-Result", "deleted") 639 } 640 if result.manifest != nil { 641 if result.manifest.Commit != nil { 642 fmt.Fprintln(w, *result.manifest.Commit) 643 } else { 644 fmt.Fprintln(w, "(archive)") 645 } 646 for _, problem := range GetProblemReport(result.manifest) { 647 fmt.Fprintln(w, problem) 648 } 649 } else if result.err != nil { 650 fmt.Fprintln(w, result.err) 651 } else { 652 fmt.Fprintln(w, "internal error") 653 } 654 observeSiteUpdate("rest", &result) 655 return nil 656} 657 658func deletePage(w http.ResponseWriter, r *http.Request) error { 659 webRoot, err := getWebRoot(r) 660 if err != nil { 661 return err 662 } 663 664 _, err = AuthorizeUpdateFromRepository(r) 665 if err != nil { 666 return err 667 } 668 669 if checkDryRun(w, r) { 670 return nil 671 } 672 673 if err = backend.DeleteManifest(r.Context(), webRoot, ModifyManifestOptions{}); err != nil { 674 w.WriteHeader(http.StatusInternalServerError) 675 fmt.Fprintln(w, err) 676 } else { 677 w.Header().Add("Update-Result", "deleted") 678 w.WriteHeader(http.StatusOK) 679 } 680 return err 681} 682 683func postPage(w http.ResponseWriter, r *http.Request) error { 684 // Start a timer for the request timeout immediately. 685 requestTimeout := 3 * time.Second 686 requestTimer := time.NewTimer(requestTimeout) 687 688 webRoot, err := getWebRoot(r) 689 if err != nil { 690 return err 691 } 692 693 auth, err := AuthorizeUpdateFromRepository(r) 694 if err != nil { 695 return err 696 } 697 698 eventName := "" 699 for _, header := range []string{ 700 "X-Forgejo-Event", 701 "X-GitHub-Event", 702 "X-Gitea-Event", 703 "X-Gogs-Event", 704 } { 705 eventName = r.Header.Get(header) 706 if eventName != "" { 707 break 708 } 709 } 710 711 if eventName == "" { 712 http.Error(w, 713 "expected a Forgejo, GitHub, Gitea, or Gogs webhook request", http.StatusBadRequest) 714 return fmt.Errorf("event expected") 715 } 716 717 if eventName != "push" { 718 http.Error(w, "only push events are allowed", http.StatusBadRequest) 719 return fmt.Errorf("invalid event") 720 } 721 722 if r.Header.Get("Content-Type") != "application/json" { 723 http.Error(w, "only JSON payload is allowed", http.StatusBadRequest) 724 return fmt.Errorf("invalid content type") 725 } 726 727 // Event payloads have no length limit, but events bigger than 16M seem excessive. 728 requestBody, err := io.ReadAll(http.MaxBytesReader(w, r.Body, 16*1048576)) 729 if err != nil { 730 return fmt.Errorf("body read: %w", err) 731 } 732 733 var event struct { 734 Ref string `json:"ref"` 735 Repository struct { 736 CloneURL string `json:"clone_url"` 737 } `json:"repository"` 738 } 739 err = json.NewDecoder(bytes.NewReader(requestBody)).Decode(&event) 740 if err != nil { 741 http.Error(w, fmt.Sprintf("invalid request body: %s", err), http.StatusBadRequest) 742 return err 743 } 744 745 if event.Ref != path.Join("refs", "heads", auth.branch) { 746 code := http.StatusUnauthorized 747 if strings.Contains(r.Header.Get("User-Agent"), "GitHub-Hookshot") { 748 // GitHub has no way to restrict branches for a webhook, and responding with 401 749 // for every non-pages branch makes the "Recent Deliveries" tab look awful. 750 code = http.StatusOK 751 } 752 http.Error(w, 753 fmt.Sprintf("ref %s not in allowlist [refs/heads/%v]", event.Ref, auth.branch), 754 code) 755 return nil 756 } 757 758 repoURL := event.Repository.CloneURL 759 if err := AuthorizeRepository(repoURL, auth); err != nil { 760 return err 761 } 762 763 if checkDryRun(w, r) { 764 return nil 765 } 766 767 resultChan := make(chan UpdateResult) 768 go func(ctx context.Context) { 769 ctx, cancel := context.WithTimeout(ctx, time.Duration(config.Limits.UpdateTimeout)) 770 defer cancel() 771 772 result := UpdateFromRepository(ctx, webRoot, repoURL, auth.branch) 773 resultChan <- result 774 observeSiteUpdate("webhook", &result) 775 }(context.WithoutCancel(r.Context())) 776 777 var result UpdateResult 778 select { 779 case result = <-resultChan: 780 case <-requestTimer.C: 781 w.WriteHeader(http.StatusAccepted) 782 fmt.Fprintf(w, "updating (taking longer than %s)", requestTimeout) 783 return nil 784 } 785 786 switch result.outcome { 787 case UpdateError: 788 w.WriteHeader(http.StatusServiceUnavailable) 789 fmt.Fprintf(w, "update error: %s\n", result.err) 790 case UpdateTimeout: 791 w.WriteHeader(http.StatusGatewayTimeout) 792 fmt.Fprintln(w, "update timeout") 793 case UpdateNoChange: 794 fmt.Fprintln(w, "unchanged") 795 case UpdateCreated: 796 fmt.Fprintln(w, "created") 797 case UpdateReplaced: 798 fmt.Fprintln(w, "replaced") 799 case UpdateDeleted: 800 fmt.Fprintln(w, "deleted") 801 } 802 if result.manifest != nil { 803 report := GetProblemReport(result.manifest) 804 if len(report) > 0 { 805 fmt.Fprintln(w, "problems:") 806 } 807 for _, problem := range report { 808 fmt.Fprintf(w, "- %s\n", problem) 809 } 810 } 811 return nil 812} 813 814func ServePages(w http.ResponseWriter, r *http.Request) { 815 r = r.WithContext(WithPrincipal(r.Context())) 816 if config.Audit.IncludeIPs != "" { 817 GetPrincipal(r.Context()).IpAddress = proto.String(r.RemoteAddr) 818 } 819 // We want upstream health checks to be done as closely to the normal flow as possible; 820 // any intentional deviation is an opportunity to miss an issue that will affect our 821 // visitors but not our health checks. 822 if r.Header.Get("Health-Check") == "" { 823 var mediaType string 824 switch r.Method { 825 case "HEAD", "GET": 826 mediaType = r.Header.Get("Accept") 827 default: 828 mediaType = r.Header.Get("Content-Type") 829 } 830 logc.Println(r.Context(), "pages:", r.Method, r.Host, r.URL, mediaType) 831 if region := os.Getenv("FLY_REGION"); region != "" { 832 machine_id := os.Getenv("FLY_MACHINE_ID") 833 w.Header().Add("Server", fmt.Sprintf("git-pages (fly.io; %s; %s)", region, machine_id)) 834 ObserveData(r.Context(), "server.name", machine_id, "server.region", region) 835 } else if hostname, err := os.Hostname(); err == nil { 836 if region := os.Getenv("PAGES_REGION"); region != "" { 837 w.Header().Add("Server", fmt.Sprintf("git-pages (%s; %s)", region, hostname)) 838 ObserveData(r.Context(), "server.name", hostname, "server.region", region) 839 } else { 840 w.Header().Add("Server", fmt.Sprintf("git-pages (%s)", hostname)) 841 ObserveData(r.Context(), "server.name", hostname) 842 } 843 } 844 } 845 allowedMethods := []string{"OPTIONS", "HEAD", "GET", "PUT", "PATCH", "DELETE", "POST"} 846 if r.Method == "OPTIONS" || !slices.Contains(allowedMethods, r.Method) { 847 w.Header().Add("Allow", strings.Join(allowedMethods, ", ")) 848 } 849 err := error(nil) 850 switch r.Method { 851 // REST API 852 case "OPTIONS": 853 // no preflight options 854 case "HEAD", "GET": 855 err = getPage(w, r) 856 case "PUT": 857 err = putPage(w, r) 858 case "PATCH": 859 err = patchPage(w, r) 860 case "DELETE": 861 err = deletePage(w, r) 862 // webhook API 863 case "POST": 864 err = postPage(w, r) 865 default: 866 http.Error(w, "method not allowed", http.StatusMethodNotAllowed) 867 err = fmt.Errorf("method %s not allowed", r.Method) 868 } 869 if err != nil { 870 var authErr AuthError 871 if errors.As(err, &authErr) { 872 http.Error(w, prettyErrMsg(err), authErr.code) 873 } 874 var tooLargeErr *http.MaxBytesError 875 if errors.As(err, &tooLargeErr) { 876 message := "request body too large" 877 http.Error(w, message, http.StatusRequestEntityTooLarge) 878 err = errors.New(message) 879 } 880 logc.Println(r.Context(), "pages err:", err) 881 } 882}