package gitserver import ( "encoding/json" "fmt" "io" "net/http" "os" "os/exec" "path/filepath" "regexp" "strings" "tangled.org/core/migrator/config" "tangled.org/core/migrator/db" migratorgit "tangled.org/core/migrator/git" ) const lfsContentType = "application/vnd.git-lfs+json" var lfsOIDPattern = regexp.MustCompile(`^[a-f0-9]{64}$`) var tokenPattern = regexp.MustCompile(`^[A-Za-z0-9_-]{43}$`) type Server struct { db *db.DB cfg *config.Config } func New(database *db.DB, cfg *config.Config) *Server { return &Server{db: database, cfg: cfg} } func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { token, name, operation, ok := parsePath(r.URL.Path) if !ok || !tokenPattern.MatchString(token) { http.NotFound(w, r) return } job, err := s.db.ResolveJobCapability(r.Context(), token, name) if err != nil { http.NotFound(w, r) return } mirror := filepath.Join(s.cfg.WorkDir, fmt.Sprintf("job-%d", job.ID), "repo.git") switch { case operation == "info/refs": services := r.URL.Query()["service"] if r.Method != http.MethodGet || len(services) != 1 || services[0] != "git-upload-pack" || len(r.URL.Query()) != 1 { http.NotFound(w, r) return } w.Header().Set("Content-Type", "application/x-git-upload-pack-advertisement") w.Header().Set("Cache-Control", "no-store") w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte("001e# service=git-upload-pack\n0000")) if err := s.uploadPack(r, w, mirror, true); err != nil { return } case operation == "git-upload-pack": if r.Method != http.MethodPost || r.Header.Get("Content-Type") != "application/x-git-upload-pack-request" { http.NotFound(w, r) return } w.Header().Set("Content-Type", "application/x-git-upload-pack-result") w.Header().Set("Cache-Control", "no-store") if err := s.uploadPack(r, w, mirror, false); err != nil { return } case operation == "lfs-batch": if r.Method != http.MethodPost || !lfsJSON(r.Header.Get("Content-Type")) { http.NotFound(w, r) return } s.lfsBatch(w, r, token, name, mirror) // the download route carries the oid itself as its operation case lfsOIDPattern.MatchString(operation): if r.Method != http.MethodGet { http.NotFound(w, r) return } s.lfsObject(w, r, mirror, operation) default: http.NotFound(w, r) } } func parsePath(path string) (token, name, operation string, ok bool) { parts := strings.Split(path, "/") if len(parts) < 5 || parts[0] != "" || parts[1] != "git" { return "", "", "", false } token = parts[2] if !strings.HasSuffix(parts[3], ".git") { return "", "", "", false } name = strings.TrimSuffix(parts[3], ".git") rest := strings.Join(parts[4:], "/") switch { case rest == "info/refs" || rest == "git-upload-pack": operation = rest case rest == "info/lfs/objects/batch": operation = "lfs-batch" case strings.HasPrefix(rest, "info/lfs/objects/"): oid := strings.TrimPrefix(rest, "info/lfs/objects/") if !lfsOIDPattern.MatchString(oid) { return "", "", "", false } operation = oid default: return "", "", "", false } if name == "" || strings.ContainsAny(name, `/\\`) || strings.Contains(name, "..") { return "", "", "", false } return token, name, operation, true } func lfsJSON(contentType string) bool { return strings.HasPrefix(contentType, lfsContentType) || strings.HasPrefix(contentType, "application/json") } func lfsObjectPath(mirror, oid string) string { return filepath.Join(mirror, "lfs", "objects", oid[:2], oid[2:4], oid) } type lfsBatchRequest struct { Operation string `json:"operation"` Objects []lfsOid `json:"objects"` } type lfsOid struct { Oid string `json:"oid"` Size int64 `json:"size"` } type lfsObject struct { Oid string `json:"oid"` // knot batch parser requires size on every object Size int64 `json:"size"` Authenticated bool `json:"authenticated,omitempty"` Actions map[string]lfsAction `json:"actions,omitempty"` Error *lfsObjectError `json:"error,omitempty"` } type lfsAction struct { Href string `json:"href"` Header map[string]string `json:"header,omitempty"` } type lfsObjectError struct { Code int `json:"code"` Message string `json:"message"` } func resolveLFSObject(mirror, hrefBase string, ref lfsOid) lfsObject { if !lfsOIDPattern.MatchString(ref.Oid) { return lfsObject{Oid: ref.Oid, Error: &lfsObjectError{Code: 422, Message: "invalid oid"}} } st, err := os.Stat(lfsObjectPath(mirror, ref.Oid)) if err != nil { return lfsObject{Oid: ref.Oid, Error: &lfsObjectError{Code: 404, Message: "object not found in the import"}} } return lfsObject{ Oid: ref.Oid, Size: st.Size(), Authenticated: true, Actions: map[string]lfsAction{"download": {Href: hrefBase + ref.Oid}}, } } func (s *Server) lfsBatch(w http.ResponseWriter, r *http.Request, token, name, mirror string) { body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, 4<<20)) if err != nil { http.Error(w, "unreadable batch request", http.StatusBadRequest) return } var batch lfsBatchRequest if err := json.Unmarshal(body, &batch); err != nil || batch.Operation != "download" { http.Error(w, "only a download batch is supported", http.StatusBadRequest) return } hrefBase := strings.TrimSuffix(s.cfg.ServiceURL(), "/") + fmt.Sprintf("/git/%s/%s.git/info/lfs/objects/", token, name) objects := make([]lfsObject, len(batch.Objects)) for i, ref := range batch.Objects { objects[i] = resolveLFSObject(mirror, hrefBase, ref) } w.Header().Set("Content-Type", lfsContentType) w.Header().Set("Cache-Control", "no-store") w.WriteHeader(http.StatusOK) _ = json.NewEncoder(w).Encode(struct { Transfer string `json:"transfer,omitempty"` Objects []lfsObject `json:"objects"` }{Objects: objects}) } func (s *Server) lfsObject(w http.ResponseWriter, r *http.Request, mirror, oid string) { f, err := os.Open(lfsObjectPath(mirror, oid)) if err != nil { http.NotFound(w, r) return } defer f.Close() st, err := f.Stat() if err != nil { http.NotFound(w, r) return } w.Header().Set("Content-Type", "application/octet-stream") http.ServeContent(w, r, oid, st.ModTime(), f) } func (s *Server) uploadPack(r *http.Request, w http.ResponseWriter, mirror string, advertise bool) error { args := []string{"upload-pack", "--stateless-rpc"} if advertise { args = append(args, "--advertise-refs") } args = append(args, mirror) cmd := exec.CommandContext(r.Context(), "git", args...) cmd.Env = migratorgit.HardenedGitEnv("", "", s.cfg.WorkDir, "", "") if protocol := r.Header.Get("Git-Protocol"); protocol != "" { cmd.Env = append(cmd.Env, "GIT_PROTOCOL="+protocol) } if !advertise { cmd.Stdin = http.MaxBytesReader(w, r.Body, 64<<20) } cmd.Stdout = w return cmd.Run() }