1package web23import (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"1617 "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)3031// GitRoute is a route for git services.32type GitRoute struct {33 method []string34 handler http.HandlerFunc35 path string36}3738var _ http.Handler = GitRoute{}3940// ServeHTTP implements http.Handler.41func (g GitRoute) ServeHTTP(w http.ResponseWriter, r *http.Request) {42 var hasMethod bool43 for _, m := range g.method {44 if m == r.Method {45 hasMethod = true46 break47 }48 }4950 if !hasMethod {51 renderMethodNotAllowed(w, r)52 return53 }5455 g.handler(w, r)56}5758var (59 //nolint:revive60 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"})6667 //nolint:revive68 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)7576func 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"]8283 // Construct "file" param from path84 vars["file"] = strings.TrimPrefix(r.URL.Path, "/"+repo+"/")8586 // Set service type87 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 }9394 repo = utils.SanitizeRepo(repo)95 vars["repo"] = repo96 vars["dir"] = filepath.Join(cfg.DataPath, "repos", repo+".git")9798 // Add repo suffix (.git)99 r.URL.Path = fmt.Sprintf("%s.git/%s", repo, vars["file"])100 r = mux.SetURLVars(r, vars)101102 next.ServeHTTP(w, r)103 })104}105106// GitController is a router for git services.107func GitController(_ context.Context, r *mux.Router) {108 basePrefix := "/{repo:.*}"109 for _, route := range gitRoutes {110 // NOTE: withParam must always be the outermost wrapper, otherwise the111 // request vars will not be set.112 r.Handle(basePrefix+route.path, withParams(withAccess(route)))113 }114115 // Handle go-get116 r.Handle(basePrefix, withParams(withAccess(http.HandlerFunc(GoGetHandler)))).Methods(http.MethodGet)117}118119var gitRoutes = []GitRoute{120 // Git services121 // These routes don't handle authentication/authorization.122 // This is handled through wrapping the handlers for each route.123 // See below (withAccess).124 {125 method: []string{http.MethodPost},126 handler: serviceRpc,127 path: "/{service:(?:git-upload-archive|git-upload-pack|git-receive-pack)$}",128 },129 {130 method: []string{http.MethodGet},131 handler: getInfoRefs,132 path: "/info/refs",133 },134 {135 method: []string{http.MethodGet},136 handler: getTextFile,137 path: "/{_:(?:HEAD|objects/info/alternates|objects/info/http-alternates|objects/info/[^/]*)$}",138 },139 {140 method: []string{http.MethodGet},141 handler: getInfoPacks,142 path: "/objects/info/packs",143 },144 {145 method: []string{http.MethodGet},146 handler: getLooseObject,147 path: "/objects/{_:[0-9a-f]{2}/[0-9a-f]{38}$}",148 },149 {150 method: []string{http.MethodGet},151 handler: getPackFile,152 path: "/objects/pack/{_:pack-[0-9a-f]{40}\\.pack$}",153 },154 {155 method: []string{http.MethodGet},156 handler: getIdxFile,157 path: "/objects/pack/{_:pack-[0-9a-f]{40}\\.idx$}",158 },159 // Git LFS160 {161 method: []string{http.MethodPost},162 handler: serviceLfsBatch,163 path: "/info/lfs/objects/batch",164 },165 {166 // Git LFS basic object handler167 method: []string{http.MethodGet, http.MethodPut},168 handler: serviceLfsBasic,169 path: "/info/lfs/objects/basic/{oid:[0-9a-f]{64}$}",170 },171 {172 method: []string{http.MethodPost},173 handler: serviceLfsBasicVerify,174 path: "/info/lfs/objects/basic/verify",175 },176 // Git LFS locks177 {178 method: []string{http.MethodPost, http.MethodGet},179 handler: serviceLfsLocks,180 path: "/info/lfs/locks",181 },182 {183 method: []string{http.MethodPost},184 handler: serviceLfsLocksVerify,185 path: "/info/lfs/locks/verify",186 },187 {188 method: []string{http.MethodPost},189 handler: serviceLfsLocksDelete,190 path: "/info/lfs/locks/{lock_id:[0-9]+}/unlock",191 },192}193194func askCredentials(w http.ResponseWriter, _ *http.Request) {195 w.Header().Set("WWW-Authenticate", `Basic realm="Git" charset="UTF-8", Token, Bearer`)196 w.Header().Set("LFS-Authenticate", `Basic realm="Git LFS" charset="UTF-8", Token, Bearer`)197}198199// withAccess handles auth.200func withAccess(next http.Handler) http.HandlerFunc {201 return func(w http.ResponseWriter, r *http.Request) {202 ctx := r.Context()203 cfg := config.FromContext(ctx)204 logger := log.FromContext(ctx)205 be := backend.FromContext(ctx)206207 // Store repository in context208 // We're not checking for errors here because we want to allow209 // repo creation on the fly.210 repoName := mux.Vars(r)["repo"]211 repo, _ := be.Repository(ctx, repoName)212 ctx = proto.WithRepositoryContext(ctx, repo)213 r = r.WithContext(ctx)214215 user, err := authenticate(r)216 if err != nil {217 switch {218 case errors.Is(err, ErrInvalidToken):219 case errors.Is(err, proto.ErrUserNotFound):220 default:221 logger.Error("failed to authenticate", "err", err)222 }223 }224225 if user == nil && !be.AllowKeyless(ctx) {226 askCredentials(w, r)227 renderUnauthorized(w, r)228 return229 }230231 // Store user in context232 ctx = proto.WithUserContext(ctx, user)233 r = r.WithContext(ctx)234235 if user != nil {236 logger.Debug("authenticated", "username", user.Username())237 }238239 service := git.Service(mux.Vars(r)["service"])240 if service == "" {241 // Get service from request params242 service = getServiceType(r)243 }244245 accessLevel := be.AccessLevelForUser(ctx, repoName, user)246 ctx = access.WithContext(ctx, accessLevel)247 r = r.WithContext(ctx)248249 file := mux.Vars(r)["file"]250251 // We only allow these services to proceed any other services should return 403252 // - git-upload-pack253 // - git-receive-pack254 // - git-lfs255 switch {256 case service == git.ReceivePackService:257 if accessLevel < access.ReadWriteAccess {258 askCredentials(w, r)259 renderUnauthorized(w, r)260 return261 }262263 // Create the repo if it doesn't exist.264 if repo == nil {265 repo, err = be.CreateRepository(ctx, repoName, user, proto.RepositoryOptions{})266 if err != nil {267 logger.Error("failed to create repository", "repo", repoName, "err", err)268 renderInternalServerError(w, r)269 return270 }271272 ctx = proto.WithRepositoryContext(ctx, repo)273 r = r.WithContext(ctx)274 }275276 fallthrough277 case service == git.UploadPackService || service == git.UploadArchiveService:278 if repo == nil {279 // If the repo doesn't exist, return 404280 renderNotFound(w, r)281 return282 } else if errors.Is(err, ErrInvalidToken) || errors.Is(err, ErrInvalidPassword) {283 // return 403 when bad credentials are provided284 renderForbidden(w, r)285 return286 } else if accessLevel < access.ReadOnlyAccess {287 askCredentials(w, r)288 renderUnauthorized(w, r)289 return290 }291292 case strings.HasPrefix(file, "info/lfs"):293 if !cfg.LFS.Enabled {294 logger.Debug("LFS is not enabled, skipping")295 renderNotFound(w, r)296 return297 }298299 switch {300 case strings.HasPrefix(file, "info/lfs/locks"):301 switch {302 case strings.HasSuffix(file, "lfs/locks"), strings.HasSuffix(file, "/unlock") && r.Method == http.MethodPost:303 // Create lock, list locks, and delete lock require write access304 fallthrough305 case strings.HasSuffix(file, "lfs/locks/verify"):306 // Locks verify requires write access307 // https://github.com/git-lfs/git-lfs/blob/main/docs/api/locking.md#unauthorized-response-2308 if accessLevel < access.ReadWriteAccess {309 renderJSON(w, http.StatusForbidden, lfs.ErrorResponse{310 Message: "write access required",311 })312 return313 }314 }315 case strings.HasPrefix(file, "info/lfs/objects/basic"):316 switch r.Method {317 case http.MethodPut:318 // Basic upload319 if accessLevel < access.ReadWriteAccess {320 renderJSON(w, http.StatusForbidden, lfs.ErrorResponse{321 Message: "write access required",322 })323 return324 }325 case http.MethodGet:326 // Basic download327 case http.MethodPost:328 // Basic verify329 }330 }331332 if accessLevel < access.ReadOnlyAccess {333 if repo == nil {334 renderJSON(w, http.StatusNotFound, lfs.ErrorResponse{335 Message: "repository not found",336 })337 } else if errors.Is(err, ErrInvalidToken) || errors.Is(err, ErrInvalidPassword) {338 renderJSON(w, http.StatusForbidden, lfs.ErrorResponse{339 Message: "bad credentials",340 })341 } else {342 askCredentials(w, r)343 renderJSON(w, http.StatusUnauthorized, lfs.ErrorResponse{344 Message: "credentials needed",345 })346 }347 return348 }349 }350351 switch {352 case r.URL.Query().Get("go-get") == "1" && accessLevel >= access.ReadOnlyAccess:353 // Allow go-get requests to passthrough.354 break355 case errors.Is(err, ErrInvalidToken), errors.Is(err, ErrInvalidPassword):356 // return 403 when bad credentials are provided357 renderForbidden(w, r)358 return359 case repo == nil, accessLevel < access.ReadOnlyAccess:360 // Don't hint that the repo exists if the user doesn't have access361 renderNotFound(w, r)362 return363 }364365 next.ServeHTTP(w, r)366 }367}368369//nolint:revive370func serviceRpc(w http.ResponseWriter, r *http.Request) {371 ctx := r.Context()372 cfg := config.FromContext(ctx)373 logger := log.FromContext(ctx)374 service, dir, repoName := git.Service(mux.Vars(r)["service"]), mux.Vars(r)["dir"], mux.Vars(r)["repo"]375376 if !isSmart(r, service) {377 renderForbidden(w, r)378 return379 }380381 if service == git.ReceivePackService {382 gitHttpReceiveCounter.WithLabelValues(repoName)383 }384385 w.Header().Set("Content-Type", fmt.Sprintf("application/x-%s-result", service))386 w.Header().Set("Connection", "Keep-Alive")387 w.Header().Set("Transfer-Encoding", "chunked")388 w.Header().Set("X-Content-Type-Options", "nosniff")389 w.WriteHeader(http.StatusOK)390391 version := r.Header.Get("Git-Protocol")392393 var stdout bytes.Buffer394 cmd := git.ServiceCommand{395 Stdout: &stdout,396 Dir: dir,397 }398399 switch service {400 case git.UploadPackService, git.ReceivePackService:401 cmd.Args = append(cmd.Args, "--stateless-rpc")402 }403404 user := proto.UserFromContext(ctx)405 cmd.Env = cfg.Environ()406 cmd.Env = append(cmd.Env, []string{407 "SOFT_SERVE_REPO_NAME=" + repoName,408 "SOFT_SERVE_REPO_PATH=" + dir,409 "SOFT_SERVE_LOG_PATH=" + filepath.Join(cfg.DataPath, "log", "hooks.log"),410 }...)411 if user != nil {412 cmd.Env = append(cmd.Env, []string{413 "SOFT_SERVE_USERNAME=" + user.Username(),414 }...)415 }416 if len(version) != 0 {417 cmd.Env = append(cmd.Env, []string{418 fmt.Sprintf("GIT_PROTOCOL=%s", version),419 }...)420 }421422 var (423 err error424 reader io.ReadCloser425 )426427 // Handle gzip encoding428 reader = r.Body429 switch r.Header.Get("Content-Encoding") {430 case "gzip":431 reader, err = gzip.NewReader(reader)432 if err != nil {433 logger.Errorf("failed to create gzip reader: %v", err)434 renderInternalServerError(w, r)435 return436 }437 defer reader.Close() // nolint: errcheck438 }439440 cmd.Stdin = reader441 cmd.Stdout = &flushResponseWriter{w}442443 if err := service.Handler(ctx, cmd); err != nil {444 logger.Errorf("failed to handle service: %v", err)445 return446 }447448 if service == git.ReceivePackService {449 if err := git.EnsureDefaultBranch(ctx, cmd.Dir); err != nil {450 logger.Errorf("failed to ensure default branch: %s", err)451 }452 }453}454455// Handle buffered output456// Useful when using proxies457type flushResponseWriter struct {458 http.ResponseWriter459}460461func (f *flushResponseWriter) ReadFrom(r io.Reader) (int64, error) {462 flusher := http.NewResponseController(f.ResponseWriter) // nolint: bodyclose463464 var n int64465 p := make([]byte, 1024)466 for {467 nRead, err := r.Read(p)468 if err == io.EOF {469 break470 }471 nWrite, err := f.ResponseWriter.Write(p[:nRead])472 if err != nil {473 return n, err474 }475 if nRead != nWrite {476 return n, err477 }478 n += int64(nRead)479 // ResponseWriter must support http.Flusher to handle buffered output.480 if err := flusher.Flush(); err != nil {481 return n, fmt.Errorf("%w: error while flush", err)482 }483 }484485 return n, nil486}487488func getInfoRefs(w http.ResponseWriter, r *http.Request) {489 ctx := r.Context()490 cfg := config.FromContext(ctx)491 dir, repoName, file := mux.Vars(r)["dir"], mux.Vars(r)["repo"], mux.Vars(r)["file"]492 service := getServiceType(r)493 protocol := r.Header.Get("Git-Protocol")494495 gitHttpUploadCounter.WithLabelValues(repoName, file).Inc()496497 if service != "" && (service == git.UploadPackService || service == git.ReceivePackService) {498 // Smart HTTP499 var refs bytes.Buffer500 cmd := git.ServiceCommand{501 Stdout: &refs,502 Dir: dir,503 Args: []string{"--stateless-rpc", "--advertise-refs"},504 }505506 user := proto.UserFromContext(ctx)507 cmd.Env = cfg.Environ()508 cmd.Env = append(cmd.Env, []string{509 "SOFT_SERVE_REPO_NAME=" + repoName,510 "SOFT_SERVE_REPO_PATH=" + dir,511 "SOFT_SERVE_LOG_PATH=" + filepath.Join(cfg.DataPath, "log", "hooks.log"),512 }...)513 if user != nil {514 cmd.Env = append(cmd.Env, []string{515 "SOFT_SERVE_USERNAME=" + user.Username(),516 }...)517 }518 if len(protocol) != 0 {519 cmd.Env = append(cmd.Env, fmt.Sprintf("GIT_PROTOCOL=%s", protocol))520 }521522 var version int523 for _, p := range strings.Split(protocol, ":") {524 if strings.HasPrefix(p, "version=") {525 if v, _ := strconv.Atoi(p[8:]); v > version {526 version = v527 }528 }529 }530531 if err := service.Handler(ctx, cmd); err != nil {532 renderNotFound(w, r)533 return534 }535536 hdrNocache(w)537 w.Header().Set("Content-Type", fmt.Sprintf("application/x-%s-advertisement", service))538 w.WriteHeader(http.StatusOK)539 if version < 2 {540 git.WritePktline(w, "# service="+service.String()) // nolint: errcheck541 }542 w.Write(refs.Bytes()) // nolint: errcheck543 } else {544 // Dumb HTTP545 updateServerInfo(ctx, dir) // nolint: errcheck546 hdrNocache(w)547 sendFile("text/plain; charset=utf-8", w, r)548 }549}550551func getInfoPacks(w http.ResponseWriter, r *http.Request) {552 hdrCacheForever(w)553 sendFile("text/plain; charset=utf-8", w, r)554}555556func getLooseObject(w http.ResponseWriter, r *http.Request) {557 hdrCacheForever(w)558 sendFile("application/x-git-loose-object", w, r)559}560561func getPackFile(w http.ResponseWriter, r *http.Request) {562 hdrCacheForever(w)563 sendFile("application/x-git-packed-objects", w, r)564}565566func getIdxFile(w http.ResponseWriter, r *http.Request) {567 hdrCacheForever(w)568 sendFile("application/x-git-packed-objects-toc", w, r)569}570571func getTextFile(w http.ResponseWriter, r *http.Request) {572 hdrNocache(w)573 sendFile("text/plain", w, r)574}575576func sendFile(contentType string, w http.ResponseWriter, r *http.Request) {577 dir, file := mux.Vars(r)["dir"], mux.Vars(r)["file"]578 reqFile := filepath.Join(dir, file)579580 f, err := os.Stat(reqFile)581 if os.IsNotExist(err) {582 renderNotFound(w, r)583 return584 }585586 w.Header().Set("Content-Type", contentType)587 w.Header().Set("Content-Length", fmt.Sprintf("%d", f.Size()))588 w.Header().Set("Last-Modified", f.ModTime().Format(http.TimeFormat))589 http.ServeFile(w, r, reqFile)590}591592func getServiceType(r *http.Request) git.Service {593 service := r.FormValue("service")594 if !strings.HasPrefix(service, "git-") {595 return ""596 }597598 return git.Service(service)599}600601func isSmart(r *http.Request, service git.Service) bool {602 contentType := r.Header.Get("Content-Type")603 return strings.HasPrefix(contentType, fmt.Sprintf("application/x-%s-request", service))604}605606func updateServerInfo(ctx context.Context, dir string) error {607 return gitb.UpdateServerInfo(ctx, dir)608}609610// HTTP error response handling functions611612func renderBadRequest(w http.ResponseWriter, r *http.Request) {613 renderStatus(http.StatusBadRequest)(w, r)614}615616func renderMethodNotAllowed(w http.ResponseWriter, r *http.Request) {617 if r.Proto == "HTTP/1.1" {618 renderStatus(http.StatusMethodNotAllowed)(w, r)619 } else {620 renderBadRequest(w, r)621 }622}623624func renderNotFound(w http.ResponseWriter, r *http.Request) {625 renderStatus(http.StatusNotFound)(w, r)626}627628func renderUnauthorized(w http.ResponseWriter, r *http.Request) {629 renderStatus(http.StatusUnauthorized)(w, r)630}631632func renderForbidden(w http.ResponseWriter, r *http.Request) {633 renderStatus(http.StatusForbidden)(w, r)634}635636func renderInternalServerError(w http.ResponseWriter, r *http.Request) {637 renderStatus(http.StatusInternalServerError)(w, r)638}639640// Header writing functions641642func hdrNocache(w http.ResponseWriter) {643 w.Header().Set("Expires", "Fri, 01 Jan 1980 00:00:00 GMT")644 w.Header().Set("Pragma", "no-cache")645 w.Header().Set("Cache-Control", "no-cache, max-age=0, must-revalidate")646}647648func hdrCacheForever(w http.ResponseWriter) {649 now := time.Now().Unix()650 expires := now + 31536000651 w.Header().Set("Date", fmt.Sprintf("%d", now))652 w.Header().Set("Expires", fmt.Sprintf("%d", expires))653 w.Header().Set("Cache-Control", "public, max-age=31536000")654}