133728e barerepo 1mo
1
// Package httpd serves the interface and git over http, per appendix C and chapter 41.4.
133728e barerepo 1mo
2
package httpd
133728e barerepo 1mo
4
import (
133728e barerepo 1mo
5
"compress/gzip"
133728e barerepo 1mo
6
"encoding/json"
133728e barerepo 1mo
7
"errors"
133728e barerepo 1mo
8
"fmt"
133728e barerepo 1mo
9
"io"
133728e barerepo 1mo
10
"net/http"
133728e barerepo 1mo
11
"strings"
133728e barerepo 1mo
13
"github.com/barerepo/server/internal/gitx"
133728e barerepo 1mo
14
"github.com/barerepo/server/internal/hook"
133728e barerepo 1mo
15
"github.com/barerepo/server/internal/transport"
133728e barerepo 1mo
18
// gitRoute is a parsed git-over-http request.
133728e barerepo 1mo
19
type gitRoute struct {
133728e barerepo 1mo
20
Owner, Name string
133728e barerepo 1mo
21
Service string // git-upload-pack or git-receive-pack
133728e barerepo 1mo
22
Advertise bool // the GET /info/refs half
133728e barerepo 1mo
23
// LFS marks the large file endpoints, which this server answers only to refuse.
133728e barerepo 1mo
24
LFS bool
133728e barerepo 1mo
27
// parseGitPath accepts the path with and without `.git`, because both reach production.
133728e barerepo 1mo
28
func parseGitPath(p string) (gitRoute, bool) {
133728e barerepo 1mo
29
p = strings.TrimPrefix(p, "/")
133728e barerepo 1mo
30
parts := strings.Split(p, "/")
133728e barerepo 1mo
31
if len(parts) < 3 {
133728e barerepo 1mo
32
return gitRoute{}, false
133728e barerepo 1mo
34
r := gitRoute{Owner: parts[0], Name: strings.TrimSuffix(parts[1], ".git")}
133728e barerepo 1mo
35
rest := strings.Join(parts[2:], "/")
133728e barerepo 1mo
36
switch rest {
133728e barerepo 1mo
37
case "info/refs":
133728e barerepo 1mo
38
r.Advertise = true
133728e barerepo 1mo
39
case "git-upload-pack", "git-receive-pack":
133728e barerepo 1mo
40
r.Service = rest
133728e barerepo 1mo
41
default:
133728e barerepo 1mo
42
// A git-lfs client asks here, and a 404 tells it the repository is missing, which is a lie.
133728e barerepo 1mo
43
if rest == "info/lfs" || strings.HasPrefix(rest, "info/lfs/") {
133728e barerepo 1mo
44
r.LFS = true
133728e barerepo 1mo
45
break
133728e barerepo 1mo
47
return gitRoute{}, false
133728e barerepo 1mo
49
if !gitx.ValidName(r.Owner) || !gitx.ValidRepoName(r.Name) {
133728e barerepo 1mo
50
return gitRoute{}, false
133728e barerepo 1mo
52
return r, true
133728e barerepo 1mo
55
// refuseLFS answers in the shape git-lfs reads, so the client prints the reason instead of a 404.
133728e barerepo 1mo
56
func (s *Server) refuseLFS(w http.ResponseWriter) {
133728e barerepo 1mo
57
w.Header().Set("Content-Type", "application/vnd.git-lfs+json")
133728e barerepo 1mo
58
w.WriteHeader(http.StatusNotImplemented)
133728e barerepo 1mo
59
msg := "large file storage is off on this server. keep binaries out of git, " +
133728e barerepo 1mo
60
"or run your own barerepo where you decide. chapter 20."
133728e barerepo 1mo
61
if s.Cfg.Behavior.AllowLFS {
133728e barerepo 1mo
62
msg = "large file storage is switched on in the config and is not built yet, " +
133728e barerepo 1mo
63
"so nothing here can serve it. chapter 20.3."
133728e barerepo 1mo
65
body, _ := json.Marshal(struct {
133728e barerepo 1mo
66
Message string `json:"message"`
133728e barerepo 1mo
67
}{msg})
133728e barerepo 1mo
68
w.Write(body)
133728e barerepo 1mo
71
// serveGit handles both halves of smart http.
133728e barerepo 1mo
72
func (s *Server) serveGit(w http.ResponseWriter, req *http.Request, r gitRoute) {
133728e barerepo 1mo
73
ctx := req.Context()
133728e barerepo 1mo
75
if r.LFS {
133728e barerepo 1mo
76
s.refuseLFS(w)
133728e barerepo 1mo
77
return
133728e barerepo 1mo
79
if r.Advertise {
133728e barerepo 1mo
80
r.Service = req.URL.Query().Get("service")
133728e barerepo 1mo
81
if r.Service != "git-upload-pack" && r.Service != "git-receive-pack" {
133728e barerepo 1mo
82
// Dumb http asks with no service, and barerepo does not serve it to anyone.
133728e barerepo 1mo
83
http.Error(w, "this server speaks the smart http protocol only", http.StatusForbidden)
133728e barerepo 1mo
84
return
133728e barerepo 1mo
87
// One push is two requests. Only the second one may create anything.
133728e barerepo 1mo
88
intent := transport.Read
133728e barerepo 1mo
89
switch {
133728e barerepo 1mo
90
case r.Service == "git-receive-pack" && r.Advertise:
133728e barerepo 1mo
91
intent = transport.Announce
133728e barerepo 1mo
92
case r.Service == "git-receive-pack":
133728e barerepo 1mo
93
intent = transport.Write
133728e barerepo 1mo
96
user, scope, err := s.authenticate(ctx, req)
133728e barerepo 1mo
97
if err != nil {
133728e barerepo 1mo
98
s.askForCredentials(w)
133728e barerepo 1mo
99
return
133728e barerepo 1mo
100
}
133728e barerepo 1mo
101
// A scoped token is a credential for its own repository and nothing else, so anywhere else the reader is whoever an anonymous one would be. 15.
133728e barerepo 1mo
102
if scope != "" && scope != r.Owner+"/"+r.Name {
133728e barerepo 1mo
103
user = ""
133728e barerepo 1mo
104
}
133728e barerepo 1mo
105
// Ask for the credential before advertising, or the push is refused later for the wrong reason.
133728e barerepo 1mo
106
if intent != transport.Read && user == "" {
133728e barerepo 1mo
107
s.askForCredentials(w)
133728e barerepo 1mo
108
return
133728e barerepo 1mo
109
}
133728e barerepo 1mo
111
res, err := s.Transport.Open(ctx, r.Owner, r.Name, user, intent)
133728e barerepo 1mo
112
var moved transport.Redirect
133728e barerepo 1mo
113
switch {
133728e barerepo 1mo
114
case errors.As(err, &moved):
133728e barerepo 1mo
115
// A 301, which git follows on both transports, so an existing clone keeps working. 44.2.
133728e barerepo 1mo
116
http.Redirect(w, req, s.movedPath(req, moved), http.StatusMovedPermanently)
133728e barerepo 1mo
117
return
133728e barerepo 1mo
118
case errors.Is(err, transport.ErrNotFound):
133728e barerepo 1mo
119
if user == "" {
133728e barerepo 1mo
120
// It may be private, so ask, rather than hand back a 404 nobody can act on.
133728e barerepo 1mo
121
s.askForCredentials(w)
133728e barerepo 1mo
122
return
133728e barerepo 1mo
123
}
133728e barerepo 1mo
124
http.NotFound(w, req)
133728e barerepo 1mo
125
return
133728e barerepo 1mo
126
case errors.Is(err, transport.ErrDenied):
133728e barerepo 1mo
127
http.Error(w, err.Error(), http.StatusForbidden)
133728e barerepo 1mo
128
return
133728e barerepo 1mo
129
case err != nil:
133728e barerepo 1mo
130
s.oops(w, req, err)
133728e barerepo 1mo
131
return
133728e barerepo 1mo
132
}
133728e barerepo 1mo
134
if r.Advertise {
133728e barerepo 1mo
135
s.advertise(w, req, res.Dir, r.Service)
133728e barerepo 1mo
136
return
133728e barerepo 1mo
137
}
133728e barerepo 1mo
138
s.pack(w, req, res, user, r.Service)
133728e barerepo 1mo
141
// advertise answers GET /info/refs?service=...
133728e barerepo 1mo
142
func (s *Server) advertise(w http.ResponseWriter, req *http.Request, dir, service string) {
133728e barerepo 1mo
143
w.Header().Set("Content-Type", "application/x-"+service+"-advertisement")
133728e barerepo 1mo
144
noCache(w)
133728e barerepo 1mo
146
// A pkt-line banner first, except under v2, where git writes the whole body itself.
133728e barerepo 1mo
147
proto := gitProtocol(req)
133728e barerepo 1mo
148
if proto == "" {
133728e barerepo 1mo
149
if err := writePktLine(w, "# service="+service+"\n"); err != nil {
133728e barerepo 1mo
150
return
133728e barerepo 1mo
151
}
133728e barerepo 1mo
152
if _, err := w.Write([]byte("0000")); err != nil {
133728e barerepo 1mo
153
return
133728e barerepo 1mo
154
}
133728e barerepo 1mo
155
}
133728e barerepo 1mo
156
verb := strings.TrimPrefix(service, "git-")
133728e barerepo 1mo
157
err := gitx.Pipe(req.Context(), dir, nil, w, io.Discard, protoEnv(proto),
133728e barerepo 1mo
158
verb, "--stateless-rpc", "--advertise-refs", ".")
133728e barerepo 1mo
159
if err != nil {
133728e barerepo 1mo
160
s.log(req, fmt.Errorf("%s advertise: %w", service, err))
133728e barerepo 1mo
161
}
133728e barerepo 1mo
164
// pack answers POST /git-upload-pack and POST /git-receive-pack.
133728e barerepo 1mo
165
func (s *Server) pack(w http.ResponseWriter, req *http.Request, res *transport.Result, user, service string) {
133728e barerepo 1mo
166
if ct := req.Header.Get("Content-Type"); ct != "application/x-"+service+"-request" {
133728e barerepo 1mo
167
http.Error(w, "unexpected content type", http.StatusBadRequest)
133728e barerepo 1mo
168
return
133728e barerepo 1mo
169
}
133728e barerepo 1mo
170
body := io.Reader(req.Body)
133728e barerepo 1mo
171
if strings.Contains(req.Header.Get("Content-Encoding"), "gzip") {
133728e barerepo 1mo
172
gz, err := gzip.NewReader(req.Body)
133728e barerepo 1mo
173
if err != nil {
133728e barerepo 1mo
174
http.Error(w, "malformed request body", http.StatusBadRequest)
133728e barerepo 1mo
175
return
133728e barerepo 1mo
176
}
133728e barerepo 1mo
177
defer gz.Close()
133728e barerepo 1mo
178
body = gz
133728e barerepo 1mo
179
}
133728e barerepo 1mo
181
w.Header().Set("Content-Type", "application/x-"+service+"-result")
133728e barerepo 1mo
182
noCache(w)
133728e barerepo 1mo
184
// git passes the environment through untouched, which is how pre-receive knows who is asking.
133728e barerepo 1mo
185
env := protoEnv(gitProtocol(req))
133728e barerepo 1mo
186
env = append(env, hook.Env{
133728e barerepo 1mo
187
Account: user, Owner: res.Owner, Name: res.Name, Dir: res.Dir,
133728e barerepo 1mo
188
Created: res.Created,
133728e barerepo 1mo
189
URL: s.Cfg.Server.ExternalURL + "/" + res.Owner + "/" + res.Name,
133728e barerepo 1mo
190
Config: s.Cfg.Path,
133728e barerepo 1mo
191
}.Vars()...)
133728e barerepo 1mo
193
verb := strings.TrimPrefix(service, "git-")
133728e barerepo 1mo
194
// Hook output rides this same connection, which is how a rejection reaches the terminal.
133728e barerepo 1mo
195
err := gitx.Pipe(req.Context(), res.Dir, body, w, io.Discard, env,
133728e barerepo 1mo
196
verb, "--stateless-rpc", ".")
133728e barerepo 1mo
197
if err != nil {
133728e barerepo 1mo
198
s.log(req, fmt.Errorf("%s: %w", service, err))
133728e barerepo 1mo
199
}
133728e barerepo 1mo
202
// gitProtocol checks the request is one of git's two shapes, since it reaches an environment variable.
133728e barerepo 1mo
203
func gitProtocol(req *http.Request) string {
133728e barerepo 1mo
204
v := req.Header.Get("Git-Protocol")
133728e barerepo 1mo
205
if v == "version=2" || v == "version=1" {
133728e barerepo 1mo
206
return v
133728e barerepo 1mo
207
}
133728e barerepo 1mo
208
return ""
133728e barerepo 1mo
211
func protoEnv(proto string) []string {
133728e barerepo 1mo
212
if proto == "" {
133728e barerepo 1mo
213
return nil
133728e barerepo 1mo
214
}
133728e barerepo 1mo
215
return []string{"GIT_PROTOCOL=" + proto}