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