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