agola/internal/git-handler/handler.go

279 lines
8.0 KiB
Go

// Copyright 2019 Sorint.lab
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied
// See the License for the specific language governing permissions and
// limitations under the License.
package githandler
import (
"bytes"
"compress/gzip"
"context"
"fmt"
"io"
"net/http"
"regexp"
"strings"
"agola.io/agola/internal/errors"
"agola.io/agola/internal/util"
"github.com/rs/zerolog"
)
var (
InfoRefsRegExp = regexp.MustCompile(`/(.+\.git)/info/refs$`)
UploadPackRegExp = regexp.MustCompile(`/(.+\.git)/git-upload-pack$`)
ReceivePackRegExp = regexp.MustCompile(`/(.+\.git)/git-receive-pack$`)
FetchFileRegExp = regexp.MustCompile(`/(.+\.git)/raw/(.+?)/(.+)`)
)
type RequestType int
const (
RequestTypeInfoRefs RequestType = iota
RequestTypeUploadPack
RequestTypeReceivePack
)
type FetchFileData struct {
RepoPath string
Ref string
Path string
}
func ParseFetchFilePath(path string) (*FetchFileData, error) {
matches := FetchFileRegExp.FindStringSubmatch(path)
if len(matches) != 4 {
return nil, errors.New("cannot get fetch file data from url")
}
return &FetchFileData{
RepoPath: matches[1],
Ref: matches[2],
Path: matches[3],
}, nil
}
func MatchPath(path string) (string, RequestType, error) {
var matchedRegExp *regexp.Regexp
var reqType RequestType
for i, regExp := range []*regexp.Regexp{InfoRefsRegExp, UploadPackRegExp, ReceivePackRegExp} {
if regExp.MatchString(path) {
matchedRegExp = regExp
reqType = RequestType(i)
}
}
if matchedRegExp == nil {
return "", 0, errors.New("wrong request path")
}
matches := matchedRegExp.FindStringSubmatch(path)
if len(matches) != 2 {
return "", 0, errors.New("cannot get repository path from url")
}
return matches[1], reqType, nil
}
func gitServiceName(r *http.Request) (string, error) {
service := r.URL.Query().Get("service")
if !strings.HasPrefix(service, "git-") {
return "", errors.Errorf("wrong git service %q", service)
}
return strings.TrimPrefix(service, "git-"), nil
}
func writePacketLine(w io.Writer, line string) {
fmt.Fprintf(w, "%.4x%s\n", len(line)+5, line)
}
func writeFlushPacket(w io.Writer) {
fmt.Fprintf(w, "0000")
}
func InfoRefsResponse(ctx context.Context, repoPath, serviceName string) ([]byte, error) {
buf := &bytes.Buffer{}
writePacketLine(buf, "# service=git-"+serviceName)
writeFlushPacket(buf)
git := &util.Git{}
out, err := git.Output(ctx, nil, serviceName, "--stateless-rpc", "--advertise-refs", repoPath)
if err != nil {
return nil, errors.WithStack(err)
}
buf.Write(out)
return buf.Bytes(), errors.WithStack(err)
}
func gitService(ctx context.Context, w io.Writer, r io.Reader, repoPath, serviceName string) error {
git := &util.Git{GitDir: repoPath}
return errors.WithStack(git.Pipe(ctx, w, r, serviceName, "--stateless-rpc", repoPath))
}
func gitFetchFile(ctx context.Context, w io.Writer, r io.Reader, repoPath, ref, path string) error {
git := &util.Git{GitDir: repoPath}
return errors.WithStack(git.Pipe(ctx, w, r, "show", fmt.Sprintf("%s:%s", ref, path)))
}
var ErrWrongRepoPath = errors.New("wrong repository path")
// RepoAbsPathFunc is a user defined functions that, given the repo path
// provided in the url request, will return the file system absolute repo path
// and if it exists.
// This function should also do path validation and return ErrWrongRepoPath if
// path validation failed.
type RepoAbsPathFunc func(reposDir, path string) (absPath string, exists bool, err error)
type RepoPostCreateFunc func(repoPath, repoAbsPath string) error
type GitSmartHandler struct {
log zerolog.Logger
reposDir string
createRepo bool
repoAbsPathFunc RepoAbsPathFunc
repoPostCreateFunc RepoPostCreateFunc
}
func NewGitSmartHandler(log zerolog.Logger, reposDir string, createRepo bool, repoAbsPathFunc RepoAbsPathFunc, repoPostCreateFunc RepoPostCreateFunc) *GitSmartHandler {
return &GitSmartHandler{
log: log,
reposDir: reposDir,
createRepo: createRepo,
repoAbsPathFunc: repoAbsPathFunc,
repoPostCreateFunc: repoPostCreateFunc,
}
}
func (h *GitSmartHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
repoPath, reqType, err := MatchPath(r.URL.Path)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
repoAbsPath, exists, err := h.repoAbsPathFunc(h.reposDir, repoPath)
if err != nil {
if errors.Is(err, ErrWrongRepoPath) {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
git := &util.Git{GitDir: repoAbsPath}
body := r.Body
if r.Header.Get("Content-Encoding") == "gzip" {
body, err = gzip.NewReader(r.Body)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
}
}
switch reqType {
case RequestTypeInfoRefs:
if h.createRepo && !exists {
if output, err := git.Output(ctx, nil, "init", "--bare", repoAbsPath); err != nil {
h.log.Err(err).Msgf("git error, output: %s", output)
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if h.repoPostCreateFunc != nil {
if err := h.repoPostCreateFunc(repoPath, repoAbsPath); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
}
}
serviceName, err := gitServiceName(r)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
res, err := InfoRefsResponse(ctx, repoAbsPath, serviceName)
if err != nil {
// we cannot return any http error since the http header has already been written
h.log.Err(err).Msgf("git command error")
return
}
w.Header().Set("Content-Type", "application/x-git-"+serviceName+"-advertisement")
_, _ = w.Write(res)
case RequestTypeUploadPack:
w.Header().Set("Content-Type", "application/x-git-upload-pack-result")
if err := gitService(ctx, w, body, repoAbsPath, "upload-pack"); err != nil {
// we cannot return any http error since the http header has already been written
h.log.Err(err).Msgf("git command error")
}
case RequestTypeReceivePack:
w.Header().Set("Content-Type", "application/x-git-receive-pack-result")
if err := gitService(ctx, w, body, repoAbsPath, "receive-pack"); err != nil {
// we cannot return any http error since the http header has already been written
h.log.Err(err).Msgf("git command error")
}
}
}
type FetchFileHandler struct {
log zerolog.Logger
reposDir string
repoAbsPathFunc RepoAbsPathFunc
}
func NewFetchFileHandler(log zerolog.Logger, reposDir string, repoAbsPathFunc RepoAbsPathFunc) *FetchFileHandler {
return &FetchFileHandler{
log: log,
reposDir: reposDir,
repoAbsPathFunc: repoAbsPathFunc,
}
}
func (h *FetchFileHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
fetchData, err := ParseFetchFilePath(r.URL.Path)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
repoAbsPath, _, err := h.repoAbsPathFunc(h.reposDir, fetchData.RepoPath)
if err != nil {
if errors.Is(err, ErrWrongRepoPath) {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if err := gitFetchFile(ctx, w, r.Body, repoAbsPath, fetchData.Ref, fetchData.Path); err != nil {
h.log.Err(err).Msgf("git command error")
// since we already answered with a 200 we cannot return another error code
// So abort the connection and the client will detect the missing ending chunk
// and consider this an error
//
// this is the way to force close a request without logging the panic
panic(http.ErrAbortHandler)
}
}