krz/gitbay
A CLI-first git forge.
clone: git clone https://gitbay.org/krz/gitbay.git
repo-descriptions: internal/httpd/smart.go · raw
1// Package httpd serves the HTTP listener: anonymous smart-HTTP git reads for
2// public repositories, and (from M5) the web UI. There is no authentication
3// on this listener by design — private repositories answer 404 everywhere,
4// and pushes are refused with a pkt-line ERR so no git version ever falls
5// back to asking for credentials.
6package httpd
7
8import (
9 "compress/gzip"
10 "fmt"
11 "io"
12 "net/http"
13 "os"
14 "os/exec"
15 "strings"
16
17 "gitbay.org/gitbay/internal/config"
18 "gitbay.org/gitbay/internal/control"
19 "gitbay.org/gitbay/internal/store"
20)
21
22type Server struct {
23 cfg config.Config
24 st *store.Store
25}
26
27func New(cfg config.Config, st *store.Store) *Server {
28 return &Server{cfg: cfg, st: st}
29}
30
31// receivePackRefusal exists only to fail legibly if a client POSTs without
32// reading the advertisement first.
33func (s *Server) receivePackRefusal(w http.ResponseWriter, r *http.Request) {
34 http.Error(w, s.pushRefusalMessage(r.PathValue("owner"), r.PathValue("repo")), http.StatusForbidden)
35}
36
37// publicRepo resolves owner/name and returns it only if it exists and is
38// public. Every failure mode is the same 404.
39func (s *Server) publicRepo(owner, name string) (store.Repo, bool) {
40 repo, err := s.st.RepoByPath(owner + "/" + name)
41 if err != nil || repo.Visibility != "public" {
42 return store.Repo{}, false
43 }
44 return repo, true
45}
46
47func pktLine(w io.Writer, s string) {
48 fmt.Fprintf(w, "%04x%s", len(s)+4, s)
49}
50
51func pktFlush(w io.Writer) { io.WriteString(w, "0000") }
52
53func (s *Server) pushRefusalMessage(owner, repo string) string {
54 host := strings.TrimSuffix(strings.TrimPrefix(strings.TrimPrefix(s.cfg.Server.SiteURL, "https://"), "http://"), "/")
55 name := strings.TrimSuffix(repo, ".git")
56 return fmt.Sprintf("pushes to this forge go over SSH: git remote set-url --push origin git@%s:%s/%s.git", host, owner, name)
57}
58
59func (s *Server) infoRefs(w http.ResponseWriter, r *http.Request) {
60 owner, name := r.PathValue("owner"), r.PathValue("repo")
61 repo, ok := s.publicRepo(owner, name)
62 if !ok {
63 http.NotFound(w, r)
64 return
65 }
66 switch service := r.URL.Query().Get("service"); service {
67 case "git-upload-pack":
68 w.Header().Set("Content-Type", "application/x-git-upload-pack-advertisement")
69 w.Header().Set("Cache-Control", "no-cache")
70 pktLine(w, "# service=git-upload-pack\n")
71 pktFlush(w)
72 dir := control.RepoDir(s.cfg.Server.Root, repo.OwnerName, repo.Name)
73 cmd := exec.CommandContext(r.Context(), "git", "upload-pack", "--stateless-rpc", "--advertise-refs", dir)
74 cmd.Env = append(os.Environ(), gitProtocolEnv(r)...)
75 cmd.Stdout = w
76 cmd.Run()
77 case "git-receive-pack":
78 // HTTP 200 with a pkt-line ERR: every git version renders this as
79 // "fatal: remote error: ..." and never falls back to credential
80 // prompting the way a 401/403 would.
81 w.Header().Set("Content-Type", "application/x-git-receive-pack-advertisement")
82 w.Header().Set("Cache-Control", "no-cache")
83 pktLine(w, "# service=git-receive-pack\n")
84 pktFlush(w)
85 pktLine(w, "ERR "+s.pushRefusalMessage(owner, name)+"\n")
86 default:
87 // Dumb-protocol clients are not supported.
88 http.NotFound(w, r)
89 }
90}
91
92func (s *Server) uploadPack(w http.ResponseWriter, r *http.Request) {
93 repo, ok := s.publicRepo(r.PathValue("owner"), r.PathValue("repo"))
94 if !ok {
95 http.NotFound(w, r)
96 return
97 }
98 body := io.Reader(r.Body)
99 if r.Header.Get("Content-Encoding") == "gzip" {
100 gz, err := gzip.NewReader(body)
101 if err != nil {
102 http.Error(w, "bad gzip body", http.StatusBadRequest)
103 return
104 }
105 defer gz.Close()
106 body = gz
107 }
108 w.Header().Set("Content-Type", "application/x-git-upload-pack-result")
109 w.Header().Set("Cache-Control", "no-cache")
110 dir := control.RepoDir(s.cfg.Server.Root, repo.OwnerName, repo.Name)
111 cmd := exec.CommandContext(r.Context(), "git", "upload-pack", "--stateless-rpc", dir)
112 cmd.Env = append(os.Environ(), gitProtocolEnv(r)...)
113 cmd.Stdin = body
114 cmd.Stdout = w
115 cmd.Run()
116}
117
118// gitProtocolEnv forwards the client's protocol negotiation header so
119// protocol v2 works over stateless HTTP.
120func gitProtocolEnv(r *http.Request) []string {
121 if p := r.Header.Get("Git-Protocol"); p != "" {
122 return []string{"GIT_PROTOCOL=" + p}
123 }
124 return nil
125}