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