mirror of
https://github.com/gogs/gogs.git
synced 2025-05-29 10:42:30 +00:00
We used to handle SSH and HTTP push separately which produces duplicated code, but now with post-receive hook, the process is unified to one single place and much cleaner. Thus, UpdateTask struct is removed. Narrow down the range of Git HTTP routes to reduce condufsing HTTP Basic Authentication window popup on browser. By detecting <old-commit, new-commit, ref-name> inside post-receive hook, Git HTTP doesn't need to read the whole content body anymore, which completely solve the RAM problem reported in #636.
398 lines
10 KiB
Go
398 lines
10 KiB
Go
// Copyright 2017 The Gogs Authors. All rights reserved.
|
|
// Use of this source code is governed by a MIT-style
|
|
// license that can be found in the LICENSE file.
|
|
|
|
package repo
|
|
|
|
import (
|
|
"bytes"
|
|
"compress/gzip"
|
|
"fmt"
|
|
"net/http"
|
|
"os"
|
|
"os/exec"
|
|
"path"
|
|
"regexp"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/Unknwon/com"
|
|
log "gopkg.in/clog.v1"
|
|
"gopkg.in/macaron.v1"
|
|
|
|
"github.com/gogits/gogs/models"
|
|
"github.com/gogits/gogs/modules/base"
|
|
"github.com/gogits/gogs/modules/context"
|
|
"github.com/gogits/gogs/modules/setting"
|
|
)
|
|
|
|
const (
|
|
ENV_AUTH_USER_ID = "AUTH_USER_ID"
|
|
ENV_AUTH_USER_NAME = "AUTH_USER_NAME"
|
|
ENV_REPO_OWNER_NAME = "REPO_OWNER_NAME"
|
|
ENV_REPO_OWNER_SALT_MD5 = "REPO_OWNER_SALT_MD5"
|
|
ENV_REPO_NAME = "REPO_NAME"
|
|
ENV_REPO_CUSTOM_HOOKS_PATH = "REPO_CUSTOM_HOOKS_PATH"
|
|
)
|
|
|
|
type HTTPContext struct {
|
|
*context.Context
|
|
OwnerName string
|
|
OwnerSalt string
|
|
RepoName string
|
|
AuthUser *models.User
|
|
}
|
|
|
|
func HTTPContexter() macaron.Handler {
|
|
return func(ctx *context.Context) {
|
|
ownerName := ctx.Params(":username")
|
|
repoName := strings.TrimSuffix(ctx.Params(":reponame"), ".git")
|
|
repoName = strings.TrimSuffix(repoName, ".wiki")
|
|
|
|
isPull := ctx.Query("service") == "git-upload-pack" ||
|
|
strings.HasSuffix(ctx.Req.URL.Path, "git-upload-pack") ||
|
|
ctx.Req.Method == "GET"
|
|
|
|
owner, err := models.GetUserByName(ownerName)
|
|
if err != nil {
|
|
ctx.NotFoundOrServerError("GetUserByName", models.IsErrUserNotExist, err)
|
|
return
|
|
}
|
|
|
|
repo, err := models.GetRepositoryByName(owner.ID, repoName)
|
|
if err != nil {
|
|
ctx.NotFoundOrServerError("GetRepositoryByName", models.IsErrRepoNotExist, err)
|
|
return
|
|
}
|
|
|
|
// Authentication is not required for pulling from public repositories.
|
|
if isPull && !repo.IsPrivate && !setting.Service.RequireSignInView {
|
|
ctx.Map(&HTTPContext{
|
|
Context: ctx,
|
|
})
|
|
return
|
|
}
|
|
|
|
// Handle HTTP Basic Authentication
|
|
authHead := ctx.Req.Header.Get("Authorization")
|
|
if len(authHead) == 0 {
|
|
ctx.Resp.Header().Set("WWW-Authenticate", "Basic realm=\".\"")
|
|
ctx.Error(http.StatusUnauthorized)
|
|
return
|
|
}
|
|
|
|
auths := strings.Fields(authHead)
|
|
if len(auths) != 2 || auths[0] != "Basic" {
|
|
ctx.Error(http.StatusUnauthorized)
|
|
return
|
|
}
|
|
authUsername, authPassword, err := base.BasicAuthDecode(auths[1])
|
|
if err != nil {
|
|
ctx.Error(http.StatusUnauthorized)
|
|
return
|
|
}
|
|
|
|
authUser, err := models.UserSignIn(authUsername, authPassword)
|
|
if err != nil && !models.IsErrUserNotExist(err) {
|
|
ctx.Handle(http.StatusInternalServerError, "UserSignIn: %v", err)
|
|
return
|
|
}
|
|
|
|
// If username and password combination failed, try again using username as a token.
|
|
if authUser == nil {
|
|
token, err := models.GetAccessTokenBySHA(authUsername)
|
|
if err != nil {
|
|
ctx.NotFoundOrServerError("GetAccessTokenBySHA", models.IsErrAccessTokenNotExist, err)
|
|
return
|
|
}
|
|
token.Updated = time.Now()
|
|
|
|
authUser, err = models.GetUserByID(token.UID)
|
|
if err != nil {
|
|
// Once we found token, we're supposed to find its related user,
|
|
// thus any error is unexpected.
|
|
ctx.Handle(http.StatusInternalServerError, "GetUserByID", err)
|
|
return
|
|
}
|
|
}
|
|
|
|
mode := models.ACCESS_MODE_WRITE
|
|
if isPull {
|
|
mode = models.ACCESS_MODE_READ
|
|
}
|
|
has, err := models.HasAccess(authUser, repo, mode)
|
|
if err != nil {
|
|
ctx.Handle(http.StatusInternalServerError, "HasAccess", err)
|
|
return
|
|
} else if !has {
|
|
ctx.HandleText(http.StatusForbidden, "User permission denied")
|
|
return
|
|
}
|
|
|
|
if !isPull && repo.IsMirror {
|
|
ctx.HandleText(http.StatusForbidden, "Mirror repository is read-only")
|
|
return
|
|
}
|
|
|
|
ctx.Map(&HTTPContext{
|
|
Context: ctx,
|
|
OwnerName: ownerName,
|
|
OwnerSalt: owner.Salt,
|
|
RepoName: repoName,
|
|
AuthUser: authUser,
|
|
})
|
|
}
|
|
}
|
|
|
|
type serviceHandler struct {
|
|
w http.ResponseWriter
|
|
r *http.Request
|
|
dir string
|
|
file string
|
|
|
|
authUser *models.User
|
|
ownerName string
|
|
ownerSalt string
|
|
repoName string
|
|
}
|
|
|
|
func (h *serviceHandler) setHeaderNoCache() {
|
|
h.w.Header().Set("Expires", "Fri, 01 Jan 1980 00:00:00 GMT")
|
|
h.w.Header().Set("Pragma", "no-cache")
|
|
h.w.Header().Set("Cache-Control", "no-cache, max-age=0, must-revalidate")
|
|
}
|
|
|
|
func (h *serviceHandler) setHeaderCacheForever() {
|
|
now := time.Now().Unix()
|
|
expires := now + 31536000
|
|
h.w.Header().Set("Date", fmt.Sprintf("%d", now))
|
|
h.w.Header().Set("Expires", fmt.Sprintf("%d", expires))
|
|
h.w.Header().Set("Cache-Control", "public, max-age=31536000")
|
|
}
|
|
|
|
func (h *serviceHandler) sendFile(contentType string) {
|
|
reqFile := path.Join(h.dir, h.file)
|
|
fi, err := os.Stat(reqFile)
|
|
if os.IsNotExist(err) {
|
|
h.w.WriteHeader(http.StatusNotFound)
|
|
return
|
|
}
|
|
|
|
h.w.Header().Set("Content-Type", contentType)
|
|
h.w.Header().Set("Content-Length", fmt.Sprintf("%d", fi.Size()))
|
|
h.w.Header().Set("Last-Modified", fi.ModTime().Format(http.TimeFormat))
|
|
http.ServeFile(h.w, h.r, reqFile)
|
|
}
|
|
|
|
func ComposeHookEnvs(repoPath, ownerName, ownerSalt, repoName string, authUser *models.User) []string {
|
|
envs := []string{
|
|
"SSH_ORIGINAL_COMMAND=1",
|
|
ENV_AUTH_USER_ID + "=" + com.ToStr(authUser.ID),
|
|
ENV_AUTH_USER_NAME + "=" + authUser.Name,
|
|
ENV_REPO_OWNER_NAME + "=" + ownerName,
|
|
ENV_REPO_OWNER_SALT_MD5 + "=" + base.EncodeMD5(ownerSalt),
|
|
ENV_REPO_NAME + "=" + repoName,
|
|
ENV_REPO_CUSTOM_HOOKS_PATH + "=" + path.Join(repoPath, "custom_hooks"),
|
|
}
|
|
return envs
|
|
}
|
|
|
|
func serviceRPC(h serviceHandler, service string) {
|
|
defer h.r.Body.Close()
|
|
|
|
if h.r.Header.Get("Content-Type") != fmt.Sprintf("application/x-git-%s-request", service) {
|
|
h.w.WriteHeader(http.StatusUnauthorized)
|
|
return
|
|
}
|
|
h.w.Header().Set("Content-Type", fmt.Sprintf("application/x-git-%s-result", service))
|
|
|
|
var (
|
|
reqBody = h.r.Body
|
|
err error
|
|
)
|
|
|
|
// Handle GZIP.
|
|
if h.r.Header.Get("Content-Encoding") == "gzip" {
|
|
reqBody, err = gzip.NewReader(reqBody)
|
|
if err != nil {
|
|
log.Error(2, "HTTP.Get: fail to create gzip reader: %v", err)
|
|
h.w.WriteHeader(http.StatusInternalServerError)
|
|
return
|
|
}
|
|
}
|
|
|
|
var stderr bytes.Buffer
|
|
cmd := exec.Command("git", service, "--stateless-rpc", h.dir)
|
|
if service == "receive-pack" {
|
|
cmd.Env = append(os.Environ(), ComposeHookEnvs(h.dir, h.ownerName, h.ownerSalt, h.repoName, h.authUser)...)
|
|
}
|
|
cmd.Dir = h.dir
|
|
cmd.Stdout = h.w
|
|
cmd.Stderr = &stderr
|
|
cmd.Stdin = reqBody
|
|
if err = cmd.Run(); err != nil {
|
|
log.Error(2, "HTTP.serviceRPC: fail to serve RPC '%s': %v - %s", service, err, stderr)
|
|
h.w.WriteHeader(http.StatusInternalServerError)
|
|
return
|
|
}
|
|
}
|
|
|
|
func serviceUploadPack(h serviceHandler) {
|
|
serviceRPC(h, "upload-pack")
|
|
}
|
|
|
|
func serviceReceivePack(h serviceHandler) {
|
|
serviceRPC(h, "receive-pack")
|
|
}
|
|
|
|
func getServiceType(r *http.Request) string {
|
|
serviceType := r.FormValue("service")
|
|
if !strings.HasPrefix(serviceType, "git-") {
|
|
return ""
|
|
}
|
|
return strings.TrimPrefix(serviceType, "git-")
|
|
}
|
|
|
|
// FIXME: use process module
|
|
func gitCommand(dir string, args ...string) []byte {
|
|
cmd := exec.Command("git", args...)
|
|
cmd.Dir = dir
|
|
out, err := cmd.Output()
|
|
if err != nil {
|
|
log.Error(2, fmt.Sprintf("Git: %v - %s", err, out))
|
|
}
|
|
return out
|
|
}
|
|
|
|
func updateServerInfo(dir string) []byte {
|
|
return gitCommand(dir, "update-server-info")
|
|
}
|
|
|
|
func packetWrite(str string) []byte {
|
|
s := strconv.FormatInt(int64(len(str)+4), 16)
|
|
if len(s)%4 != 0 {
|
|
s = strings.Repeat("0", 4-len(s)%4) + s
|
|
}
|
|
return []byte(s + str)
|
|
}
|
|
|
|
func getInfoRefs(h serviceHandler) {
|
|
h.setHeaderNoCache()
|
|
service := getServiceType(h.r)
|
|
if service != "upload-pack" && service != "receive-pack" {
|
|
updateServerInfo(h.dir)
|
|
h.sendFile("text/plain; charset=utf-8")
|
|
return
|
|
}
|
|
|
|
refs := gitCommand(h.dir, service, "--stateless-rpc", "--advertise-refs", ".")
|
|
h.w.Header().Set("Content-Type", fmt.Sprintf("application/x-git-%s-advertisement", service))
|
|
h.w.WriteHeader(http.StatusOK)
|
|
h.w.Write(packetWrite("# service=git-" + service + "\n"))
|
|
h.w.Write([]byte("0000"))
|
|
h.w.Write(refs)
|
|
}
|
|
|
|
func getTextFile(h serviceHandler) {
|
|
h.setHeaderNoCache()
|
|
h.sendFile("text/plain")
|
|
}
|
|
|
|
func getInfoPacks(h serviceHandler) {
|
|
h.setHeaderCacheForever()
|
|
h.sendFile("text/plain; charset=utf-8")
|
|
}
|
|
|
|
func getLooseObject(h serviceHandler) {
|
|
h.setHeaderCacheForever()
|
|
h.sendFile("application/x-git-loose-object")
|
|
}
|
|
|
|
func getPackFile(h serviceHandler) {
|
|
h.setHeaderCacheForever()
|
|
h.sendFile("application/x-git-packed-objects")
|
|
}
|
|
|
|
func getIdxFile(h serviceHandler) {
|
|
h.setHeaderCacheForever()
|
|
h.sendFile("application/x-git-packed-objects-toc")
|
|
}
|
|
|
|
var routes = []struct {
|
|
reg *regexp.Regexp
|
|
method string
|
|
handler func(serviceHandler)
|
|
}{
|
|
{regexp.MustCompile("(.*?)/git-upload-pack$"), "POST", serviceUploadPack},
|
|
{regexp.MustCompile("(.*?)/git-receive-pack$"), "POST", serviceReceivePack},
|
|
{regexp.MustCompile("(.*?)/info/refs$"), "GET", getInfoRefs},
|
|
{regexp.MustCompile("(.*?)/HEAD$"), "GET", getTextFile},
|
|
{regexp.MustCompile("(.*?)/objects/info/alternates$"), "GET", getTextFile},
|
|
{regexp.MustCompile("(.*?)/objects/info/http-alternates$"), "GET", getTextFile},
|
|
{regexp.MustCompile("(.*?)/objects/info/packs$"), "GET", getInfoPacks},
|
|
{regexp.MustCompile("(.*?)/objects/info/[^/]*$"), "GET", getTextFile},
|
|
{regexp.MustCompile("(.*?)/objects/[0-9a-f]{2}/[0-9a-f]{38}$"), "GET", getLooseObject},
|
|
{regexp.MustCompile("(.*?)/objects/pack/pack-[0-9a-f]{40}\\.pack$"), "GET", getPackFile},
|
|
{regexp.MustCompile("(.*?)/objects/pack/pack-[0-9a-f]{40}\\.idx$"), "GET", getIdxFile},
|
|
}
|
|
|
|
func getGitRepoPath(dir string) (string, error) {
|
|
if !strings.HasSuffix(dir, ".git") {
|
|
dir += ".git"
|
|
}
|
|
|
|
filename := path.Join(setting.RepoRootPath, dir)
|
|
if _, err := os.Stat(filename); os.IsNotExist(err) {
|
|
return "", err
|
|
}
|
|
|
|
return filename, nil
|
|
}
|
|
|
|
func HTTP(ctx *HTTPContext) {
|
|
for _, route := range routes {
|
|
reqPath := strings.ToLower(ctx.Req.URL.Path)
|
|
m := route.reg.FindStringSubmatch(reqPath)
|
|
if m == nil {
|
|
continue
|
|
}
|
|
|
|
// We perform check here because routes matched in cmd/web.go is wider than needed,
|
|
// but we only want to output this message only if user is really trying to access
|
|
// Git HTTP endpoints.
|
|
if setting.Repository.DisableHTTPGit {
|
|
ctx.HandleText(http.StatusForbidden, "Interacting with repositories by HTTP protocol is not disabled")
|
|
return
|
|
}
|
|
|
|
if route.method != ctx.Req.Method {
|
|
ctx.NotFound()
|
|
return
|
|
}
|
|
|
|
file := strings.TrimPrefix(reqPath, m[1]+"/")
|
|
dir, err := getGitRepoPath(m[1])
|
|
if err != nil {
|
|
log.Warn("HTTP.getGitRepoPath: %v", err)
|
|
ctx.NotFound()
|
|
return
|
|
}
|
|
|
|
route.handler(serviceHandler{
|
|
w: ctx.Resp,
|
|
r: ctx.Req.Request,
|
|
dir: dir,
|
|
file: file,
|
|
|
|
authUser: ctx.AuthUser,
|
|
ownerName: ctx.OwnerName,
|
|
ownerSalt: ctx.OwnerSalt,
|
|
repoName: ctx.RepoName,
|
|
})
|
|
return
|
|
}
|
|
|
|
ctx.NotFound()
|
|
}
|