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 "strconv"
14 "strings"
15 "time"
16
17 log "github.com/charmbracelet/log/v2"
18 gitb "github.com/charmbracelet/soft-serve/git"
19 "github.com/charmbracelet/soft-serve/pkg/access"
20 "github.com/charmbracelet/soft-serve/pkg/backend"
21 "github.com/charmbracelet/soft-serve/pkg/config"
22 "github.com/charmbracelet/soft-serve/pkg/git"
23 "github.com/charmbracelet/soft-serve/pkg/lfs"
24 "github.com/charmbracelet/soft-serve/pkg/proto"
25 "github.com/charmbracelet/soft-serve/pkg/utils"
26 "github.com/gorilla/mux"
27 "github.com/prometheus/client_golang/prometheus"
28 "github.com/prometheus/client_golang/prometheus/promauto"
29)
30
31// GitRoute is a route for git services.
32type GitRoute struct {
33 method []string
34 handler http.HandlerFunc
35 path string
36}
37
38var _ http.Handler = GitRoute{}
39
40// ServeHTTP implements http.Handler.
41func (g GitRoute) ServeHTTP(w http.ResponseWriter, r *http.Request) {
42 var hasMethod bool
43 for _, m := range g.method {
44 if m == r.Method {
45 hasMethod = true
46 break
47 }
48 }
49
50 if !hasMethod {
51 renderMethodNotAllowed(w, r)
52 return
53 }
54
55 g.handler(w, r)
56}
57
58var (
59 //nolint:revive
60 gitHttpReceiveCounter = promauto.NewCounterVec(prometheus.CounterOpts{
61 Namespace: "soft_serve",
62 Subsystem: "http",
63 Name: "git_receive_pack_total",
64 Help: "The total number of git push requests",
65 }, []string{"repo"})
66
67 //nolint:revive
68 gitHttpUploadCounter = promauto.NewCounterVec(prometheus.CounterOpts{
69 Namespace: "soft_serve",
70 Subsystem: "http",
71 Name: "git_upload_pack_total",
72 Help: "The total number of git fetch/pull requests",
73 }, []string{"repo", "file"})
74)
75
76func withParams(next http.Handler) http.Handler {
77 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
78 ctx := r.Context()
79 cfg := config.FromContext(ctx)
80 vars := mux.Vars(r)
81 repo := vars["repo"]
82
83 // Construct "file" param from path
84 vars["file"] = strings.TrimPrefix(r.URL.Path, "/"+repo+"/")
85
86 // Set service type
87 switch {
88 case strings.HasSuffix(r.URL.Path, git.UploadPackService.String()):
89 vars["service"] = git.UploadPackService.String()
90 case strings.HasSuffix(r.URL.Path, git.ReceivePackService.String()):
91 vars["service"] = git.ReceivePackService.String()
92 }
93
94 repo = utils.SanitizeRepo(repo)
95 vars["repo"] = repo
96 vars["dir"] = filepath.Join(cfg.DataPath, "repos", repo+".git")
97
98 // Add repo suffix (.git)
99 r.URL.Path = fmt.Sprintf("%s.git/%s", repo, vars["file"])
100 r = mux.SetURLVars(r, vars)
101
102 next.ServeHTTP(w, r)
103 })
104}
105
106// GitController is a router for git services.
107func GitController(_ context.Context, r *mux.Router) {
108 basePrefix := "/{repo:.*}"
109 for _, route := range gitRoutes {
110 // NOTE: withParam must always be the outermost wrapper, otherwise the
111 // request vars will not be set.
112 r.Handle(basePrefix+route.path, withParams(withAccess(route)))
113 }
114
115 // Handle go-get
116 r.Handle(basePrefix, withParams(withAccess(http.HandlerFunc(GoGetHandler)))).Methods(http.MethodGet)
117}
118
119var gitRoutes = []GitRoute{
120 // Git services
121 // These routes don't handle authentication/authorization.
122 // This is handled through wrapping the handlers for each route.
123 // See below (withAccess).
124 {
125 method: []string{http.MethodPost},
126 handler: serviceRpc,
127 path: "/{service:(?:git-upload-archive|git-upload-pack|git-receive-pack)$}",
128 },
129 {
130 method: []string{http.MethodGet},
131 handler: getInfoRefs,
132 path: "/info/refs",
133 },
134 {
135 method: []string{http.MethodGet},
136 handler: getTextFile,
137 path: "/{_:(?:HEAD|objects/info/alternates|objects/info/http-alternates|objects/info/[^/]*)$}",
138 },
139 {
140 method: []string{http.MethodGet},
141 handler: getInfoPacks,
142 path: "/objects/info/packs",
143 },
144 {
145 method: []string{http.MethodGet},
146 handler: getLooseObject,
147 path: "/objects/{_:[0-9a-f]{2}/[0-9a-f]{38}$}",
148 },
149 {
150 method: []string{http.MethodGet},
151 handler: getPackFile,
152 path: "/objects/pack/{_:pack-[0-9a-f]{40}\\.pack$}",
153 },
154 {
155 method: []string{http.MethodGet},
156 handler: getIdxFile,
157 path: "/objects/pack/{_:pack-[0-9a-f]{40}\\.idx$}",
158 },
159 // Git LFS
160 {
161 method: []string{http.MethodPost},
162 handler: serviceLfsBatch,
163 path: "/info/lfs/objects/batch",
164 },
165 {
166 // Git LFS basic object handler
167 method: []string{http.MethodGet, http.MethodPut},
168 handler: serviceLfsBasic,
169 path: "/info/lfs/objects/basic/{oid:[0-9a-f]{64}$}",
170 },
171 {
172 method: []string{http.MethodPost},
173 handler: serviceLfsBasicVerify,
174 path: "/info/lfs/objects/basic/verify",
175 },
176 // Git LFS locks
177 {
178 method: []string{http.MethodPost, http.MethodGet},
179 handler: serviceLfsLocks,
180 path: "/info/lfs/locks",
181 },
182 {
183 method: []string{http.MethodPost},
184 handler: serviceLfsLocksVerify,
185 path: "/info/lfs/locks/verify",
186 },
187 {
188 method: []string{http.MethodPost},
189 handler: serviceLfsLocksDelete,
190 path: "/info/lfs/locks/{lock_id:[0-9]+}/unlock",
191 },
192}
193
194func askCredentials(w http.ResponseWriter, _ *http.Request) {
195 w.Header().Set("WWW-Authenticate", `Basic realm="Git" charset="UTF-8", Token, Bearer`)
196 w.Header().Set("LFS-Authenticate", `Basic realm="Git LFS" charset="UTF-8", Token, Bearer`)
197}
198
199// withAccess handles auth.
200func withAccess(next http.Handler) http.HandlerFunc {
201 return func(w http.ResponseWriter, r *http.Request) {
202 ctx := r.Context()
203 cfg := config.FromContext(ctx)
204 logger := log.FromContext(ctx)
205 be := backend.FromContext(ctx)
206
207 // Store repository in context
208 // We're not checking for errors here because we want to allow
209 // repo creation on the fly.
210 repoName := mux.Vars(r)["repo"]
211 repo, _ := be.Repository(ctx, repoName)
212 ctx = proto.WithRepositoryContext(ctx, repo)
213 r = r.WithContext(ctx)
214
215 user, err := authenticate(r)
216 if err != nil {
217 switch {
218 case errors.Is(err, ErrInvalidToken):
219 case errors.Is(err, proto.ErrUserNotFound):
220 default:
221 logger.Error("failed to authenticate", "err", err)
222 }
223 }
224
225 if user == nil && !be.AllowKeyless(ctx) {
226 askCredentials(w, r)
227 renderUnauthorized(w, r)
228 return
229 }
230
231 // Store user in context
232 ctx = proto.WithUserContext(ctx, user)
233 r = r.WithContext(ctx)
234
235 if user != nil {
236 logger.Debug("authenticated", "username", user.Username())
237 }
238
239 service := git.Service(mux.Vars(r)["service"])
240 if service == "" {
241 // Get service from request params
242 service = getServiceType(r)
243 }
244
245 accessLevel := be.AccessLevelForUser(ctx, repoName, user)
246 ctx = access.WithContext(ctx, accessLevel)
247 r = r.WithContext(ctx)
248
249 file := mux.Vars(r)["file"]
250
251 // We only allow these services to proceed any other services should return 403
252 // - git-upload-pack
253 // - git-receive-pack
254 // - git-lfs
255 switch {
256 case service == git.ReceivePackService:
257 if accessLevel < access.ReadWriteAccess {
258 askCredentials(w, r)
259 renderUnauthorized(w, r)
260 return
261 }
262
263 // Create the repo if it doesn't exist.
264 if repo == nil {
265 repo, err = be.CreateRepository(ctx, repoName, user, proto.RepositoryOptions{})
266 if err != nil {
267 logger.Error("failed to create repository", "repo", repoName, "err", err)
268 renderInternalServerError(w, r)
269 return
270 }
271
272 ctx = proto.WithRepositoryContext(ctx, repo)
273 r = r.WithContext(ctx)
274 }
275
276 fallthrough
277 case service == git.UploadPackService || service == git.UploadArchiveService:
278 if repo == nil {
279 // If the repo doesn't exist, return 404
280 renderNotFound(w, r)
281 return
282 } else if errors.Is(err, ErrInvalidToken) || errors.Is(err, ErrInvalidPassword) {
283 // return 403 when bad credentials are provided
284 renderForbidden(w, r)
285 return
286 } else if accessLevel < access.ReadOnlyAccess {
287 askCredentials(w, r)
288 renderUnauthorized(w, r)
289 return
290 }
291
292 case strings.HasPrefix(file, "info/lfs"):
293 if !cfg.LFS.Enabled {
294 logger.Debug("LFS is not enabled, skipping")
295 renderNotFound(w, r)
296 return
297 }
298
299 switch {
300 case strings.HasPrefix(file, "info/lfs/locks"):
301 switch {
302 case strings.HasSuffix(file, "lfs/locks"), strings.HasSuffix(file, "/unlock") && r.Method == http.MethodPost:
303 // Create lock, list locks, and delete lock require write access
304 fallthrough
305 case strings.HasSuffix(file, "lfs/locks/verify"):
306 // Locks verify requires write access
307 // https://github.com/git-lfs/git-lfs/blob/main/docs/api/locking.md#unauthorized-response-2
308 if accessLevel < access.ReadWriteAccess {
309 renderJSON(w, http.StatusForbidden, lfs.ErrorResponse{
310 Message: "write access required",
311 })
312 return
313 }
314 }
315 case strings.HasPrefix(file, "info/lfs/objects/basic"):
316 switch r.Method {
317 case http.MethodPut:
318 // Basic upload
319 if accessLevel < access.ReadWriteAccess {
320 renderJSON(w, http.StatusForbidden, lfs.ErrorResponse{
321 Message: "write access required",
322 })
323 return
324 }
325 case http.MethodGet:
326 // Basic download
327 case http.MethodPost:
328 // Basic verify
329 }
330 }
331
332 if accessLevel < access.ReadOnlyAccess {
333 if repo == nil {
334 renderJSON(w, http.StatusNotFound, lfs.ErrorResponse{
335 Message: "repository not found",
336 })
337 } else if errors.Is(err, ErrInvalidToken) || errors.Is(err, ErrInvalidPassword) {
338 renderJSON(w, http.StatusForbidden, lfs.ErrorResponse{
339 Message: "bad credentials",
340 })
341 } else {
342 askCredentials(w, r)
343 renderJSON(w, http.StatusUnauthorized, lfs.ErrorResponse{
344 Message: "credentials needed",
345 })
346 }
347 return
348 }
349 }
350
351 switch {
352 case r.URL.Query().Get("go-get") == "1" && accessLevel >= access.ReadOnlyAccess:
353 // Allow go-get requests to passthrough.
354 break
355 case errors.Is(err, ErrInvalidToken), errors.Is(err, ErrInvalidPassword):
356 // return 403 when bad credentials are provided
357 renderForbidden(w, r)
358 return
359 case repo == nil, accessLevel < access.ReadOnlyAccess:
360 // Don't hint that the repo exists if the user doesn't have access
361 renderNotFound(w, r)
362 return
363 }
364
365 next.ServeHTTP(w, r)
366 }
367}
368
369//nolint:revive
370func serviceRpc(w http.ResponseWriter, r *http.Request) {
371 ctx := r.Context()
372 cfg := config.FromContext(ctx)
373 logger := log.FromContext(ctx)
374 service, dir, repoName := git.Service(mux.Vars(r)["service"]), mux.Vars(r)["dir"], mux.Vars(r)["repo"]
375
376 if !isSmart(r, service) {
377 renderForbidden(w, r)
378 return
379 }
380
381 if service == git.ReceivePackService {
382 gitHttpReceiveCounter.WithLabelValues(repoName)
383 }
384
385 w.Header().Set("Content-Type", fmt.Sprintf("application/x-%s-result", service))
386 w.Header().Set("Connection", "Keep-Alive")
387 w.Header().Set("Transfer-Encoding", "chunked")
388 w.Header().Set("X-Content-Type-Options", "nosniff")
389 w.WriteHeader(http.StatusOK)
390
391 version := r.Header.Get("Git-Protocol")
392
393 var stdout bytes.Buffer
394 cmd := git.ServiceCommand{
395 Stdout: &stdout,
396 Dir: dir,
397 }
398
399 switch service {
400 case git.UploadPackService, git.ReceivePackService:
401 cmd.Args = append(cmd.Args, "--stateless-rpc")
402 case git.UploadArchiveService, git.LFSTransferService:
403 // No additional arguments needed for these services
404 }
405
406 user := proto.UserFromContext(ctx)
407 cmd.Env = cfg.Environ()
408 cmd.Env = append(cmd.Env, []string{
409 "SOFT_SERVE_REPO_NAME=" + repoName,
410 "SOFT_SERVE_REPO_PATH=" + dir,
411 "SOFT_SERVE_LOG_PATH=" + filepath.Join(cfg.DataPath, "log", "hooks.log"),
412 }...)
413 if user != nil {
414 cmd.Env = append(cmd.Env, []string{
415 "SOFT_SERVE_USERNAME=" + user.Username(),
416 }...)
417 }
418 if len(version) != 0 {
419 cmd.Env = append(cmd.Env, []string{
420 fmt.Sprintf("GIT_PROTOCOL=%s", version),
421 }...)
422 }
423
424 var (
425 err error
426 reader io.ReadCloser
427 )
428
429 // Handle gzip encoding
430 reader = r.Body
431 switch r.Header.Get("Content-Encoding") {
432 case "gzip":
433 reader, err = gzip.NewReader(reader)
434 if err != nil {
435 logger.Errorf("failed to create gzip reader: %v", err)
436 renderInternalServerError(w, r)
437 return
438 }
439 defer reader.Close() //nolint: errcheck
440 }
441
442 cmd.Stdin = reader
443 cmd.Stdout = &flushResponseWriter{w}
444
445 if err := service.Handler(ctx, cmd); err != nil {
446 logger.Errorf("failed to handle service: %v", err)
447 return
448 }
449
450 if service == git.ReceivePackService {
451 if err := git.EnsureDefaultBranch(ctx, cmd.Dir); err != nil {
452 logger.Errorf("failed to ensure default branch: %s", err)
453 }
454 }
455}
456
457// Handle buffered output
458// Useful when using proxies
459type flushResponseWriter struct {
460 http.ResponseWriter
461}
462
463func (f *flushResponseWriter) ReadFrom(r io.Reader) (int64, error) {
464 flusher := http.NewResponseController(f.ResponseWriter)
465
466 var n int64
467 p := make([]byte, 1024)
468 for {
469 nRead, err := r.Read(p)
470 if err == io.EOF {
471 break
472 }
473 nWrite, err := f.ResponseWriter.Write(p[:nRead])
474 if err != nil {
475 return n, err
476 }
477 if nRead != nWrite {
478 return n, err
479 }
480 n += int64(nRead)
481 // ResponseWriter must support http.Flusher to handle buffered output.
482 if err := flusher.Flush(); err != nil {
483 return n, fmt.Errorf("%w: error while flush", err)
484 }
485 }
486
487 return n, nil
488}
489
490func getInfoRefs(w http.ResponseWriter, r *http.Request) {
491 ctx := r.Context()
492 cfg := config.FromContext(ctx)
493 dir, repoName, file := mux.Vars(r)["dir"], mux.Vars(r)["repo"], mux.Vars(r)["file"]
494 service := getServiceType(r)
495 protocol := r.Header.Get("Git-Protocol")
496
497 gitHttpUploadCounter.WithLabelValues(repoName, file).Inc()
498
499 if service != "" && (service == git.UploadPackService || service == git.ReceivePackService) {
500 // Smart HTTP
501 var refs bytes.Buffer
502 cmd := git.ServiceCommand{
503 Stdout: &refs,
504 Dir: dir,
505 Args: []string{"--stateless-rpc", "--advertise-refs"},
506 }
507
508 user := proto.UserFromContext(ctx)
509 cmd.Env = cfg.Environ()
510 cmd.Env = append(cmd.Env, []string{
511 "SOFT_SERVE_REPO_NAME=" + repoName,
512 "SOFT_SERVE_REPO_PATH=" + dir,
513 "SOFT_SERVE_LOG_PATH=" + filepath.Join(cfg.DataPath, "log", "hooks.log"),
514 }...)
515 if user != nil {
516 cmd.Env = append(cmd.Env, []string{
517 "SOFT_SERVE_USERNAME=" + user.Username(),
518 }...)
519 }
520 if len(protocol) != 0 {
521 cmd.Env = append(cmd.Env, fmt.Sprintf("GIT_PROTOCOL=%s", protocol))
522 }
523
524 var version int
525 for _, p := range strings.Split(protocol, ":") {
526 if strings.HasPrefix(p, "version=") {
527 if v, _ := strconv.Atoi(p[8:]); v > version {
528 version = v
529 }
530 }
531 }
532
533 if err := service.Handler(ctx, cmd); err != nil {
534 renderNotFound(w, r)
535 return
536 }
537
538 hdrNocache(w)
539 w.Header().Set("Content-Type", fmt.Sprintf("application/x-%s-advertisement", service))
540 w.WriteHeader(http.StatusOK)
541 if version < 2 {
542 git.WritePktline(w, "# service="+service.String()) //nolint: errcheck
543 }
544 w.Write(refs.Bytes()) //nolint: errcheck
545 } else {
546 // Dumb HTTP
547 updateServerInfo(ctx, dir) //nolint: errcheck
548 hdrNocache(w)
549 sendFile("text/plain; charset=utf-8", w, r)
550 }
551}
552
553func getInfoPacks(w http.ResponseWriter, r *http.Request) {
554 hdrCacheForever(w)
555 sendFile("text/plain; charset=utf-8", w, r)
556}
557
558func getLooseObject(w http.ResponseWriter, r *http.Request) {
559 hdrCacheForever(w)
560 sendFile("application/x-git-loose-object", w, r)
561}
562
563func getPackFile(w http.ResponseWriter, r *http.Request) {
564 hdrCacheForever(w)
565 sendFile("application/x-git-packed-objects", w, r)
566}
567
568func getIdxFile(w http.ResponseWriter, r *http.Request) {
569 hdrCacheForever(w)
570 sendFile("application/x-git-packed-objects-toc", w, r)
571}
572
573func getTextFile(w http.ResponseWriter, r *http.Request) {
574 hdrNocache(w)
575 sendFile("text/plain", w, r)
576}
577
578func sendFile(contentType string, w http.ResponseWriter, r *http.Request) {
579 dir, file := mux.Vars(r)["dir"], mux.Vars(r)["file"]
580 reqFile := filepath.Join(dir, file)
581
582 f, err := os.Stat(reqFile)
583 if os.IsNotExist(err) {
584 renderNotFound(w, r)
585 return
586 }
587
588 w.Header().Set("Content-Type", contentType)
589 w.Header().Set("Content-Length", fmt.Sprintf("%d", f.Size()))
590 w.Header().Set("Last-Modified", f.ModTime().Format(http.TimeFormat))
591 http.ServeFile(w, r, reqFile)
592}
593
594func getServiceType(r *http.Request) git.Service {
595 service := r.FormValue("service")
596 if !strings.HasPrefix(service, "git-") {
597 return ""
598 }
599
600 return git.Service(service)
601}
602
603func isSmart(r *http.Request, service git.Service) bool {
604 contentType := r.Header.Get("Content-Type")
605 return strings.HasPrefix(contentType, fmt.Sprintf("application/x-%s-request", service))
606}
607
608func updateServerInfo(ctx context.Context, dir string) error {
609 return gitb.UpdateServerInfo(ctx, dir)
610}
611
612// HTTP error response handling functions
613
614func renderBadRequest(w http.ResponseWriter, r *http.Request) {
615 renderStatus(http.StatusBadRequest)(w, r)
616}
617
618func renderMethodNotAllowed(w http.ResponseWriter, r *http.Request) {
619 if r.Proto == "HTTP/1.1" {
620 renderStatus(http.StatusMethodNotAllowed)(w, r)
621 } else {
622 renderBadRequest(w, r)
623 }
624}
625
626func renderNotFound(w http.ResponseWriter, r *http.Request) {
627 renderStatus(http.StatusNotFound)(w, r)
628}
629
630func renderUnauthorized(w http.ResponseWriter, r *http.Request) {
631 renderStatus(http.StatusUnauthorized)(w, r)
632}
633
634func renderForbidden(w http.ResponseWriter, r *http.Request) {
635 renderStatus(http.StatusForbidden)(w, r)
636}
637
638func renderInternalServerError(w http.ResponseWriter, r *http.Request) {
639 renderStatus(http.StatusInternalServerError)(w, r)
640}
641
642// Header writing functions
643
644func hdrNocache(w http.ResponseWriter) {
645 w.Header().Set("Expires", "Fri, 01 Jan 1980 00:00:00 GMT")
646 w.Header().Set("Pragma", "no-cache")
647 w.Header().Set("Cache-Control", "no-cache, max-age=0, must-revalidate")
648}
649
650func hdrCacheForever(w http.ResponseWriter) {
651 now := time.Now().Unix()
652 expires := now + 31536000
653 w.Header().Set("Date", fmt.Sprintf("%d", now))
654 w.Header().Set("Expires", fmt.Sprintf("%d", expires))
655 w.Header().Set("Cache-Control", "public, max-age=31536000")
656}