// Package httpd serves the interface and git over http, per appendix C and chapter 41.4. package httpd import ( "compress/gzip" "encoding/json" "errors" "fmt" "io" "net/http" "strings" "github.com/barerepo/server/internal/gitx" "github.com/barerepo/server/internal/hook" "github.com/barerepo/server/internal/transport" ) // gitRoute is a parsed git-over-http request. type gitRoute struct { Owner, Name string Service string // git-upload-pack or git-receive-pack Advertise bool // the GET /info/refs half // LFS marks the large file endpoints, which this server answers only to refuse. LFS bool } // parseGitPath accepts the path with and without `.git`, because both reach production. func parseGitPath(p string) (gitRoute, bool) { p = strings.TrimPrefix(p, "/") parts := strings.Split(p, "/") if len(parts) < 3 { return gitRoute{}, false } r := gitRoute{Owner: parts[0], Name: strings.TrimSuffix(parts[1], ".git")} rest := strings.Join(parts[2:], "/") switch rest { case "info/refs": r.Advertise = true case "git-upload-pack", "git-receive-pack": r.Service = rest default: // A git-lfs client asks here, and a 404 tells it the repository is missing, which is a lie. if rest == "info/lfs" || strings.HasPrefix(rest, "info/lfs/") { r.LFS = true break } return gitRoute{}, false } if !gitx.ValidName(r.Owner) || !gitx.ValidRepoName(r.Name) { return gitRoute{}, false } return r, true } // refuseLFS answers in the shape git-lfs reads, so the client prints the reason instead of a 404. func (s *Server) refuseLFS(w http.ResponseWriter) { w.Header().Set("Content-Type", "application/vnd.git-lfs+json") w.WriteHeader(http.StatusNotImplemented) msg := "large file storage is off on this server. keep binaries out of git, " + "or run your own barerepo where you decide. chapter 20." if s.Cfg.Behavior.AllowLFS { msg = "large file storage is switched on in the config and is not built yet, " + "so nothing here can serve it. chapter 20.3." } body, _ := json.Marshal(struct { Message string `json:"message"` }{msg}) w.Write(body) } // serveGit handles both halves of smart http. func (s *Server) serveGit(w http.ResponseWriter, req *http.Request, r gitRoute) { ctx := req.Context() if r.LFS { s.refuseLFS(w) return } if r.Advertise { r.Service = req.URL.Query().Get("service") if r.Service != "git-upload-pack" && r.Service != "git-receive-pack" { // Dumb http asks with no service, and barerepo does not serve it to anyone. http.Error(w, "this server speaks the smart http protocol only", http.StatusForbidden) return } } // One push is two requests. Only the second one may create anything. intent := transport.Read switch { case r.Service == "git-receive-pack" && r.Advertise: intent = transport.Announce case r.Service == "git-receive-pack": intent = transport.Write } user, scope, err := s.authenticate(ctx, req) if err != nil { s.askForCredentials(w) return } // A scoped token is a credential for its own repository and nothing else, so anywhere else the reader is whoever an anonymous one would be. 15. if scope != "" && scope != r.Owner+"/"+r.Name { user = "" } // Ask for the credential before advertising, or the push is refused later for the wrong reason. if intent != transport.Read && user == "" { s.askForCredentials(w) return } res, err := s.Transport.Open(ctx, r.Owner, r.Name, user, intent) var moved transport.Redirect switch { case errors.As(err, &moved): // A 301, which git follows on both transports, so an existing clone keeps working. 44.2. http.Redirect(w, req, s.movedPath(req, moved), http.StatusMovedPermanently) return case errors.Is(err, transport.ErrNotFound): if user == "" { // It may be private, so ask, rather than hand back a 404 nobody can act on. s.askForCredentials(w) return } http.NotFound(w, req) return case errors.Is(err, transport.ErrDenied): http.Error(w, err.Error(), http.StatusForbidden) return case err != nil: s.oops(w, req, err) return } if r.Advertise { s.advertise(w, req, res.Dir, r.Service) return } s.pack(w, req, res, user, r.Service) } // advertise answers GET /info/refs?service=... func (s *Server) advertise(w http.ResponseWriter, req *http.Request, dir, service string) { w.Header().Set("Content-Type", "application/x-"+service+"-advertisement") noCache(w) // A pkt-line banner first, except under v2, where git writes the whole body itself. proto := gitProtocol(req) if proto == "" { if err := writePktLine(w, "# service="+service+"\n"); err != nil { return } if _, err := w.Write([]byte("0000")); err != nil { return } } verb := strings.TrimPrefix(service, "git-") err := gitx.Pipe(req.Context(), dir, nil, w, io.Discard, protoEnv(proto), verb, "--stateless-rpc", "--advertise-refs", ".") if err != nil { s.log(req, fmt.Errorf("%s advertise: %w", service, err)) } } // pack answers POST /git-upload-pack and POST /git-receive-pack. func (s *Server) pack(w http.ResponseWriter, req *http.Request, res *transport.Result, user, service string) { if ct := req.Header.Get("Content-Type"); ct != "application/x-"+service+"-request" { http.Error(w, "unexpected content type", http.StatusBadRequest) return } body := io.Reader(req.Body) if strings.Contains(req.Header.Get("Content-Encoding"), "gzip") { gz, err := gzip.NewReader(req.Body) if err != nil { http.Error(w, "malformed request body", http.StatusBadRequest) return } defer gz.Close() body = gz } w.Header().Set("Content-Type", "application/x-"+service+"-result") noCache(w) // git passes the environment through untouched, which is how pre-receive knows who is asking. env := protoEnv(gitProtocol(req)) env = append(env, hook.Env{ Account: user, Owner: res.Owner, Name: res.Name, Dir: res.Dir, Created: res.Created, URL: s.Cfg.Server.ExternalURL + "/" + res.Owner + "/" + res.Name, Config: s.Cfg.Path, }.Vars()...) verb := strings.TrimPrefix(service, "git-") // Hook output rides this same connection, which is how a rejection reaches the terminal. err := gitx.Pipe(req.Context(), res.Dir, body, w, io.Discard, env, verb, "--stateless-rpc", ".") if err != nil { s.log(req, fmt.Errorf("%s: %w", service, err)) } } // gitProtocol checks the request is one of git's two shapes, since it reaches an environment variable. func gitProtocol(req *http.Request) string { v := req.Header.Get("Git-Protocol") if v == "version=2" || v == "version=1" { return v } return "" } func protoEnv(proto string) []string { if proto == "" { return nil } return []string{"GIT_PROTOCOL=" + proto} } // writePktLine writes one git pkt-line: four hex length bytes, then the body. func writePktLine(w io.Writer, s string) error { _, err := fmt.Fprintf(w, "%04x%s", len(s)+4, s) return err } func noCache(w http.ResponseWriter) { w.Header().Set("Expires", "Fri, 01 Jan 1980 00:00:00 GMT") w.Header().Set("Pragma", "no-cache") w.Header().Set("Cache-Control", "no-cache, max-age=0, must-revalidate") }