gogs/routers/repo/http.go
Unknwon d521e716dd
refactoring: SSH and HTTP push procees is now unified
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.
2017-02-16 16:33:49 -05:00

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()
}