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