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 // Handle gzip encoding
417 reader := r.Body
418 defer reader.Close() // nolint: errcheck
419 switch r.Header.Get("Content-Encoding") {
420 case "gzip":
421 reader, err := gzip.NewReader(reader)
422 if err != nil {
423 logger.Errorf("failed to create gzip reader: %v", err)
424 renderInternalServerError(w, r)
425 return
426 }
427 defer reader.Close() // nolint: errcheck
428 }
429
430 cmd.Stdin = reader
431
432 if err := service.Handler(ctx, cmd); err != nil {
433 if errors.Is(err, git.ErrInvalidRepo) {
434 renderNotFound(w, r)
435 return
436 }
437 renderInternalServerError(w, r)
438 return
439 }
440
441 // Handle buffered output
442 // Useful when using proxies
443
444 // We know that `w` is an `http.ResponseWriter`.
445 flusher, ok := w.(http.Flusher)
446 if !ok {
447 logger.Errorf("expected http.ResponseWriter to be an http.Flusher, got %T", w)
448 return
449 }
450
451 p := make([]byte, 1024)
452 for {
453 nRead, err := stdout.Read(p)
454 if err == io.EOF {
455 break
456 }
457 nWrite, err := w.Write(p[:nRead])
458 if err != nil {
459 logger.Errorf("failed to write data: %v", err)
460 return
461 }
462 if nRead != nWrite {
463 logger.Errorf("failed to write data: %d read, %d written", nRead, nWrite)
464 return
465 }
466 flusher.Flush()
467 }
468
469 if service == git.ReceivePackService {
470 if err := git.EnsureDefaultBranch(ctx, cmd); err != nil {
471 logger.Errorf("failed to ensure default branch: %s", err)
472 }
473 }
474}
475
476func getInfoRefs(w http.ResponseWriter, r *http.Request) {
477 ctx := r.Context()
478 cfg := config.FromContext(ctx)
479 dir, repoName, file := mux.Vars(r)["dir"], mux.Vars(r)["repo"], mux.Vars(r)["file"]
480 service := getServiceType(r)
481 version := r.Header.Get("Git-Protocol")
482
483 gitHttpUploadCounter.WithLabelValues(repoName, file).Inc()
484
485 if service != "" && (service == git.UploadPackService || service == git.ReceivePackService) {
486 // Smart HTTP
487 var refs bytes.Buffer
488 cmd := git.ServiceCommand{
489 Stdout: &refs,
490 Dir: dir,
491 Args: []string{"--stateless-rpc", "--advertise-refs"},
492 }
493
494 user := proto.UserFromContext(ctx)
495 cmd.Env = cfg.Environ()
496 cmd.Env = append(cmd.Env, []string{
497 "SOFT_SERVE_REPO_NAME=" + repoName,
498 "SOFT_SERVE_REPO_PATH=" + dir,
499 "SOFT_SERVE_LOG_PATH=" + filepath.Join(cfg.DataPath, "log", "hooks.log"),
500 }...)
501 if user != nil {
502 cmd.Env = append(cmd.Env, []string{
503 "SOFT_SERVE_USERNAME=" + user.Username(),
504 }...)
505 }
506 if len(version) != 0 {
507 cmd.Env = append(cmd.Env, fmt.Sprintf("GIT_PROTOCOL=%s", version))
508 }
509
510 if err := service.Handler(ctx, cmd); err != nil {
511 renderNotFound(w, r)
512 return
513 }
514
515 hdrNocache(w)
516 w.Header().Set("Content-Type", fmt.Sprintf("application/x-%s-advertisement", service))
517 w.WriteHeader(http.StatusOK)
518 if len(version) == 0 {
519 git.WritePktline(w, "# service="+service.String()) // nolint: errcheck
520 }
521
522 w.Write(refs.Bytes()) // nolint: errcheck
523 } else {
524 // Dumb HTTP
525 updateServerInfo(ctx, dir) // nolint: errcheck
526 hdrNocache(w)
527 sendFile("text/plain; charset=utf-8", w, r)
528 }
529}
530
531func getInfoPacks(w http.ResponseWriter, r *http.Request) {
532 hdrCacheForever(w)
533 sendFile("text/plain; charset=utf-8", w, r)
534}
535
536func getLooseObject(w http.ResponseWriter, r *http.Request) {
537 hdrCacheForever(w)
538 sendFile("application/x-git-loose-object", w, r)
539}
540
541func getPackFile(w http.ResponseWriter, r *http.Request) {
542 hdrCacheForever(w)
543 sendFile("application/x-git-packed-objects", w, r)
544}
545
546func getIdxFile(w http.ResponseWriter, r *http.Request) {
547 hdrCacheForever(w)
548 sendFile("application/x-git-packed-objects-toc", w, r)
549}
550
551func getTextFile(w http.ResponseWriter, r *http.Request) {
552 hdrNocache(w)
553 sendFile("text/plain", w, r)
554}
555
556func sendFile(contentType string, w http.ResponseWriter, r *http.Request) {
557 dir, file := mux.Vars(r)["dir"], mux.Vars(r)["file"]
558 reqFile := filepath.Join(dir, file)
559
560 f, err := os.Stat(reqFile)
561 if os.IsNotExist(err) {
562 renderNotFound(w, r)
563 return
564 }
565
566 w.Header().Set("Content-Type", contentType)
567 w.Header().Set("Content-Length", fmt.Sprintf("%d", f.Size()))
568 w.Header().Set("Last-Modified", f.ModTime().Format(http.TimeFormat))
569 http.ServeFile(w, r, reqFile)
570}
571
572func getServiceType(r *http.Request) git.Service {
573 service := r.FormValue("service")
574 if !strings.HasPrefix(service, "git-") {
575 return ""
576 }
577
578 return git.Service(service)
579}
580
581func isSmart(r *http.Request, service git.Service) bool {
582 contentType := r.Header.Get("Content-Type")
583 return strings.HasPrefix(contentType, fmt.Sprintf("application/x-%s-request", service))
584}
585
586func updateServerInfo(ctx context.Context, dir string) error {
587 return gitb.UpdateServerInfo(ctx, dir)
588}
589
590// HTTP error response handling functions
591
592func renderBadRequest(w http.ResponseWriter, r *http.Request) {
593 renderStatus(http.StatusBadRequest)(w, r)
594}
595
596func renderMethodNotAllowed(w http.ResponseWriter, r *http.Request) {
597 if r.Proto == "HTTP/1.1" {
598 renderStatus(http.StatusMethodNotAllowed)(w, r)
599 } else {
600 renderBadRequest(w, r)
601 }
602}
603
604func renderNotFound(w http.ResponseWriter, r *http.Request) {
605 renderStatus(http.StatusNotFound)(w, r)
606}
607
608func renderUnauthorized(w http.ResponseWriter, r *http.Request) {
609 renderStatus(http.StatusUnauthorized)(w, r)
610}
611
612func renderForbidden(w http.ResponseWriter, r *http.Request) {
613 renderStatus(http.StatusForbidden)(w, r)
614}
615
616func renderInternalServerError(w http.ResponseWriter, r *http.Request) {
617 renderStatus(http.StatusInternalServerError)(w, r)
618}
619
620// Header writing functions
621
622func hdrNocache(w http.ResponseWriter) {
623 w.Header().Set("Expires", "Fri, 01 Jan 1980 00:00:00 GMT")
624 w.Header().Set("Pragma", "no-cache")
625 w.Header().Set("Cache-Control", "no-cache, max-age=0, must-revalidate")
626}
627
628func hdrCacheForever(w http.ResponseWriter) {
629 now := time.Now().Unix()
630 expires := now + 31536000
631 w.Header().Set("Date", fmt.Sprintf("%d", now))
632 w.Header().Set("Expires", fmt.Sprintf("%d", expires))
633 w.Header().Set("Cache-Control", "public, max-age=31536000")
634}