internal/httpd/pages.go
127 lines · 4247 bytes
1package httpd
2
3import (
4 "mime"
5 "net"
6 "net/http"
7 "path"
8 "strings"
9
10 "gitbay.org/gitbay/internal/control"
11 "gitbay.org/gitbay/internal/gitutil"
12 "gitbay.org/gitbay/internal/store"
13)
14
15// PagesBranch is the branch a repo publishes as its static site.
16const PagesBranch = "refs/heads/pages"
17
18// pagesRouter sends <owner>.<domain> requests to the pages server and
19// everything else to the forge. Pages responses deliberately bypass the
20// forge's security headers: sites need their own scripts, and they run on
21// a separate origin where the forge has no cookies to protect.
22func (s *Server) pagesRouter(forge http.Handler) http.Handler {
23 domain := s.cfg.Pages.Domain
24 siteHost := s.cfg.SiteHost()
25 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
26 host := hostOnly(r.Host)
27 if domain != "" && (host == domain || strings.HasSuffix(host, "."+domain)) {
28 s.servePage(w, r, host)
29 return
30 }
31 // Any other foreign host may be a custom pages domain.
32 if host != siteHost && host != "" {
33 if repo, err := s.st.PageDomainRepo(host); err == nil && repo.Visibility == "public" {
34 if r.Method != http.MethodGet && r.Method != http.MethodHead {
35 http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
36 return
37 }
38 s.servePageFile(w, r, repo, strings.TrimPrefix(path.Clean("/"+r.URL.Path), "/"))
39 return
40 }
41 }
42 forge.ServeHTTP(w, r)
43 })
44}
45
46func hostOnly(hostport string) string {
47 if h, _, err := net.SplitHostPort(hostport); err == nil {
48 return h
49 }
50 return hostport
51}
52
53// servePage maps <owner>.<domain>/<repo>/<path> to the repo's pages
54// branch, and <owner>.<domain>/<path> to the owner's repo named "pages".
55// Private repos and missing branches are plain 404s.
56func (s *Server) servePage(w http.ResponseWriter, r *http.Request, host string) {
57 if r.Method != http.MethodGet && r.Method != http.MethodHead {
58 http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
59 return
60 }
61 // The apex has no site of its own; send visitors to the forge.
62 if host == s.cfg.Pages.Domain {
63 http.Redirect(w, r, s.cfg.Server.SiteURL, http.StatusFound)
64 return
65 }
66 owner, ok := strings.CutSuffix(host, "."+s.cfg.Pages.Domain)
67 if !ok || owner == "" || strings.Contains(owner, ".") {
68 http.NotFound(w, r)
69 return
70 }
71 reqPath := strings.TrimPrefix(path.Clean("/"+r.URL.Path), "/")
72
73 // A first segment naming a public repo with a pages branch wins;
74 // everything else falls through to the owner's "pages" repo.
75 if seg, rest, _ := strings.Cut(reqPath, "/"); seg != "" && seg != "pages" {
76 if repo, err := s.st.RepoByPath(owner + "/" + seg); err == nil && repo.Visibility == "public" {
77 dir := control.RepoDir(s.cfg.Server.Root, repo.OwnerName, repo.Name)
78 if _, err := gitutil.ResolveRef(dir, PagesBranch); err == nil {
79 if rest == "" && !strings.HasSuffix(r.URL.Path, "/") {
80 http.Redirect(w, r, r.URL.Path+"/", http.StatusMovedPermanently)
81 return
82 }
83 s.servePageFile(w, r, repo, rest)
84 return
85 }
86 }
87 }
88 repo, err := s.st.RepoByPath(owner + "/pages")
89 if err != nil || repo.Visibility != "public" {
90 http.NotFound(w, r)
91 return
92 }
93 s.servePageFile(w, r, repo, reqPath)
94}
95
96func (s *Server) servePageFile(w http.ResponseWriter, r *http.Request, repo store.Repo, filePath string) {
97 dir := control.RepoDir(s.cfg.Server.Root, repo.OwnerName, repo.Name)
98 if filePath == "" {
99 filePath = "index.html"
100 }
101 data, err := gitutil.ReadBlob(dir, PagesBranch, filePath, s.cfg.Limits.MaxBlobBytes)
102 if err != nil {
103 // A directory path serves its index.html; /guide -> /guide/ keeps
104 // relative links working.
105 if idx, ierr := gitutil.ReadBlob(dir, PagesBranch, filePath+"/index.html", s.cfg.Limits.MaxBlobBytes); ierr == nil {
106 if !strings.HasSuffix(r.URL.Path, "/") {
107 http.Redirect(w, r, r.URL.Path+"/", http.StatusMovedPermanently)
108 return
109 }
110 data, filePath = idx, filePath+"/index.html"
111 } else {
112 http.NotFound(w, r)
113 return
114 }
115 }
116 ct := mime.TypeByExtension(path.Ext(filePath))
117 if ct == "" {
118 ct = http.DetectContentType(data)
119 }
120 w.Header().Set("Content-Type", ct)
121 w.Header().Set("X-Content-Type-Options", "nosniff")
122 w.Header().Set("Cache-Control", "public, max-age=60")
123 if r.Method == http.MethodHead {
124 return
125 }
126 w.Write(data)
127}