package forge import ( "bytes" "mime" "net" "net/http" "path" "regexp" "strings" "time" ) const ( pagesBranch = "pages" pagesRepo = "pages" maxPage = 32 << 20 pagesMinAge = 24 * time.Hour ) var pagesLabel = regexp.MustCompile(`^[a-z0-9]([a-z0-9-]{0,37}[a-z0-9])?$`) var pageTypes = map[string]string{ ".html": "text/html; charset=utf-8", ".htm": "text/html; charset=utf-8", ".css": "text/css; charset=utf-8", ".js": "text/javascript; charset=utf-8", ".mjs": "text/javascript; charset=utf-8", ".json": "application/json", ".map": "application/json", ".txt": "text/plain; charset=utf-8", ".md": "text/plain; charset=utf-8", ".xml": "application/xml", ".svg": "image/svg+xml", ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".gif": "image/gif", ".webp": "image/webp", ".avif": "image/avif", ".ico": "image/x-icon", ".woff": "font/woff", ".woff2": "font/woff2", ".ttf": "font/ttf", ".otf": "font/otf", ".wasm": "application/wasm", ".pdf": "application/pdf", ".mp4": "video/mp4", ".webm": "video/webm", ".mp3": "audio/mpeg", ".ogg": "audio/ogg", ".wav": "audio/wav", } func pageType(name string) string { ext := strings.ToLower(path.Ext(name)) if t, ok := pageTypes[ext]; ok { return t } if t := mime.TypeByExtension(ext); t != "" { return t } return "application/octet-stream" } func bareHost(host string) string { host = strings.ToLower(host) if h, _, err := net.SplitHostPort(host); err == nil { host = h } return strings.TrimSuffix(host, ".") } func (s *Server) pagesOwner(host string) (label string, sub bool) { host = bareHost(host) label, sub = strings.CutSuffix(host, "."+s.host) if !sub || !pagesLabel.MatchString(label) || reservedUsers[label] { return "", sub } return label, true } type pageSite struct { repo Repo g Git commit string prefix string } func (s *Server) pagesEnabled(owner User) bool { return time.Since(owner.Created) >= pagesMinAge && s.store.PagesAllowed(owner.ID) } func (s *Server) pageSite(owner User, name string, branchOnly bool) (pageSite, bool) { repo, err := s.store.RepoByPath(owner.Name, name) if err != nil { return pageSite{}, false } g := Git{Dir: s.repoDir(repo)} commit, err := g.Resolve("refs/heads/" + pagesBranch) if err != nil && !branchOnly && !repo.Private { commit, err = g.Resolve("HEAD") } if err != nil { return pageSite{}, false } return pageSite{repo: repo, g: g, commit: commit}, true } func (g Git) pageEntry(commit, filePath string) (mode, oid string, ok bool) { out, err := g.run(nil, "ls-tree", "-z", "--full-tree", commit, "--", filePath) if err != nil || len(out) == 0 { return "", "", false } meta, name, found := strings.Cut(strings.TrimSuffix(string(out), "\x00"), "\t") f := strings.Fields(meta) if !found || name != filePath || len(f) != 3 { return "", "", false } return f[0], f[2], true } func (s *Server) servePages(w http.ResponseWriter, r *http.Request, label string) { h := w.Header() h.Set("X-Content-Type-Options", "nosniff") h.Set("Referrer-Policy", "strict-origin-when-cross-origin") if r.Method != "GET" && r.Method != "HEAD" { h.Set("Allow", "GET, HEAD") http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } if !s.requests.allow(clientIP(r)) { h.Set("Retry-After", "10") http.Error(w, "too many requests, slow down", http.StatusTooManyRequests) return } notFound := func() { http.Error(w, "page not found", http.StatusNotFound) } owner, err := s.store.UserByName(label) if err != nil || s.store.IsBanned(owner.ID) || !s.pagesEnabled(owner) { notFound() return } rel, ok := cleanPath(r.URL.Path) if !ok { notFound() return } var site pageSite first, rest, _ := strings.Cut(rel, "/") if first != "" && first != pagesRepo && validName(first) { if sub, found := s.pageSite(owner, first, true); found { if rest == "" && !strings.HasSuffix(r.URL.Path, "/") { http.Redirect(w, r, "/"+first+"/", http.StatusMovedPermanently) return } site, rel = sub, rest site.prefix = "/" + first } } if site.commit == "" { root, found := s.pageSite(owner, pagesRepo, false) if !found { notFound() return } site = root } file := rel if file == "" || strings.HasSuffix(r.URL.Path, "/") { file = path.Join(file, "index.html") } mode, oid, found := site.g.pageEntry(site.commit, file) if found && mode == "040000" { http.Redirect(w, r, site.prefix+"/"+rel+"/", http.StatusMovedPermanently) return } if !found && path.Ext(file) == "" { mode, oid, found = site.g.pageEntry(site.commit, file+".html") file += ".html" } status := http.StatusOK if !found || (mode != "100644" && mode != "100755") { mode, oid, found = site.g.pageEntry(site.commit, "404.html") if !found || (mode != "100644" && mode != "100755") { notFound() return } file, status = "404.html", http.StatusNotFound } size := site.g.Size(site.commit, file) if size > maxPage { http.Error(w, "file too large for pages", http.StatusRequestEntityTooLarge) return } if size > 1<<20 && !s.heavy.allow(clientIP(r)) { h.Set("Retry-After", "10") http.Error(w, "too many requests, slow down", http.StatusTooManyRequests) return } data, err := site.g.run(nil, "cat-file", "blob", oid) if err != nil { notFound() return } h.Set("Content-Type", pageType(file)) h.Set("Cache-Control", "public, max-age=60") if status != http.StatusOK { w.WriteHeader(status) if r.Method == "GET" { w.Write(data) } return } h.Set("ETag", `"`+oid+`"`) http.ServeContent(w, r, "", time.Time{}, bytes.NewReader(data)) }