internal/httpd/pages.go
154 lines · 5484 bytes
1package httpd
2
3import (
4 "mime"
5 "net"
6 "net/http"
7 "net/url"
8 "path"
9 "strings"
10
11 "gitbay.org/gitbay/internal/control"
12 "gitbay.org/gitbay/internal/gitutil"
13 "gitbay.org/gitbay/internal/store"
14)
15
16// PagesBranch is the branch a repo publishes as its static site.
17const PagesBranch = "refs/heads/pages"
18
19// pagesRouter sends <owner>.<domain> requests to the pages server and
20// everything else to the forge. Pages responses deliberately bypass the
21// forge's security headers: sites need their own scripts, and they run on
22// a separate origin where the forge has no cookies to protect.
23func (s *Server) pagesRouter(forge http.Handler) http.Handler {
24 domain := s.cfg.Pages.Domain
25 siteHost := s.cfg.SiteHost()
26 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
27 host := hostOnly(r.Host)
28 if domain != "" && (host == domain || strings.HasSuffix(host, "."+domain)) {
29 s.servePage(w, r, host)
30 return
31 }
32 // Any other foreign host may be a custom pages domain.
33 if host != siteHost && host != "" {
34 if repo, err := s.st.PageDomainRepo(host); err == nil && repo.Visibility == "public" {
35 if r.Method != http.MethodGet && r.Method != http.MethodHead {
36 http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
37 return
38 }
39 s.servePageFile(w, r, repo, strings.TrimPrefix(path.Clean("/"+r.URL.Path), "/"))
40 return
41 }
42 }
43 forge.ServeHTTP(w, r)
44 })
45}
46
47func hostOnly(hostport string) string {
48 if h, _, err := net.SplitHostPort(hostport); err == nil {
49 return h
50 }
51 return hostport
52}
53
54// servePage maps <owner>.<domain>/<repo>/<path> to the repo's pages
55// branch, and <owner>.<domain>/<path> to the owner's repo named "pages".
56// Private repos and missing branches are plain 404s.
57func (s *Server) servePage(w http.ResponseWriter, r *http.Request, host string) {
58 if r.Method != http.MethodGet && r.Method != http.MethodHead {
59 http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
60 return
61 }
62 // The apex has no site of its own; send visitors to the forge.
63 if host == s.cfg.Pages.Domain {
64 http.Redirect(w, r, s.cfg.Server.SiteURL, http.StatusFound)
65 return
66 }
67 owner, ok := strings.CutSuffix(host, "."+s.cfg.Pages.Domain)
68 if !ok || owner == "" || strings.Contains(owner, ".") {
69 http.NotFound(w, r)
70 return
71 }
72 reqPath := strings.TrimPrefix(path.Clean("/"+r.URL.Path), "/")
73
74 // A first segment naming a public repo with a pages branch wins;
75 // everything else falls through to the owner's "pages" repo.
76 if seg, rest, _ := strings.Cut(reqPath, "/"); seg != "" && seg != "pages" {
77 if repo, err := s.st.RepoByPath(owner + "/" + seg); err == nil && repo.Visibility == "public" {
78 dir := control.RepoDir(s.cfg.Server.Root, repo.OwnerName, repo.Name)
79 if _, err := gitutil.ResolveRef(dir, PagesBranch); err == nil {
80 if rest == "" && !strings.HasSuffix(r.URL.Path, "/") {
81 http.Redirect(w, r, pageRedirectTarget(r.URL.Path), http.StatusMovedPermanently)
82 return
83 }
84 s.servePageFile(w, r, repo, rest)
85 return
86 }
87 }
88 }
89 repo, err := s.st.RepoByPath(owner + "/pages")
90 if err != nil || repo.Visibility != "public" {
91 http.NotFound(w, r)
92 return
93 }
94 s.servePageFile(w, r, repo, reqPath)
95}
96
97func (s *Server) servePageFile(w http.ResponseWriter, r *http.Request, repo store.Repo, filePath string) {
98 dir := control.RepoDir(s.cfg.Server.Root, repo.OwnerName, repo.Name)
99 if filePath == "" {
100 filePath = "index.html"
101 }
102 data, err := gitutil.ReadBlob(dir, PagesBranch, filePath, s.cfg.Limits.MaxBlobBytes)
103 if err != nil {
104 // A directory path serves its index.html; /guide -> /guide/ keeps
105 // relative links working.
106 if idx, ierr := gitutil.ReadBlob(dir, PagesBranch, filePath+"/index.html", s.cfg.Limits.MaxBlobBytes); ierr == nil {
107 if !strings.HasSuffix(r.URL.Path, "/") {
108 http.Redirect(w, r, pageRedirectTarget(r.URL.Path), http.StatusMovedPermanently)
109 return
110 }
111 data, filePath = idx, filePath+"/index.html"
112 } else {
113 http.NotFound(w, r)
114 return
115 }
116 }
117 ct := mime.TypeByExtension(path.Ext(filePath))
118 if ct == "" {
119 ct = http.DetectContentType(data)
120 }
121 w.Header().Set("Content-Type", ct)
122 w.Header().Set("X-Content-Type-Options", "nosniff")
123 w.Header().Set("Cache-Control", "public, max-age=60")
124 if r.Method == http.MethodHead {
125 return
126 }
127 w.Write(data)
128}
129
130// pageRedirectTarget is the "add a trailing slash" destination for a
131// directory URL, normalised so it cannot leave the site.
132//
133// The raw request path is not safe to redirect to. net/url keeps a
134// leading "//", and Go emits `Location: //evil.example/` unchanged, which
135// a browser reads as protocol-relative and follows to another origin.
136// Reaching it needed content named like a host under the subdomain's
137// owner — repository names permit dots — so it was narrow rather than
138// impossible (gosecurity:S5146, #153).
139//
140// path.Clean collapses the leading slashes and resolves any "..", and the
141// result is re-rooted, so the destination is always one same-origin
142// absolute path.
143func pageRedirectTarget(reqPath string) string {
144 clean := path.Clean("/" + reqPath)
145 if clean == "/" {
146 return "/"
147 }
148 // Encoding through url.URL rather than concatenating: a backslash is
149 // not a path separator here but browsers following the WHATWG URL
150 // rules treat one as a slash, so "/\\evil.example/" would be another
151 // way to say "//evil.example/". Escaping settles that, and every other
152 // byte a path can hold, without a denylist.
153 return (&url.URL{Path: clean + "/"}).String()
154}