opengist/internal/web/git_http.go
Thomas Miceli f52310a841 Add 2 new admin actions (#191)
* Synchronize all gists previews
* Reset Git server hooks for all repositories
2024-01-04 18:06:19 +01:00

332 lines
9.8 KiB
Go

package web
import (
"bytes"
"compress/gzip"
"encoding/base64"
"errors"
"fmt"
"net/http"
"os"
"os/exec"
"path"
"regexp"
"strconv"
"strings"
"time"
"github.com/google/uuid"
"github.com/labstack/echo/v4"
"github.com/rs/zerolog/log"
"github.com/thomiceli/opengist/internal/db"
"github.com/thomiceli/opengist/internal/git"
"github.com/thomiceli/opengist/internal/memdb"
"gorm.io/gorm"
)
var routes = []struct {
gitUrl string
method string
handler func(ctx echo.Context) error
}{
{"(.*?)/git-upload-pack$", "POST", uploadPack},
{"(.*?)/git-receive-pack$", "POST", receivePack},
{"(.*?)/info/refs$", "GET", infoRefs},
{"(.*?)/HEAD$", "GET", textFile},
{"(.*?)/objects/info/alternates$", "GET", textFile},
{"(.*?)/objects/info/http-alternates$", "GET", textFile},
{"(.*?)/objects/info/packs$", "GET", infoPacks},
{"(.*?)/objects/info/[^/]*$", "GET", textFile},
{"(.*?)/objects/[0-9a-f]{2}/[0-9a-f]{38}$", "GET", looseObject},
{"(.*?)/objects/pack/pack-[0-9a-f]{40}\\.pack$", "GET", packFile},
{"(.*?)/objects/pack/pack-[0-9a-f]{40}\\.idx$", "GET", idxFile},
}
func gitHttp(ctx echo.Context) error {
for _, route := range routes {
matched, _ := regexp.MatchString(route.gitUrl, ctx.Request().URL.Path)
if ctx.Request().Method == route.method && matched {
if !strings.HasPrefix(ctx.Request().Header.Get("User-Agent"), "git/") {
continue
}
gist := getData(ctx, "gist").(*db.Gist)
isInit := strings.HasPrefix(ctx.Request().URL.Path, "/init/info/refs")
isInitReceive := strings.HasPrefix(ctx.Request().URL.Path, "/init/git-receive-pack")
isInfoRefs := strings.HasSuffix(route.gitUrl, "/info/refs$")
isPull := ctx.QueryParam("service") == "git-upload-pack" ||
strings.HasSuffix(ctx.Request().URL.Path, "git-upload-pack") ||
ctx.Request().Method == "GET" && !isInfoRefs
repositoryPath := git.RepositoryPath(gist.User.Username, gist.Uuid)
if _, err := os.Stat(repositoryPath); os.IsNotExist(err) {
if err != nil {
log.Info().Err(err).Msg("Repository directory does not exist")
return errorRes(404, "Repository directory does not exist", err)
}
}
setData(ctx, "repositoryPath", repositoryPath)
// Shows basic auth if :
// - user wants to push the gist
// - user wants to clone/pull a private gist
// - gist is not found (obfuscation)
// - admin setting to require login is set to true
if isPull && gist.Private != db.PrivateVisibility && gist.ID != 0 && !getData(ctx, "RequireLogin").(bool) {
return route.handler(ctx)
}
authHeader := ctx.Request().Header.Get("Authorization")
if authHeader == "" {
return basicAuth(ctx)
}
authFields := strings.Fields(authHeader)
if len(authFields) != 2 || authFields[0] != "Basic" {
return basicAuth(ctx)
}
authUsername, authPassword, err := basicAuthDecode(authFields[1])
if err != nil {
return basicAuth(ctx)
}
if !isInit && !isInitReceive {
if gist.ID == 0 {
return plainText(ctx, 404, "Check your credentials or make sure you have access to the Gist")
}
if ok, err := argon2id.verify(authPassword, gist.User.Password); !ok || gist.User.Username != authUsername {
if err != nil {
return errorRes(500, "Cannot verify password", err)
}
log.Warn().Msg("Invalid HTTP authentication attempt from " + ctx.RealIP())
return plainText(ctx, 404, "Check your credentials or make sure you have access to the Gist")
}
} else {
var user *db.User
if user, err = db.GetUserByUsername(authUsername); err != nil {
if !errors.Is(err, gorm.ErrRecordNotFound) {
return errorRes(500, "Cannot get user", err)
}
log.Warn().Msg("Invalid HTTP authentication attempt from " + ctx.RealIP())
return errorRes(401, "Invalid credentials", nil)
}
if ok, err := argon2id.verify(authPassword, user.Password); !ok {
if err != nil {
return errorRes(500, "Cannot check for password", err)
}
log.Warn().Msg("Invalid HTTP authentication attempt from " + ctx.RealIP())
return errorRes(401, "Invalid credentials", nil)
}
if isInit {
gist = new(db.Gist)
gist.UserID = user.ID
gist.User = *user
uuidGist, err := uuid.NewRandom()
if err != nil {
return errorRes(500, "Error creating an UUID", err)
}
gist.Uuid = strings.Replace(uuidGist.String(), "-", "", -1)
gist.Title = "gist:" + gist.Uuid
if err = gist.InitRepositoryViaInit(ctx); err != nil {
return errorRes(500, "Cannot init repository in the file system", err)
}
if err = gist.Create(); err != nil {
return errorRes(500, "Cannot init repository in database", err)
}
if err := memdb.InsertGistInit(user.ID, gist); err != nil {
return errorRes(500, "Cannot save the URL for the new Gist", err)
}
setData(ctx, "gist", gist)
} else {
gistFromMemdb, err := memdb.GetGistInitAndDelete(user.ID)
if err != nil {
return errorRes(500, "Cannot get the gist link from the in memory database", err)
}
gist := gistFromMemdb.Gist
setData(ctx, "gist", gist)
setData(ctx, "repositoryPath", git.RepositoryPath(gist.User.Username, gist.Uuid))
}
}
return route.handler(ctx)
}
}
return notFound("Gist not found")
}
func uploadPack(ctx echo.Context) error {
return pack(ctx, "upload-pack")
}
func receivePack(ctx echo.Context) error {
return pack(ctx, "receive-pack")
}
func pack(ctx echo.Context, serviceType string) error {
noCacheHeaders(ctx)
defer ctx.Request().Body.Close()
if ctx.Request().Header.Get("Content-Type") != "application/x-git-"+serviceType+"-request" {
return errorRes(401, "Git client unsupported", nil)
}
ctx.Response().Header().Set("Content-Type", "application/x-git-"+serviceType+"-result")
var err error
reqBody := ctx.Request().Body
if ctx.Request().Header.Get("Content-Encoding") == "gzip" {
reqBody, err = gzip.NewReader(reqBody)
if err != nil {
return errorRes(500, "Cannot create gzip reader", err)
}
}
repositoryPath := getData(ctx, "repositoryPath").(string)
var stderr bytes.Buffer
cmd := exec.Command("git", serviceType, "--stateless-rpc", repositoryPath)
cmd.Dir = repositoryPath
cmd.Stdin = reqBody
cmd.Stdout = ctx.Response().Writer
cmd.Stderr = &stderr
if err = cmd.Run(); err != nil {
return errorRes(500, "Cannot run git "+serviceType+" ; "+stderr.String(), err)
}
// updatedAt is updated only if serviceType is receive-pack
if serviceType == "receive-pack" {
gist := getData(ctx, "gist").(*db.Gist)
if hasNoCommits, err := git.HasNoCommits(gist.User.Username, gist.Uuid); err != nil {
return err
} else if hasNoCommits {
if err = gist.Delete(); err != nil {
return err
}
}
_ = gist.SetLastActiveNow()
_ = gist.UpdatePreviewAndCount(false)
}
return nil
}
func infoRefs(ctx echo.Context) error {
noCacheHeaders(ctx)
var service string
gist := getData(ctx, "gist").(*db.Gist)
serviceType := ctx.QueryParam("service")
if strings.HasPrefix(serviceType, "git-") {
service = strings.TrimPrefix(serviceType, "git-")
}
if service != "upload-pack" && service != "receive-pack" {
if err := gist.UpdateServerInfo(); err != nil {
return errorRes(500, "Cannot update server info", err)
}
return sendFile(ctx, "text/plain; charset=utf-8")
}
refs, err := gist.RPC(service)
if err != nil {
return errorRes(500, "Cannot run git "+service, err)
}
ctx.Response().Header().Set("Content-Type", "application/x-git-"+service+"-advertisement")
ctx.Response().WriteHeader(200)
_, _ = ctx.Response().Write(packetWrite("# service=git-" + service + "\n"))
_, _ = ctx.Response().Write([]byte("0000"))
_, _ = ctx.Response().Write(refs)
return nil
}
func textFile(ctx echo.Context) error {
noCacheHeaders(ctx)
return sendFile(ctx, "text/plain")
}
func infoPacks(ctx echo.Context) error {
cacheHeadersForever(ctx)
return sendFile(ctx, "text/plain; charset=utf-8")
}
func looseObject(ctx echo.Context) error {
cacheHeadersForever(ctx)
return sendFile(ctx, "application/x-git-loose-object")
}
func packFile(ctx echo.Context) error {
cacheHeadersForever(ctx)
return sendFile(ctx, "application/x-git-packed-objects")
}
func idxFile(ctx echo.Context) error {
cacheHeadersForever(ctx)
return sendFile(ctx, "application/x-git-packed-objects-toc")
}
func noCacheHeaders(ctx echo.Context) {
ctx.Response().Header().Set("Expires", "Thu, 01 Jan 1970 00:00:00 UTC")
ctx.Response().Header().Set("Pragma", "no-cache")
ctx.Response().Header().Set("Cache-Control", "no-cache, max-age=0, must-revalidate")
}
func cacheHeadersForever(ctx echo.Context) {
now := time.Now().Unix()
expires := now + 31536000
ctx.Response().Header().Set("Date", fmt.Sprintf("%d", now))
ctx.Response().Header().Set("Expires", fmt.Sprintf("%d", expires))
ctx.Response().Header().Set("Cache-Control", "public, max-age=31536000")
}
func basicAuth(ctx echo.Context) error {
ctx.Response().Header().Set("WWW-Authenticate", `Basic realm="."`)
return plainText(ctx, 401, "Requires authentication")
}
func basicAuthDecode(encoded string) (string, string, error) {
s, err := base64.StdEncoding.DecodeString(encoded)
if err != nil {
return "", "", err
}
auth := strings.SplitN(string(s), ":", 2)
return auth[0], auth[1], nil
}
func sendFile(ctx echo.Context, contentType string) error {
gitFile := "/" + strings.Join(strings.Split(ctx.Request().URL.Path, "/")[3:], "/")
gitFile = path.Join(getData(ctx, "repositoryPath").(string), gitFile)
fi, err := os.Stat(gitFile)
if os.IsNotExist(err) {
return errorRes(404, "File not found", nil)
}
ctx.Response().Header().Set("Content-Type", contentType)
ctx.Response().Header().Set("Content-Length", fmt.Sprintf("%d", fi.Size()))
ctx.Response().Header().Set("Last-Modified", fi.ModTime().Format(http.TimeFormat))
return ctx.File(gitFile)
}
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)
}