1package web
2
3import (
4 "bytes"
5 "compress/gzip"
6 "context"
7 "errors"
8 "fmt"
9 "io"
10 "net/http"
11 "os"
12 "path/filepath"
13 "regexp"
14 "strings"
15 "time"
16
17 "github.com/charmbracelet/log"
18 gitb "github.com/charmbracelet/soft-serve/git"
19 "github.com/charmbracelet/soft-serve/server/access"
20 "github.com/charmbracelet/soft-serve/server/backend"
21 "github.com/charmbracelet/soft-serve/server/config"
22 "github.com/charmbracelet/soft-serve/server/git"
23 "github.com/charmbracelet/soft-serve/server/proto"
24 "github.com/charmbracelet/soft-serve/server/utils"
25 "github.com/prometheus/client_golang/prometheus"
26 "github.com/prometheus/client_golang/prometheus/promauto"
27 "goji.io/pat"
28 "goji.io/pattern"
29)
30
31// GitRoute is a route for git services.
32type GitRoute struct {
33 method []string
34 pattern *regexp.Regexp
35 handler http.HandlerFunc
36}
37
38var _ Route = GitRoute{}
39
40// Match implements goji.Pattern.
41func (g GitRoute) Match(r *http.Request) *http.Request {
42 re := g.pattern
43 ctx := r.Context()
44 cfg := config.FromContext(ctx)
45 if m := re.FindStringSubmatch(r.URL.Path); m != nil {
46 // This finds the Git objects & packs filenames in the URL.
47 file := strings.Replace(r.URL.Path, m[1]+"/", "", 1)
48 repo := utils.SanitizeRepo(m[1])
49
50 switch {
51 case strings.HasSuffix(r.URL.Path, git.UploadPackService.String()):
52 ctx = context.WithValue(ctx, pattern.Variable("service"), git.UploadPackService.String())
53 case strings.HasSuffix(r.URL.Path, git.ReceivePackService.String()):
54 ctx = context.WithValue(ctx, pattern.Variable("service"), git.ReceivePackService.String())
55 case len(m) > 1:
56 // XXX: right now, the only pattern that captures more than one group
57 // is the Git LFS basic upload/download handler. This captures the LFS
58 // object Oid.
59 // See the Git LFS basic handler down below.
60 // TODO: make this more generic.
61 ctx = context.WithValue(ctx, pattern.Variable("oid"), m[2])
62 }
63
64 ctx = context.WithValue(ctx, pattern.Variable("dir"), filepath.Join(cfg.DataPath, "repos", repo+".git"))
65 ctx = context.WithValue(ctx, pattern.Variable("repo"), repo)
66 ctx = context.WithValue(ctx, pattern.Variable("file"), file)
67
68 return r.WithContext(ctx)
69 }
70
71 return nil
72}
73
74// ServeHTTP implements http.Handler.
75func (g GitRoute) ServeHTTP(w http.ResponseWriter, r *http.Request) {
76 var hasMethod bool
77 for _, m := range g.method {
78 if m == r.Method {
79 hasMethod = true
80 break
81 }
82 }
83
84 if !hasMethod {
85 renderMethodNotAllowed(w, r)
86 return
87 }
88
89 g.handler(w, r)
90}
91
92var (
93 //nolint:revive
94 gitHttpReceiveCounter = promauto.NewCounterVec(prometheus.CounterOpts{
95 Namespace: "soft_serve",
96 Subsystem: "http",
97 Name: "git_receive_pack_total",
98 Help: "The total number of git push requests",
99 }, []string{"repo"})
100
101 //nolint:revive
102 gitHttpUploadCounter = promauto.NewCounterVec(prometheus.CounterOpts{
103 Namespace: "soft_serve",
104 Subsystem: "http",
105 Name: "git_upload_pack_total",
106 Help: "The total number of git fetch/pull requests",
107 }, []string{"repo", "file"})
108)
109
110var gitRoutes = []GitRoute{
111 // Git services
112 // These routes don't handle authentication/authorization.
113 // This is handled through wrapping the handlers for each route.
114 // See below (withAccess).
115 {
116 pattern: regexp.MustCompile("(.*?)/git-upload-pack$"),
117 method: []string{http.MethodPost},
118 handler: serviceRpc,
119 },
120 {
121 pattern: regexp.MustCompile("(.*?)/git-receive-pack$"),
122 method: []string{http.MethodPost},
123 handler: serviceRpc,
124 },
125 {
126 pattern: regexp.MustCompile("(.*?)/info/refs$"),
127 method: []string{http.MethodGet},
128 handler: getInfoRefs,
129 },
130 {
131 pattern: regexp.MustCompile("(.*?)/HEAD$"),
132 method: []string{http.MethodGet},
133 handler: getTextFile,
134 },
135 {
136 pattern: regexp.MustCompile("(.*?)/objects/info/alternates$"),
137 method: []string{http.MethodGet},
138 handler: getTextFile,
139 },
140 {
141 pattern: regexp.MustCompile("(.*?)/objects/info/http-alternates$"),
142 method: []string{http.MethodGet},
143 handler: getTextFile,
144 },
145 {
146 pattern: regexp.MustCompile("(.*?)/objects/info/packs$"),
147 method: []string{http.MethodGet},
148 handler: getInfoPacks,
149 },
150 {
151 pattern: regexp.MustCompile("(.*?)/objects/info/[^/]*$"),
152 method: []string{http.MethodGet},
153 handler: getTextFile,
154 },
155 {
156 pattern: regexp.MustCompile("(.*?)/objects/[0-9a-f]{2}/[0-9a-f]{38}$"),
157 method: []string{http.MethodGet},
158 handler: getLooseObject,
159 },
160 {
161 pattern: regexp.MustCompile(`(.*?)/objects/pack/pack-[0-9a-f]{40}\.pack$`),
162 method: []string{http.MethodGet},
163 handler: getPackFile,
164 },
165 {
166 pattern: regexp.MustCompile(`(.*?)/objects/pack/pack-[0-9a-f]{40}\.idx$`),
167 method: []string{http.MethodGet},
168 handler: getIdxFile,
169 },
170 // Git LFS
171 {
172 pattern: regexp.MustCompile(`(.*?)/info/lfs/objects/batch$`),
173 method: []string{http.MethodPost},
174 handler: serviceLfsBatch,
175 },
176 {
177 // Git LFS basic object handler
178 pattern: regexp.MustCompile(`(.*?)/info/lfs/objects/basic/([0-9a-f]{64})$`),
179 method: []string{http.MethodGet, http.MethodPut},
180 handler: serviceLfsBasic,
181 },
182 {
183 pattern: regexp.MustCompile(`(.*?)/info/lfs/objects/basic/verify$`),
184 method: []string{http.MethodPost},
185 handler: serviceLfsBasicVerify,
186 },
187 // Git LFS locks
188 // TODO: implement locks
189 // {
190 // pattern: regexp.MustCompile(`(.*?)/info/lfs/locks$`),
191 // method: []string{http.MethodPost},
192 // handler: serviceLfsLocksCreate,
193 // },
194}
195
196// withAccess handles auth.
197func withAccess(fn http.HandlerFunc) http.HandlerFunc {
198 return func(w http.ResponseWriter, r *http.Request) {
199 ctx := r.Context()
200 be := backend.FromContext(ctx)
201 logger := log.FromContext(ctx)
202
203 if !be.AllowKeyless(ctx) {
204 renderForbidden(w)
205 return
206 }
207
208 repo := pat.Param(r, "repo")
209 service := git.Service(pat.Param(r, "service"))
210 accessLevel := be.AccessLevel(ctx, repo, "")
211
212 switch service {
213 case git.ReceivePackService:
214 if accessLevel < access.ReadWriteAccess {
215 renderUnauthorized(w)
216 return
217 }
218
219 // Create the repo if it doesn't exist.
220 if _, err := be.Repository(ctx, repo); err != nil {
221 if _, err := be.CreateRepository(ctx, repo, proto.RepositoryOptions{}); err != nil {
222 logger.Error("failed to create repository", "repo", repo, "err", err)
223 renderInternalServerError(w)
224 return
225 }
226 }
227 default:
228 if accessLevel < access.ReadOnlyAccess {
229 renderUnauthorized(w)
230 return
231 }
232 }
233
234 fn(w, r)
235 }
236}
237
238//nolint:revive
239func serviceRpc(w http.ResponseWriter, r *http.Request) {
240 ctx := r.Context()
241 cfg := config.FromContext(ctx)
242 logger := log.FromContext(ctx)
243 service, dir, repo := git.Service(pat.Param(r, "service")), pat.Param(r, "dir"), pat.Param(r, "repo")
244
245 if !isSmart(r, service) {
246 renderForbidden(w)
247 return
248 }
249
250 if service == git.ReceivePackService {
251 gitHttpReceiveCounter.WithLabelValues(repo)
252 }
253
254 w.Header().Set("Content-Type", fmt.Sprintf("application/x-%s-result", service))
255 w.Header().Set("Connection", "Keep-Alive")
256 w.Header().Set("Transfer-Encoding", "chunked")
257 w.Header().Set("X-Content-Type-Options", "nosniff")
258 w.WriteHeader(http.StatusOK)
259
260 version := r.Header.Get("Git-Protocol")
261
262 var stdout bytes.Buffer
263 cmd := git.ServiceCommand{
264 Stdout: &stdout,
265 Dir: dir,
266 Args: []string{"--stateless-rpc"},
267 }
268
269 if len(version) != 0 {
270 cmd.Env = append(cmd.Env, []string{
271 // TODO: add the rest of env vars when we support pushing using http
272 "SOFT_SERVE_LOG_PATH=" + filepath.Join(cfg.DataPath, "log", "hooks.log"),
273 fmt.Sprintf("GIT_PROTOCOL=%s", version),
274 }...)
275 }
276
277 // Handle gzip encoding
278 reader := r.Body
279 defer reader.Close() // nolint: errcheck
280 switch r.Header.Get("Content-Encoding") {
281 case "gzip":
282 reader, err := gzip.NewReader(reader)
283 if err != nil {
284 logger.Errorf("failed to create gzip reader: %v", err)
285 renderInternalServerError(w)
286 return
287 }
288 defer reader.Close() // nolint: errcheck
289 }
290
291 cmd.Stdin = reader
292
293 if err := service.Handler(ctx, cmd); err != nil {
294 if errors.Is(err, git.ErrInvalidRepo) {
295 renderNotFound(w)
296 return
297 }
298 renderInternalServerError(w)
299 return
300 }
301
302 // Handle buffered output
303 // Useful when using proxies
304
305 // We know that `w` is an `http.ResponseWriter`.
306 flusher, ok := w.(http.Flusher)
307 if !ok {
308 logger.Errorf("expected http.ResponseWriter to be an http.Flusher, got %T", w)
309 return
310 }
311
312 p := make([]byte, 1024)
313 for {
314 nRead, err := stdout.Read(p)
315 if err == io.EOF {
316 break
317 }
318 nWrite, err := w.Write(p[:nRead])
319 if err != nil {
320 logger.Errorf("failed to write data: %v", err)
321 return
322 }
323 if nRead != nWrite {
324 logger.Errorf("failed to write data: %d read, %d written", nRead, nWrite)
325 return
326 }
327 flusher.Flush()
328 }
329}
330
331func getInfoRefs(w http.ResponseWriter, r *http.Request) {
332 ctx := r.Context()
333 dir, repo, file := pat.Param(r, "dir"), pat.Param(r, "repo"), pat.Param(r, "file")
334 service := getServiceType(r)
335 version := r.Header.Get("Git-Protocol")
336
337 gitHttpUploadCounter.WithLabelValues(repo, file).Inc()
338
339 if service != "" && (service == git.UploadPackService || service == git.ReceivePackService) {
340 // Smart HTTP
341 var refs bytes.Buffer
342 cmd := git.ServiceCommand{
343 Stdout: &refs,
344 Dir: dir,
345 Args: []string{"--stateless-rpc", "--advertise-refs"},
346 }
347
348 if len(version) != 0 {
349 cmd.Env = append(cmd.Env, fmt.Sprintf("GIT_PROTOCOL=%s", version))
350 }
351
352 if err := service.Handler(ctx, cmd); err != nil {
353 renderNotFound(w)
354 return
355 }
356
357 hdrNocache(w)
358 w.Header().Set("Content-Type", fmt.Sprintf("application/x-%s-advertisement", service))
359 w.WriteHeader(http.StatusOK)
360 if len(version) == 0 {
361 git.WritePktline(w, "# service="+service.String()) // nolint: errcheck
362 }
363
364 w.Write(refs.Bytes()) // nolint: errcheck
365 } else {
366 // Dumb HTTP
367 updateServerInfo(ctx, dir) // nolint: errcheck
368 hdrNocache(w)
369 sendFile("text/plain; charset=utf-8", w, r)
370 }
371}
372
373func getInfoPacks(w http.ResponseWriter, r *http.Request) {
374 hdrCacheForever(w)
375 sendFile("text/plain; charset=utf-8", w, r)
376}
377
378func getLooseObject(w http.ResponseWriter, r *http.Request) {
379 hdrCacheForever(w)
380 sendFile("application/x-git-loose-object", w, r)
381}
382
383func getPackFile(w http.ResponseWriter, r *http.Request) {
384 hdrCacheForever(w)
385 sendFile("application/x-git-packed-objects", w, r)
386}
387
388func getIdxFile(w http.ResponseWriter, r *http.Request) {
389 hdrCacheForever(w)
390 sendFile("application/x-git-packed-objects-toc", w, r)
391}
392
393func getTextFile(w http.ResponseWriter, r *http.Request) {
394 hdrNocache(w)
395 sendFile("text/plain", w, r)
396}
397
398func sendFile(contentType string, w http.ResponseWriter, r *http.Request) {
399 dir, file := pat.Param(r, "dir"), pat.Param(r, "file")
400 reqFile := filepath.Join(dir, file)
401
402 f, err := os.Stat(reqFile)
403 if os.IsNotExist(err) {
404 renderNotFound(w)
405 return
406 }
407
408 w.Header().Set("Content-Type", contentType)
409 w.Header().Set("Content-Length", fmt.Sprintf("%d", f.Size()))
410 w.Header().Set("Last-Modified", f.ModTime().Format(http.TimeFormat))
411 http.ServeFile(w, r, reqFile)
412}
413
414func getServiceType(r *http.Request) git.Service {
415 service := r.FormValue("service")
416 if !strings.HasPrefix(service, "git-") {
417 return ""
418 }
419
420 return git.Service(service)
421}
422
423func isSmart(r *http.Request, service git.Service) bool {
424 return r.Header.Get("Content-Type") == fmt.Sprintf("application/x-%s-request", service)
425}
426
427func updateServerInfo(ctx context.Context, dir string) error {
428 return gitb.UpdateServerInfo(ctx, dir)
429}
430
431// HTTP error response handling functions
432
433func renderBadRequest(w http.ResponseWriter) {
434 renderStatus(http.StatusBadRequest)(w, nil)
435}
436
437func renderMethodNotAllowed(w http.ResponseWriter, r *http.Request) {
438 if r.Proto == "HTTP/1.1" {
439 renderStatus(http.StatusMethodNotAllowed)(w, r)
440 } else {
441 renderBadRequest(w)
442 }
443}
444
445func renderNotFound(w http.ResponseWriter) {
446 renderStatus(http.StatusNotFound)(w, nil)
447}
448
449func renderUnauthorized(w http.ResponseWriter) {
450 renderStatus(http.StatusUnauthorized)(w, nil)
451}
452
453func renderForbidden(w http.ResponseWriter) {
454 renderStatus(http.StatusForbidden)(w, nil)
455}
456
457func renderInternalServerError(w http.ResponseWriter) {
458 renderStatus(http.StatusInternalServerError)(w, nil)
459}
460
461// Header writing functions
462
463func hdrNocache(w http.ResponseWriter) {
464 w.Header().Set("Expires", "Fri, 01 Jan 1980 00:00:00 GMT")
465 w.Header().Set("Pragma", "no-cache")
466 w.Header().Set("Cache-Control", "no-cache, max-age=0, must-revalidate")
467}
468
469func hdrCacheForever(w http.ResponseWriter) {
470 now := time.Now().Unix()
471 expires := now + 31536000
472 w.Header().Set("Date", fmt.Sprintf("%d", now))
473 w.Header().Set("Expires", fmt.Sprintf("%d", expires))
474 w.Header().Set("Cache-Control", "public, max-age=31536000")
475}