internal/httpd/pages.go

96df83f2d3eb9f241bcaa53fcc243d090c53ab2b
gitbay/internal/httpd/pages.go history · blame · raw

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}