// Handler for raw blob downloads
//
// Blobs are read via `git cat-file ...` with first querying authentication
// backend about download-access permission for containing repository.

package git

import (
	"bufio"
	"errors"
	"fmt"
	"io"
	"log"
	"mime"
	"net/http"
	"net/url"
	"os"
	"os/exec"
	"path"
	"regexp"
	"strings"
	"syscall"

	"gitlab.com/gitlab-org/gitlab-workhorse/internal/api"
	"gitlab.com/gitlab-org/gitlab-workhorse/internal/helper"
)

// HTTP handler for `.../raw/<ref>/path`
func GetBlobRaw(a *api.API, repoPath string) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		handleGetBlobRaw(a, w, r, repoPath)
	})
}

var rawRe = regexp.MustCompile(`/raw/`)

func handleGetBlobRaw(a *api.API, w http.ResponseWriter, r *http.Request, repoPath string) {
	// Extract project & refpath
	// <project>/raw/branch/file -> <project>, branch/file
	u := r.URL
	rawLoc := rawRe.FindStringIndex(u.Path)
	if rawLoc == nil {
		helper.Fail500(w, r, errors.New("extract project name"))
		return
	}
	project := u.Path[:rawLoc[0]]
	refpath := u.Path[rawLoc[1]:]

	// Prepare userinfo
	var user *url.Userinfo
	username, password, ok := r.BasicAuth()
	if ok {
		user = url.UserPassword(username, password)
	}

	// Query download access auth for this project
	authReply := a.VerifyDownloadAccess(project, user, u.RawQuery, r.Header)
	//if authReply.Repository.RelativePath == "" {
	if authReply.RawReply.Code != http.StatusOK {
		// access denied - copy auth reply to client in full -
		// there are HTTP code and other headers / body relevant for
		// about why access was denied.
		for k, v := range authReply.RawReply.HeaderMap {
			w.Header()[k] = v
		}
		w.WriteHeader(authReply.RawReply.Code)
		// NOTE do not consume authReply.RawReply.Body with io.Copy() -
		// this way it will be read one time only and next reads will be empty.
		_, err := w.Write(authReply.RawReply.Body.Bytes())
		if err != nil {
			helper.LogError(r, fmt.Errorf("writing authReply.RawReply.Body: %v", err))
		}
		return
	}

	// Access granted - we can emit the blob
	p := repoPath + project + ".git"
	emitBlob(w, p, refpath, r)
}

// Put back gitCommand function which was removed in
var execCommand = exec.Command

// Git subprocess helpers
func gitCommand(glId string, glRepository string, name string, args ...string) *exec.Cmd {
	cmd := execCommand(name, args...)
	// Start the command in its own process group (nice for signalling)
	cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
	// Explicitly set the environment for the Git command
	cmd.Env = []string{
		fmt.Sprintf("HOME=%s", os.Getenv("HOME")),
		fmt.Sprintf("PATH=%s", os.Getenv("PATH")),
		fmt.Sprintf("LD_LIBRARY_PATH=%s", os.Getenv("LD_LIBRARY_PATH")),
		fmt.Sprintf("GL_ID=%s", glId),
		fmt.Sprintf("GL_PROTOCOL=http"),
	}

	if glRepository != "" {
		cmd.Env = append(cmd.Env, fmt.Sprintf("GL_REPOSITORY=%s", glRepository))
	}
	// If we don't do something with cmd.Stderr, Git errors will be lost
	cmd.Stderr = os.Stderr
	return cmd
}

// Emit content of blob located at <ref>/path (jointly denoted as 'refpath') to output
func emitBlob(w http.ResponseWriter, repopath string, refpath string, r *http.Request) {
	// Communicate with `git cat-file --batch` trying refs from longest
	// to shortest prefix in refpath. This way we find longest-match for
	// ref and get blob sha1 and content in the end.
	queryCmd := gitCommand("", "", "git", "--git-dir="+repopath, "cat-file", "--batch")
	queryStdin, err := queryCmd.StdinPipe()
	if err != nil {
		helper.Fail500(w, r, fmt.Errorf("git cat-file --batch; stdin: %v", err))
		return
	}
	defer queryStdin.Close()
	queryStdout, err := queryCmd.StdoutPipe()
	if err != nil {
		helper.Fail500(w, r, fmt.Errorf("git cat-file --batch; stdout: %v", err))
		return
	}
	defer queryStdout.Close()
	queryReader := bufio.NewReader(queryStdout)

	err = queryCmd.Start()
	if err != nil {
		helper.Fail500(w, r, fmt.Errorf("git cat-file --batch; start: %v", err))
		return
	}
	defer helper.CleanUpProcessGroup(queryCmd)

	// refpath components as vector
	refpathv := strings.Split(refpath, "/")

	// scan from right to left and try to change '/' -> ':' and see if it
	// creates a correct git object name. If it does - we read object
	// content which follows.
	var sha1, type_ string
	var size int64
	for i := len(refpathv); i > 0; i-- {
		ref := strings.Join(refpathv[:i], "/")
		path := strings.Join(refpathv[i:], "/")
		_, err := fmt.Fprintf(queryStdin, "%s:%s\n", ref, path)
		if err != nil {
			helper.Fail500(w, r, fmt.Errorf("git cat-file --batch; write: %v", err))
			return
		}

		reply, err := queryReader.ReadString('\n')
		if err != nil {
			helper.Fail500(w, r, fmt.Errorf("git cat-file --batch; read: %v", err))
			return
		}

		// <object> SP missing LF
		if strings.HasSuffix(reply, " missing\n") {
			continue
		}

		// <sha1> SP <type> SP <size> LF
		_, err = fmt.Sscanf(reply, "%s %s %d\n", &sha1, &type_, &size)
		if err != nil {
			helper.Fail500(w, r, fmt.Errorf("git cat-file --batch; reply parse: %v", err))
			return
		}

		if type_ != "blob" {
			log.Printf("git cat-file --batch-check; %v is not blob (is %v)", sha1, type_)
			sha1 = "" // so it will return 404
		}

		// git object found
		break
	}

	// Blob not found -> 404
	if sha1 == "" {
		http.Error(w, fmt.Sprintf("Blob for %v not found", refpath), http.StatusNotFound)
		return
	}

	// Blob found - start writing response
	w.Header().Set("Content-Transfer-Encoding", "binary")
	w.Header().Set("Content-Length", fmt.Sprintf("%d", size))
	w.Header().Set("X-Content-Type-Options", "nosniff")
	w.Header().Set("Cache-Control", "private") // Rails sets this for IE compatibility
	w.Header().Set("ETag", fmt.Sprintf(`"%s"`, sha1))

	// read a little bit buffer to sniff content type, then we'll use io.CopyN to copy
	// the rest
	var buffsize int64 = 1024
	if buffsize > size {
		buffsize = size
	}
	buffer := make([]byte, buffsize)
	_, err = io.ReadFull(queryReader, buffer)
	if err != nil {
		helper.Fail500(w, r, fmt.Errorf("io.ReadFull: %v", err))
		return
	}

	// Serve html, javascript, etc as text/plain, using utf-8 charset because it's more likely to be correct
	// than the default latin1. Keep the original content type only for images, the /raw/ of an image is
	// accessed from the /blob/ view.
	// See https://gitlab.com/gitlab-org/gitlab-foss/-/blob/77c7b561/app/helpers/blob_helper.rb#L138-144
	contentType := http.DetectContentType(buffer)
	if mime.TypeByExtension(path.Ext(refpath)) == "image/svg+xml" {
		// Serve SVG with a content securtiy policy to prevent execution of inline javascript
		// note that the detection is a bit more complex
		// https://github.com/golang/go/issues/15888#issuecomment-222457018
		w.Header().Set("Content-Type", "image/svg+xml; charset=utf-8")
		w.Header().Set("Content-Disposition", "attachment")
		w.Header().Set("Content-Security-Policy", "default-src 'none'; style-src 'unsafe-inline'; sandbox")
	} else if strings.HasPrefix(contentType, "text/") {
		w.Header().Set("Content-Type", "text/plain; charset=utf-8")
		w.Header().Set("Content-Disposition", "inline")
	} else if strings.HasPrefix(contentType, "image/") {
		w.Header().Set("Content-Type", contentType)
		w.Header().Set("Content-Disposition", "inline")
	} else {
		w.Header().Set("Content-Type", "application/octet-stream")
		w.Header().Set("Content-Disposition", "attachment")
	}
	w.WriteHeader(http.StatusOK) // Don't bother with HTTP 500 from this point on, just return
	w.Write(buffer)
	if size > buffsize {
		_, err = io.CopyN(w, queryReader, size-buffsize)
		if err != nil {
			helper.LogError(r, fmt.Errorf("io.CopyN: %v", err))
			return
		}
	}

	// close git stdin explicitly, so it can exit cleanly
	err = queryStdin.Close()
	if err != nil {
		helper.LogError(r, fmt.Errorf("queryStdin.Close: %v", err))
		return
	}

	err = queryCmd.Wait()
	if err != nil {
		helper.LogError(r, fmt.Errorf("wait: %v", err))
		return
	}
}
