mirror of
https://github.com/thomiceli/opengist.git
synced 2026-08-07 07:14:49 +00:00
Improve git /init with correlation token (#751)
This commit is contained in:
@@ -160,6 +160,12 @@ func Setup(dbUri string) error {
|
||||
return err
|
||||
}
|
||||
|
||||
if db.Migrator().HasTable(&GistInitQueue{}) {
|
||||
if err = db.Where("1 = 1").Delete(&GistInitQueue{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if err = db.AutoMigrate(&User{}, &Gist{}, &SSHKey{}, &AdminSetting{}, &Invitation{}, &WebAuthnCredential{}, &TOTP{}, &GistTopic{}, &GistLanguage{}, &GistInitQueue{}, &AccessToken{}, &ActionLock{}); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -225,6 +231,7 @@ func setupSQLite(dbInfo databaseInfo) error {
|
||||
u.Scheme = "file"
|
||||
q := u.Query()
|
||||
q.Set("_pragma", "foreign_keys(1)")
|
||||
q.Add("_pragma", "busy_timeout(5000)")
|
||||
q.Set("_journal_mode", journalMode)
|
||||
u.RawQuery = q.Encode()
|
||||
dsn = u.String()
|
||||
|
||||
@@ -1,34 +1,79 @@
|
||||
package db
|
||||
|
||||
import "errors"
|
||||
|
||||
var ErrInitGistAlreadyConsumed = errors.New("init gist already consumed")
|
||||
|
||||
// GistInitQueue tracks gists created by the "git push .../init" flow between the
|
||||
// two HTTP requests git performs (info/refs then git-receive-pack). Each entry
|
||||
// carries a Token that correlates those two requests so the receive-pack step
|
||||
// targets the exact gist created by its matching info/refs step, instead of
|
||||
// guessing from a per-user FIFO.
|
||||
type GistInitQueue struct {
|
||||
GistID uint `gorm:"primaryKey"`
|
||||
Gist Gist `gorm:"constraint:OnUpdate:CASCADE,OnDelete:CASCADE;foreignKey:GistID"`
|
||||
UserID uint `gorm:"primaryKey"`
|
||||
User User `gorm:"constraint:OnUpdate:CASCADE,OnDelete:CASCADE;foreignKey:UserID"`
|
||||
GistID uint `gorm:"primaryKey"`
|
||||
Gist Gist `gorm:"constraint:OnUpdate:CASCADE,OnDelete:CASCADE;foreignKey:GistID"`
|
||||
UserID uint `gorm:"primaryKey"`
|
||||
User User `gorm:"constraint:OnUpdate:CASCADE,OnDelete:CASCADE;foreignKey:UserID"`
|
||||
Token string `gorm:"size:32;uniqueIndex"`
|
||||
}
|
||||
|
||||
func GetInitGistInQueueForUser(userID uint) (*Gist, error) {
|
||||
func AddInitGistToQueue(gistID uint, userID uint, token string) error {
|
||||
queue := &GistInitQueue{
|
||||
GistID: gistID,
|
||||
UserID: userID,
|
||||
Token: token,
|
||||
}
|
||||
return db.Create(&queue).Error
|
||||
}
|
||||
|
||||
func GetInitGistByToken(token string) (*Gist, error) {
|
||||
queue := new(GistInitQueue)
|
||||
err := db.Preload("Gist").Preload("Gist.User").
|
||||
Where("user_id = ?", userID).
|
||||
Order("gist_id asc").
|
||||
Where("token = ?", token).
|
||||
First(&queue).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &queue.Gist, nil
|
||||
}
|
||||
|
||||
err = db.Delete(&queue).Error
|
||||
if err != nil {
|
||||
func PopInitGistByToken(token string) (*Gist, error) {
|
||||
queue := new(GistInitQueue)
|
||||
if err := db.Preload("Gist").Preload("Gist.User").
|
||||
Where("token = ?", token).
|
||||
First(&queue).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
res := db.Where("token = ?", token).Delete(&GistInitQueue{})
|
||||
if res.Error != nil {
|
||||
return nil, res.Error
|
||||
}
|
||||
if res.RowsAffected == 0 {
|
||||
return nil, ErrInitGistAlreadyConsumed
|
||||
}
|
||||
|
||||
return &queue.Gist, nil
|
||||
}
|
||||
|
||||
func AddInitGistToQueue(gistID uint, userID uint) error {
|
||||
queue := &GistInitQueue{
|
||||
GistID: gistID,
|
||||
UserID: userID,
|
||||
func PopInitGistForUser(userID uint) (*Gist, error) {
|
||||
for {
|
||||
queue := new(GistInitQueue)
|
||||
if err := db.Preload("Gist").Preload("Gist.User").
|
||||
Where("user_id = ?", userID).
|
||||
Order("gist_id asc").
|
||||
First(&queue).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
res := db.Where("gist_id = ? AND user_id = ?", queue.GistID, userID).
|
||||
Delete(&GistInitQueue{})
|
||||
if res.Error != nil {
|
||||
return nil, res.Error
|
||||
}
|
||||
if res.RowsAffected > 0 {
|
||||
return &queue.Gist, nil
|
||||
}
|
||||
// Lost the race for this entry; try the next oldest one.
|
||||
}
|
||||
return db.Create(&queue).Error
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/gorilla/sessions"
|
||||
"github.com/markbates/goth/gothic"
|
||||
@@ -11,6 +12,8 @@ import (
|
||||
"github.com/thomiceli/opengist/internal/session"
|
||||
)
|
||||
|
||||
var gothicStoreOnce sync.Once
|
||||
|
||||
type Store struct {
|
||||
sessionsPath string
|
||||
|
||||
@@ -27,7 +30,9 @@ func NewStore(sessionsPath string) *Store {
|
||||
s.UserStore = sessions.NewFilesystemStore(s.sessionsPath, config.SecretKey, encryptKey)
|
||||
s.UserStore.MaxLength(10 * 1024)
|
||||
hardenCookie(s.UserStore.Options)
|
||||
gothic.Store = s.UserStore
|
||||
gothicStoreOnce.Do(func() {
|
||||
gothic.Store = s.UserStore
|
||||
})
|
||||
|
||||
return s
|
||||
}
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
package git
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/rs/zerolog/log"
|
||||
"github.com/thomiceli/opengist/internal/db"
|
||||
"github.com/thomiceli/opengist/internal/web/context"
|
||||
)
|
||||
|
||||
// handlePull authorizes a clone/pull of an existing gist before serving it.
|
||||
func handlePull(ctx *context.Context, route *gitRoute, gist *db.Gist, gistExists bool, username, password string) error {
|
||||
log.Debug().Msg("Detected git pull operation")
|
||||
if !gistExists {
|
||||
log.Debug().Str("authUsername", username).Msg("Pulling unknown gist")
|
||||
return ctx.PlainText(404, "Check your credentials or make sure you have access to the Gist")
|
||||
}
|
||||
|
||||
// For a non-private gist (reached here only when unauthenticated access is
|
||||
// disabled) any valid account may pull; for a private gist the password is
|
||||
// checked against the gist owner.
|
||||
userToCheck := gist.User.Username
|
||||
if gist.Private != db.PrivateVisibility {
|
||||
log.Debug().Str("authUsername", username).Msg("Pulling non-private gist with authenticated access")
|
||||
userToCheck = username
|
||||
} else {
|
||||
log.Debug().Str("authUsername", username).Str("gistOwner", gist.User.Username).Msg("Pulling private gist")
|
||||
}
|
||||
|
||||
if user, err := authOrFail(ctx, userToCheck, password, 404, "Check your credentials or make sure you have access to the Gist"); user == nil {
|
||||
return err
|
||||
}
|
||||
|
||||
log.Debug().Str("authUsername", username).Msg("Pulling gist")
|
||||
return route.handler(ctx)
|
||||
}
|
||||
|
||||
// handlePush authorizes a push to an existing gist, or creates a new gist when
|
||||
// the user pushes to /<user>/<name>.
|
||||
func handlePush(ctx *context.Context, route *gitRoute, gist *db.Gist, gistExists bool, username, password string) error {
|
||||
log.Debug().Msg("Detected git push operation")
|
||||
|
||||
if gistExists {
|
||||
log.Debug().Str("authUsername", username).Str("gistOwner", gist.User.Username).Msg("Pushing to existing gist")
|
||||
if user, err := authOrFail(ctx, gist.User.Username, password, 404, "Check your credentials or make sure you have access to the Gist"); user == nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if gist.Archived {
|
||||
log.Debug().Str("authUsername", username).Msg("Pushing to archived gist")
|
||||
return ctx.PlainText(403, "This gist is archived and is read-only")
|
||||
}
|
||||
|
||||
log.Debug().Str("authUsername", username).Msg("Pushing gist")
|
||||
return route.handler(ctx)
|
||||
}
|
||||
|
||||
// The gist does not exist: the user creates it by pushing to /<user>/<name>.
|
||||
log.Debug().Str("authUsername", username).Msg("Creating new gist by pushing")
|
||||
user, err := authOrFail(ctx, username, password, 404, "Check your credentials or make sure you have access to the Gist")
|
||||
if user == nil {
|
||||
return err
|
||||
}
|
||||
|
||||
urlPath := ctx.Request().URL.Path
|
||||
pathParts := strings.Split(strings.Trim(urlPath, "/"), "/")
|
||||
if pathParts[0] != username || len(pathParts) != 4 {
|
||||
log.Debug().Str("authUsername", username).Any("path", pathParts).Msg("Invalid URL format for push operation")
|
||||
return ctx.PlainText(401, "Invalid URL format for push operation")
|
||||
}
|
||||
|
||||
log.Debug().Str("authUsername", username).Msg("Valid URL format for push operation")
|
||||
gist, err = createGist(user, pathParts[1])
|
||||
if err != nil {
|
||||
return ctx.ErrorRes(500, "Cannot create gist", err)
|
||||
}
|
||||
log.Debug().Str("authUsername", username).Str("url", urlPath).Msg("Gist created")
|
||||
|
||||
setGistContext(ctx, gist)
|
||||
return route.handler(ctx)
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package git
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"strings"
|
||||
|
||||
"github.com/rs/zerolog/log"
|
||||
"github.com/thomiceli/opengist/internal/auth"
|
||||
"github.com/thomiceli/opengist/internal/db"
|
||||
"github.com/thomiceli/opengist/internal/web/context"
|
||||
)
|
||||
|
||||
// authOrFail authenticates the given credentials. On success it returns the
|
||||
// user. On failure it writes the appropriate HTTP response and returns a nil
|
||||
// user, so callers stop with:
|
||||
//
|
||||
// user, err := authOrFail(...)
|
||||
// if user == nil {
|
||||
// return err
|
||||
// }
|
||||
//
|
||||
// `err` is nil for an already-written invalid-credentials response and a
|
||||
// renderable error for an internal authentication failure.
|
||||
func authOrFail(ctx *context.Context, username, password string, invalidCode int, invalidMsg string) (*db.User, error) {
|
||||
user, err := auth.TryAuthentication(username, password)
|
||||
if err == nil {
|
||||
return user, nil
|
||||
}
|
||||
|
||||
var authErr auth.AuthError
|
||||
if errors.As(err, &authErr) {
|
||||
log.Warn().Msg("Invalid HTTP authentication attempt from " + ctx.RealIP())
|
||||
return nil, ctx.PlainText(invalidCode, invalidMsg)
|
||||
}
|
||||
return nil, ctx.ErrorRes(500, "Authentication system error", nil)
|
||||
}
|
||||
|
||||
func basicAuth(ctx *context.Context) error {
|
||||
ctx.Response().Header().Set("WWW-Authenticate", `Basic realm="."`)
|
||||
return ctx.PlainText(401, "Requires authentication")
|
||||
}
|
||||
|
||||
func parseAuthHeader(ctx *context.Context) (string, string, error) {
|
||||
authHeader := ctx.Request().Header.Get("Authorization")
|
||||
if authHeader == "" {
|
||||
return "", "", errors.New("no auth header")
|
||||
}
|
||||
|
||||
authFields := strings.Fields(authHeader)
|
||||
if len(authFields) != 2 || authFields[0] != "Basic" {
|
||||
return "", "", errors.New("invalid auth header")
|
||||
}
|
||||
|
||||
authUsername, authPassword, err := basicAuthDecode(authFields[1])
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("Cannot decode basic auth header")
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
return authUsername, authPassword, nil
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package git
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"path"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/thomiceli/opengist/internal/web/context"
|
||||
)
|
||||
|
||||
func textFile(ctx *context.Context) error {
|
||||
noCacheHeaders(ctx)
|
||||
return sendFile(ctx, "text/plain")
|
||||
}
|
||||
|
||||
func infoPacks(ctx *context.Context) error {
|
||||
cacheHeadersForever(ctx)
|
||||
return sendFile(ctx, "text/plain; charset=utf-8")
|
||||
}
|
||||
|
||||
func looseObject(ctx *context.Context) error {
|
||||
cacheHeadersForever(ctx)
|
||||
return sendFile(ctx, "application/x-git-loose-object")
|
||||
}
|
||||
|
||||
func packFile(ctx *context.Context) error {
|
||||
cacheHeadersForever(ctx)
|
||||
return sendFile(ctx, "application/x-git-packed-objects")
|
||||
}
|
||||
|
||||
func idxFile(ctx *context.Context) error {
|
||||
cacheHeadersForever(ctx)
|
||||
return sendFile(ctx, "application/x-git-packed-objects-toc")
|
||||
}
|
||||
|
||||
func sendFile(ctx *context.Context, contentType string) error {
|
||||
gitFile := "/" + strings.Join(strings.Split(ctx.Request().URL.Path, "/")[3:], "/")
|
||||
gitFile = path.Join(ctx.GetData("repositoryPath").(string), gitFile)
|
||||
fi, err := os.Stat(gitFile)
|
||||
if os.IsNotExist(err) {
|
||||
return ctx.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 noCacheHeaders(ctx *context.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 *context.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")
|
||||
}
|
||||
@@ -1,21 +1,9 @@
|
||||
package git
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/rs/zerolog/log"
|
||||
"github.com/thomiceli/opengist/internal/auth"
|
||||
"github.com/thomiceli/opengist/internal/db"
|
||||
@@ -24,11 +12,15 @@ import (
|
||||
"github.com/thomiceli/opengist/internal/web/handlers"
|
||||
)
|
||||
|
||||
var routes = []struct {
|
||||
// gitRoute maps a Git smart/dumb HTTP URL (matched as a regexp) and method to
|
||||
// the handler that serves it.
|
||||
type gitRoute struct {
|
||||
gitUrl string
|
||||
method string
|
||||
handler func(ctx *context.Context) error
|
||||
}{
|
||||
}
|
||||
|
||||
var routes = []gitRoute{
|
||||
{"(.*?)/git-upload-pack$", "POST", uploadPack},
|
||||
{"(.*?)/git-receive-pack$", "POST", receivePack},
|
||||
{"(.*?)/info/refs$", "GET", infoRefs},
|
||||
@@ -42,37 +34,39 @@ var routes = []struct {
|
||||
{"(.*?)/objects/pack/pack-[0-9a-f]{40}\\.idx$", "GET", idxFile},
|
||||
}
|
||||
|
||||
// GitHttp is the entry point for all Git-over-HTTP requests. It resolves the
|
||||
// matching route, classifies the request (init / pull / push) and dispatches to
|
||||
// the relevant handler once access has been authorized.
|
||||
func GitHttp(ctx *context.Context) error {
|
||||
route := findMatchingRoute(ctx)
|
||||
if route == nil {
|
||||
return ctx.NotFound("Gist not found") // regular 404 for non-git routes
|
||||
}
|
||||
|
||||
gist := ctx.GetData("gist").(*db.Gist)
|
||||
gistExists := gist.ID != 0
|
||||
|
||||
isInitRoute := strings.HasPrefix(ctx.Request().URL.Path, "/init/info/refs")
|
||||
isInitRouteReceive := strings.HasPrefix(ctx.Request().URL.Path, "/init/git-receive-pack")
|
||||
initKind, initToken := classifyInitRequest(ctx.Request().URL.Path)
|
||||
isInfoRefs := strings.HasSuffix(route.gitUrl, "/info/refs$")
|
||||
isPull := ctx.QueryParam("service") == "git-upload-pack" ||
|
||||
strings.HasSuffix(ctx.Request().URL.Path, "git-upload-pack") && !isInfoRefs
|
||||
isPush := ctx.QueryParam("service") == "git-receive-pack" ||
|
||||
strings.HasSuffix(ctx.Request().URL.Path, "git-receive-pack") && !isInfoRefs
|
||||
|
||||
repositoryPath := git.RepositoryPath(gist.User.Username, gist.Uuid)
|
||||
ctx.SetData("repositoryPath", repositoryPath)
|
||||
ctx.SetData("repositoryPath", git.RepositoryPath(gist.User.Username, gist.Uuid))
|
||||
|
||||
allow, err := auth.ShouldAllowUnauthenticatedGistAccess(handlers.ContextAuthInfo{Context: ctx}, true)
|
||||
if err != nil {
|
||||
log.Fatal().Err(err).Msg("Cannot check if unauthenticated access is allowed")
|
||||
}
|
||||
|
||||
// No need to authenticate if the user wants
|
||||
// to clone/pull ; a non-private gist ; that exists ; where unauthenticated access is allowed in the instance
|
||||
// No need to authenticate if the user wants to clone/pull ; a non-private
|
||||
// gist ; that exists ; where unauthenticated access is allowed in the instance
|
||||
if isPull && gist.Private != db.PrivateVisibility && gistExists && allow {
|
||||
return route.handler(ctx)
|
||||
}
|
||||
|
||||
// Else we need to authenticate the user, that include other cases:
|
||||
// Every other case needs credentials:
|
||||
// - user wants to push the gist
|
||||
// - user wants to clone/pull a private gist
|
||||
// - user wants to clone/pull a non-private gist but unauthenticated access is not allowed
|
||||
@@ -83,350 +77,35 @@ func GitHttp(ctx *context.Context) error {
|
||||
return basicAuth(ctx)
|
||||
}
|
||||
|
||||
// if the user wants to create a gist via the /init route
|
||||
if isInitRoute || isInitRouteReceive {
|
||||
var user *db.User
|
||||
|
||||
// check if the user has a valid account on opengist to push a gist
|
||||
user, err = auth.TryAuthentication(authUsername, authPassword)
|
||||
if err != nil {
|
||||
var authErr auth.AuthError
|
||||
if errors.As(err, &authErr) {
|
||||
log.Warn().Msg("Invalid HTTP authentication attempt from " + ctx.RealIP())
|
||||
return ctx.PlainText(401, "Invalid credentials")
|
||||
}
|
||||
return ctx.ErrorRes(500, "Authentication system error", nil)
|
||||
}
|
||||
|
||||
if isInitRoute {
|
||||
gist, err = createGist(user, "")
|
||||
if err != nil {
|
||||
return ctx.ErrorRes(500, "Cannot create gist", err)
|
||||
}
|
||||
|
||||
err = db.AddInitGistToQueue(gist.ID, user.ID)
|
||||
if err != nil {
|
||||
return ctx.ErrorRes(500, "Cannot add inited gist to the queue", err)
|
||||
}
|
||||
ctx.SetData("gist", gist)
|
||||
return route.handler(ctx)
|
||||
} else {
|
||||
gist, err = db.GetInitGistInQueueForUser(user.ID)
|
||||
if err != nil {
|
||||
return ctx.ErrorRes(500, "Cannot retrieve inited gist from the queue", err)
|
||||
}
|
||||
|
||||
ctx.SetData("gist", gist)
|
||||
ctx.SetData("repositoryPath", git.RepositoryPath(gist.User.Username, gist.Uuid))
|
||||
return route.handler(ctx)
|
||||
}
|
||||
}
|
||||
|
||||
// if clone/pull
|
||||
// check if the gist exists and if the credentials are valid
|
||||
if isPull {
|
||||
log.Debug().Msg("Detected git pull operation")
|
||||
if !gistExists {
|
||||
log.Debug().Str("authUsername", authUsername).Msg("Pulling unknown gist")
|
||||
return ctx.PlainText(404, "Check your credentials or make sure you have access to the Gist")
|
||||
}
|
||||
|
||||
var userToCheckPermissions string
|
||||
// if the user is trying to clone/pull a non-private gist while unauthenticated access is not allowed,
|
||||
// check if the user has a valid account
|
||||
if gist.Private != db.PrivateVisibility {
|
||||
log.Debug().Str("authUsername", authUsername).Msg("Pulling non-private gist with authenticated access")
|
||||
userToCheckPermissions = authUsername
|
||||
} else { // else just check the password against the gist owner
|
||||
log.Debug().Str("authUsername", authUsername).Str("gistOwner", gist.User.Username).Msg("Pulling private gist")
|
||||
userToCheckPermissions = gist.User.Username
|
||||
}
|
||||
|
||||
if _, err = auth.TryAuthentication(userToCheckPermissions, authPassword); err != nil {
|
||||
var authErr auth.AuthError
|
||||
if errors.As(err, &authErr) {
|
||||
log.Warn().Msg("Invalid HTTP authentication attempt from " + ctx.RealIP())
|
||||
return ctx.PlainText(404, "Check your credentials or make sure you have access to the Gist")
|
||||
}
|
||||
return ctx.ErrorRes(500, "Authentication system error", nil)
|
||||
}
|
||||
log.Debug().Str("authUsername", authUsername).Msg("Pulling gist")
|
||||
|
||||
switch {
|
||||
case initKind != initNone:
|
||||
return handleInit(ctx, route, initKind, initToken, authUsername, authPassword)
|
||||
case isPull:
|
||||
return handlePull(ctx, route, gist, gistExists, authUsername, authPassword)
|
||||
case isPush:
|
||||
return handlePush(ctx, route, gist, gistExists, authUsername, authPassword)
|
||||
default:
|
||||
return route.handler(ctx)
|
||||
}
|
||||
|
||||
if isPush {
|
||||
log.Debug().Msg("Detected git push operation")
|
||||
// if gist exists, check if the credentials are valid and if the user is the gist owner
|
||||
if gistExists {
|
||||
log.Debug().Str("authUsername", authUsername).Str("gistOwner", gist.User.Username).Msg("Pushing to existing gist")
|
||||
if _, err = auth.TryAuthentication(gist.User.Username, authPassword); err != nil {
|
||||
var authErr auth.AuthError
|
||||
if errors.As(err, &authErr) {
|
||||
log.Warn().Msg("Invalid HTTP authentication attempt from " + ctx.RealIP())
|
||||
return ctx.PlainText(404, "Check your credentials or make sure you have access to the Gist")
|
||||
}
|
||||
return ctx.ErrorRes(500, "Authentication system error", nil)
|
||||
}
|
||||
|
||||
if gist.Archived {
|
||||
log.Debug().Str("authUsername", authUsername).Msg("Pushing to archived gist")
|
||||
return ctx.PlainText(403, "This gist is archived and is read-only")
|
||||
}
|
||||
log.Debug().Str("authUsername", authUsername).Msg("Pushing gist")
|
||||
|
||||
return route.handler(ctx)
|
||||
} else { // if the gist does not exist, check if the user has a valid account on opengist to push a gist and create it
|
||||
log.Debug().Str("authUsername", authUsername).Msg("Creating new gist by pushing")
|
||||
var user *db.User
|
||||
if user, err = auth.TryAuthentication(authUsername, authPassword); err != nil {
|
||||
var authErr auth.AuthError
|
||||
if errors.As(err, &authErr) {
|
||||
log.Warn().Msg("Invalid HTTP authentication attempt from " + ctx.RealIP())
|
||||
return ctx.PlainText(404, "Check your credentials or make sure you have access to the Gist")
|
||||
}
|
||||
return ctx.ErrorRes(500, "Authentication system error", nil)
|
||||
}
|
||||
|
||||
urlPath := ctx.Request().URL.Path
|
||||
pathParts := strings.Split(strings.Trim(urlPath, "/"), "/")
|
||||
if pathParts[0] == authUsername && len(pathParts) == 4 {
|
||||
log.Debug().Str("authUsername", authUsername).Msg("Valid URL format for push operation")
|
||||
gist, err = createGist(user, pathParts[1])
|
||||
if err != nil {
|
||||
return ctx.ErrorRes(500, "Cannot create gist", err)
|
||||
}
|
||||
log.Debug().Str("authUsername", authUsername).Str("url", urlPath).Msg("Gist created")
|
||||
ctx.SetData("gist", gist)
|
||||
ctx.SetData("repositoryPath", git.RepositoryPath(gist.User.Username, gist.Uuid))
|
||||
} else {
|
||||
log.Debug().Str("authUsername", authUsername).Any("path", pathParts).Msg("Invalid URL format for push operation")
|
||||
return ctx.PlainText(401, "Invalid URL format for push operation")
|
||||
}
|
||||
return route.handler(ctx)
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
return route.handler(ctx)
|
||||
}
|
||||
|
||||
func findMatchingRoute(ctx *context.Context) *struct {
|
||||
gitUrl string
|
||||
method string
|
||||
handler func(ctx *context.Context) error
|
||||
} {
|
||||
for _, route := range routes {
|
||||
// setGistContext points the request at a specific gist and its repository path,
|
||||
// overriding whatever the soft-init middleware put in place.
|
||||
func setGistContext(ctx *context.Context, gist *db.Gist) {
|
||||
ctx.SetData("gist", gist)
|
||||
ctx.SetData("repositoryPath", git.RepositoryPath(gist.User.Username, gist.Uuid))
|
||||
}
|
||||
|
||||
func findMatchingRoute(ctx *context.Context) *gitRoute {
|
||||
for i := range routes {
|
||||
route := &routes[i]
|
||||
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
|
||||
}
|
||||
return &route
|
||||
return route
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func createGist(user *db.User, url string) (*db.Gist, error) {
|
||||
gist := new(db.Gist)
|
||||
gist.UserID = user.ID
|
||||
gist.User = *user
|
||||
uuidGist, err := uuid.NewRandom()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
gist.Uuid = strings.ReplaceAll(uuidGist.String(), "-", "")
|
||||
gist.Title = "gist:" + gist.Uuid
|
||||
|
||||
if url != "" {
|
||||
gist.URL = strings.TrimSuffix(url, ".git")
|
||||
gist.Title = strings.TrimSuffix(url, ".git")
|
||||
}
|
||||
|
||||
if err := gist.InitRepository(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := gist.Create(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return gist, nil
|
||||
}
|
||||
|
||||
func uploadPack(ctx *context.Context) error {
|
||||
return pack(ctx, "upload-pack")
|
||||
}
|
||||
|
||||
func receivePack(ctx *context.Context) error {
|
||||
return pack(ctx, "receive-pack")
|
||||
}
|
||||
|
||||
func pack(ctx *context.Context, serviceType string) error {
|
||||
noCacheHeaders(ctx)
|
||||
defer ctx.Request().Body.Close()
|
||||
|
||||
if ctx.Request().Header.Get("Content-Type") != "application/x-git-"+serviceType+"-request" {
|
||||
return ctx.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 ctx.ErrorRes(500, "Cannot create gzip reader", err)
|
||||
}
|
||||
}
|
||||
|
||||
repositoryPath := ctx.GetData("repositoryPath").(string)
|
||||
gist := ctx.GetData("gist").(*db.Gist)
|
||||
|
||||
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
|
||||
cmd.Env = os.Environ()
|
||||
cmd.Env = append(cmd.Env, "OPENGIST_REPOSITORY_URL_INTERNAL="+git.RepositoryUrl(ctx, gist.User.Username, gist.Identifier()))
|
||||
cmd.Env = append(cmd.Env, "OPENGIST_REPOSITORY_ID="+strconv.Itoa(int(gist.ID)))
|
||||
|
||||
if err = cmd.Run(); err != nil {
|
||||
return ctx.ErrorRes(500, "Cannot run git "+serviceType+" ; "+stderr.String(), err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func infoRefs(ctx *context.Context) error {
|
||||
noCacheHeaders(ctx)
|
||||
var service string
|
||||
|
||||
gist := ctx.GetData("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 ctx.ErrorRes(500, "Cannot update server info", err)
|
||||
}
|
||||
return sendFile(ctx, "text/plain; charset=utf-8")
|
||||
}
|
||||
|
||||
refs, err := gist.RPC(service)
|
||||
if err != nil {
|
||||
return ctx.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 *context.Context) error {
|
||||
noCacheHeaders(ctx)
|
||||
return sendFile(ctx, "text/plain")
|
||||
}
|
||||
|
||||
func infoPacks(ctx *context.Context) error {
|
||||
cacheHeadersForever(ctx)
|
||||
return sendFile(ctx, "text/plain; charset=utf-8")
|
||||
}
|
||||
|
||||
func looseObject(ctx *context.Context) error {
|
||||
cacheHeadersForever(ctx)
|
||||
return sendFile(ctx, "application/x-git-loose-object")
|
||||
}
|
||||
|
||||
func packFile(ctx *context.Context) error {
|
||||
cacheHeadersForever(ctx)
|
||||
return sendFile(ctx, "application/x-git-packed-objects")
|
||||
}
|
||||
|
||||
func idxFile(ctx *context.Context) error {
|
||||
cacheHeadersForever(ctx)
|
||||
return sendFile(ctx, "application/x-git-packed-objects-toc")
|
||||
}
|
||||
|
||||
func noCacheHeaders(ctx *context.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 *context.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 *context.Context) error {
|
||||
ctx.Response().Header().Set("WWW-Authenticate", `Basic realm="."`)
|
||||
return ctx.PlainText(401, "Requires authentication")
|
||||
}
|
||||
|
||||
func parseAuthHeader(ctx *context.Context) (string, string, error) {
|
||||
authHeader := ctx.Request().Header.Get("Authorization")
|
||||
if authHeader == "" {
|
||||
return "", "", errors.New("no auth header")
|
||||
}
|
||||
|
||||
authFields := strings.Fields(authHeader)
|
||||
if len(authFields) != 2 || authFields[0] != "Basic" {
|
||||
return "", "", errors.New("invalid auth header")
|
||||
}
|
||||
|
||||
authUsername, authPassword, err := basicAuthDecode(authFields[1])
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("Cannot decode basic auth header")
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
return authUsername, authPassword, nil
|
||||
}
|
||||
|
||||
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 *context.Context, contentType string) error {
|
||||
gitFile := "/" + strings.Join(strings.Split(ctx.Request().URL.Path, "/")[3:], "/")
|
||||
gitFile = path.Join(ctx.GetData("repositoryPath").(string), gitFile)
|
||||
fi, err := os.Stat(gitFile)
|
||||
if os.IsNotExist(err) {
|
||||
return ctx.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)
|
||||
}
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
package git_test
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
@@ -212,6 +214,104 @@ func TestGitPushArchived(t *testing.T) {
|
||||
require.NoError(t, gitPush(dest, "afterunarchive.txt", "content"))
|
||||
}
|
||||
|
||||
// gitInitAndPushTo initializes a fresh repo with a single file and pushes it to
|
||||
// remotePath (e.g. "/init") as creds. It returns whatever git push returns.
|
||||
func gitInitAndPushTo(baseUrl, creds, remotePath, filename, content string, destDir string) error {
|
||||
if err := exec.Command("git", "init", "--initial-branch=master", destDir).Run(); err != nil {
|
||||
return err
|
||||
}
|
||||
remote := "http://" + creds + "@" + baseUrl[len("http://"):] + remotePath
|
||||
if err := exec.Command("git", "-C", destDir, "remote", "add", "origin", remote).Run(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(destDir, filename), []byte(content), 0644); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := exec.Command("git", "-C", destDir, "add", ".").Run(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := exec.Command("git", "-C", destDir, "commit", "-m", "init").Run(); err != nil {
|
||||
return err
|
||||
}
|
||||
return exec.Command("git", "-C", destDir, "push", "origin", "master").Run()
|
||||
}
|
||||
|
||||
// TestGitInitPushParallel hammers the /init create-by-push flow concurrently.
|
||||
// Before the per-push correlation token, the two HTTP requests git makes
|
||||
// (info/refs then git-receive-pack) were matched via a per-user FIFO queue, so
|
||||
// interleaved pushes desynced it: pushes 500'd or content landed in the wrong
|
||||
// gist. This asserts every parallel push lands in its own gist with its own
|
||||
// content, and that the queue drains to empty.
|
||||
func TestGitInitPushParallel(t *testing.T) {
|
||||
s := webtest.Setup(t)
|
||||
defer webtest.Teardown(t)
|
||||
|
||||
baseUrl := s.StartHttpServer(t)
|
||||
s.Register(t, "thomas")
|
||||
|
||||
const n = 10
|
||||
creds := "thomas:thomas"
|
||||
|
||||
// Each push writes a distinct file/content so we can detect misrouting.
|
||||
want := make(map[string]string, n)
|
||||
for i := 0; i < n; i++ {
|
||||
want[fmt.Sprintf("file-%d.txt", i)] = fmt.Sprintf("content-%d", i)
|
||||
}
|
||||
|
||||
// Pre-create the temp dirs on the main goroutine, then push from all of them
|
||||
// at once.
|
||||
dirs := make([]string, n)
|
||||
for i := 0; i < n; i++ {
|
||||
dirs[i] = t.TempDir()
|
||||
}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
errs := make([]error, n)
|
||||
for i := 0; i < n; i++ {
|
||||
wg.Add(1)
|
||||
go func(i int) {
|
||||
defer wg.Done()
|
||||
errs[i] = gitInitAndPushTo(baseUrl, creds, "/init",
|
||||
fmt.Sprintf("file-%d.txt", i), fmt.Sprintf("content-%d", i), dirs[i])
|
||||
}(i)
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
for i, err := range errs {
|
||||
require.NoErrorf(t, err, "parallel /init push %d failed", i)
|
||||
}
|
||||
|
||||
user, err := db.GetUserByUsername("thomas")
|
||||
require.NoError(t, err)
|
||||
|
||||
// Exactly n gists were created, one per push.
|
||||
count, err := db.CountAllGistsFromUser(user.ID, user.ID)
|
||||
require.NoError(t, err)
|
||||
require.EqualValues(t, n, count, "expected one gist per parallel push")
|
||||
|
||||
// Every pushed file/content is present exactly once across the gists, proving
|
||||
// no push's content was routed into another push's gist.
|
||||
gists, err := db.GetAllGistsOfUser(user.ID, nil, 0, "created", "desc", 100, 100)
|
||||
require.NoError(t, err)
|
||||
|
||||
got := make(map[string]string, n)
|
||||
for _, g := range gists {
|
||||
files, _, err := g.Files("HEAD", false)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, files, 1, "each init gist should contain exactly one file")
|
||||
f := files[0]
|
||||
_, dup := got[f.Filename]
|
||||
require.Falsef(t, dup, "file %q appeared in more than one gist", f.Filename)
|
||||
got[f.Filename] = f.Content
|
||||
}
|
||||
require.Equal(t, want, got, "pushed files/content did not map one-to-one onto gists")
|
||||
|
||||
// The init queue must drain completely, leaving no stale correlation entries.
|
||||
queued, err := db.CountAll(&db.GistInitQueue{})
|
||||
require.NoError(t, err)
|
||||
require.EqualValues(t, 0, queued, "init queue should be empty after all pushes complete")
|
||||
}
|
||||
|
||||
func TestGitCreatePush(t *testing.T) {
|
||||
s := webtest.Setup(t)
|
||||
defer webtest.Teardown(t)
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
package git
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/rs/zerolog/log"
|
||||
"github.com/thomiceli/opengist/internal/db"
|
||||
"github.com/thomiceli/opengist/internal/web/context"
|
||||
)
|
||||
|
||||
type initKind int
|
||||
|
||||
const (
|
||||
initNone initKind = iota // not an /init request
|
||||
initStart // GET /init/info/refs (create gist, redirect)
|
||||
initToken2InfoRefs // GET /init/<token>/info/refs
|
||||
initToken2Receive // POST /init/<token>/git-receive-pack
|
||||
initLegacyReceive // POST /init/git-receive-pack (no token)
|
||||
)
|
||||
|
||||
// classifyInitRequest inspects the request path and reports which leg of the
|
||||
// /init push flow it belongs to, returning the correlation token when present.
|
||||
func classifyInitRequest(urlPath string) (initKind, string) {
|
||||
parts := strings.Split(strings.Trim(urlPath, "/"), "/")
|
||||
if len(parts) < 2 || parts[0] != "init" {
|
||||
return initNone, ""
|
||||
}
|
||||
|
||||
switch parts[1] {
|
||||
case "info": // /init/info/refs
|
||||
return initStart, ""
|
||||
case "git-receive-pack": // /init/git-receive-pack (legacy, no token)
|
||||
return initLegacyReceive, ""
|
||||
default: // /init/<token>/...
|
||||
token := parts[1]
|
||||
tail := strings.Join(parts[2:], "/")
|
||||
switch {
|
||||
case strings.HasPrefix(tail, "info/refs"):
|
||||
return initToken2InfoRefs, token
|
||||
case tail == "git-receive-pack":
|
||||
return initToken2Receive, token
|
||||
default:
|
||||
return initNone, ""
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// handleInit serves a request belonging to the "git push .../init" flow, after
|
||||
// the caller has been authenticated.
|
||||
func handleInit(ctx *context.Context, route *gitRoute, kind initKind, token, username, password string) error {
|
||||
user, err := authOrFail(ctx, username, password, 401, "Invalid credentials")
|
||||
if user == nil {
|
||||
return err
|
||||
}
|
||||
|
||||
switch kind {
|
||||
case initStart:
|
||||
return initStartPush(ctx, user)
|
||||
case initToken2InfoRefs:
|
||||
return initServeToken(ctx, route, user, token, false)
|
||||
case initToken2Receive:
|
||||
return initServeToken(ctx, route, user, token, true)
|
||||
default: // initLegacyReceive
|
||||
// git client that did not follow the correlation redirect: fall back to
|
||||
// the per-user queue, popping the oldest pending init gist atomically.
|
||||
gist, err := db.PopInitGistForUser(user.ID)
|
||||
if err != nil {
|
||||
return ctx.ErrorRes(500, "Cannot retrieve inited gist from the queue", err)
|
||||
}
|
||||
setGistContext(ctx, gist)
|
||||
return route.handler(ctx)
|
||||
}
|
||||
}
|
||||
|
||||
// initStartPush handles the first request of a push to /init: it creates the
|
||||
// gist and hands the client a per-push token via a redirect. git follows the
|
||||
// redirect on this initial request (http.followRedirects=initial, the default)
|
||||
// and uses the redirected URL as the base for the git-receive-pack POST, so that
|
||||
// POST is unambiguously tied to this gist.
|
||||
func initStartPush(ctx *context.Context, user *db.User) error {
|
||||
gist, err := createGist(user, "")
|
||||
if err != nil {
|
||||
return ctx.ErrorRes(500, "Cannot create gist", err)
|
||||
}
|
||||
|
||||
token, err := newInitToken()
|
||||
if err != nil {
|
||||
return ctx.ErrorRes(500, "Cannot create init token", err)
|
||||
}
|
||||
if err = db.AddInitGistToQueue(gist.ID, user.ID, token); err != nil {
|
||||
return ctx.ErrorRes(500, "Cannot add inited gist to the queue", err)
|
||||
}
|
||||
|
||||
// Relative redirect so it stays correct behind a reverse-proxy subpath.
|
||||
noCacheHeaders(ctx)
|
||||
return ctx.Redirect(302, "../"+token+"/info/refs?service=git-receive-pack")
|
||||
}
|
||||
|
||||
// initServeToken resolves the gist for a token-carrying second-leg request and
|
||||
// dispatches to the underlying handler. The info/refs leg looks it up without
|
||||
// consuming the queue entry; the receive-pack leg atomically consumes it.
|
||||
func initServeToken(ctx *context.Context, route *gitRoute, user *db.User, token string, consume bool) error {
|
||||
var gist *db.Gist
|
||||
var err error
|
||||
if consume {
|
||||
gist, err = db.PopInitGistByToken(token)
|
||||
} else {
|
||||
gist, err = db.GetInitGistByToken(token)
|
||||
}
|
||||
if err != nil {
|
||||
return ctx.PlainText(404, "Unknown or expired init token")
|
||||
}
|
||||
if gist.UserID != user.ID {
|
||||
log.Warn().Msg("Init token user mismatch from " + ctx.RealIP())
|
||||
return ctx.PlainText(404, "Unknown or expired init token")
|
||||
}
|
||||
|
||||
setGistContext(ctx, gist)
|
||||
return route.handler(ctx)
|
||||
}
|
||||
|
||||
// newInitToken returns a random, URL-safe token used to correlate the two HTTP
|
||||
// requests of a single push to /init.
|
||||
func newInitToken() (string, error) {
|
||||
u, err := uuid.NewRandom()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return strings.ReplaceAll(u.String(), "-", ""), nil
|
||||
}
|
||||
|
||||
func createGist(user *db.User, url string) (*db.Gist, error) {
|
||||
gist := new(db.Gist)
|
||||
gist.UserID = user.ID
|
||||
gist.User = *user
|
||||
uuidGist, err := uuid.NewRandom()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
gist.Uuid = strings.ReplaceAll(uuidGist.String(), "-", "")
|
||||
gist.Title = "gist:" + gist.Uuid
|
||||
|
||||
if url != "" {
|
||||
gist.URL = strings.TrimSuffix(url, ".git")
|
||||
gist.Title = strings.TrimSuffix(url, ".git")
|
||||
}
|
||||
|
||||
if err := gist.InitRepository(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := gist.Create(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return gist, nil
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
package git
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/thomiceli/opengist/internal/db"
|
||||
"github.com/thomiceli/opengist/internal/git"
|
||||
"github.com/thomiceli/opengist/internal/web/context"
|
||||
)
|
||||
|
||||
func uploadPack(ctx *context.Context) error {
|
||||
return pack(ctx, "upload-pack")
|
||||
}
|
||||
|
||||
func receivePack(ctx *context.Context) error {
|
||||
return pack(ctx, "receive-pack")
|
||||
}
|
||||
|
||||
func pack(ctx *context.Context, serviceType string) error {
|
||||
noCacheHeaders(ctx)
|
||||
defer ctx.Request().Body.Close()
|
||||
|
||||
if ctx.Request().Header.Get("Content-Type") != "application/x-git-"+serviceType+"-request" {
|
||||
return ctx.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 ctx.ErrorRes(500, "Cannot create gzip reader", err)
|
||||
}
|
||||
}
|
||||
|
||||
repositoryPath := ctx.GetData("repositoryPath").(string)
|
||||
gist := ctx.GetData("gist").(*db.Gist)
|
||||
|
||||
// Guard against a stale/desynced reference pointing at a repository that no
|
||||
// longer exists on disk (e.g. an init gist whose empty repo was cleaned up).
|
||||
// Without this, git would fail with an opaque "chdir ...: no such file or
|
||||
// directory" and 500 the push.
|
||||
if fi, err := os.Stat(repositoryPath); err != nil || !fi.IsDir() {
|
||||
return ctx.ErrorRes(404, "Repository not found", err)
|
||||
}
|
||||
|
||||
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
|
||||
cmd.Env = os.Environ()
|
||||
cmd.Env = append(cmd.Env, "OPENGIST_REPOSITORY_URL_INTERNAL="+git.RepositoryUrl(ctx, gist.User.Username, gist.Identifier()))
|
||||
cmd.Env = append(cmd.Env, "OPENGIST_REPOSITORY_ID="+strconv.Itoa(int(gist.ID)))
|
||||
|
||||
if err = cmd.Run(); err != nil {
|
||||
return ctx.ErrorRes(500, "Cannot run git "+serviceType+" ; "+stderr.String(), err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func infoRefs(ctx *context.Context) error {
|
||||
noCacheHeaders(ctx)
|
||||
var service string
|
||||
|
||||
gist := ctx.GetData("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 ctx.ErrorRes(500, "Cannot update server info", err)
|
||||
}
|
||||
return sendFile(ctx, "text/plain; charset=utf-8")
|
||||
}
|
||||
|
||||
refs, err := gist.RPC(service)
|
||||
if err != nil {
|
||||
return ctx.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 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)
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/gorilla/schema"
|
||||
@@ -34,7 +35,25 @@ func init() {
|
||||
type Server struct {
|
||||
server *server.Server
|
||||
SessionCookie string
|
||||
contextData echo.Map
|
||||
|
||||
// contextData captures the last request's context data for assertion
|
||||
// helpers. It is written by a middleware on every request, so concurrent
|
||||
// requests (e.g. parallel pushes) must serialize access via mu.
|
||||
mu sync.Mutex
|
||||
contextData echo.Map
|
||||
}
|
||||
|
||||
func (s *Server) setContextData(data echo.Map) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.contextData = data
|
||||
}
|
||||
|
||||
func (s *Server) ContextData(key string) (any, bool) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
v, ok := s.contextData[key]
|
||||
return v, ok
|
||||
}
|
||||
|
||||
func (s *Server) Request(t *testing.T, method, uri string, data interface{}, expectedCode int) *http.Response {
|
||||
@@ -118,15 +137,17 @@ func (s *Server) StartHttpServer(t *testing.T) string {
|
||||
|
||||
func (s *Server) User() *db.User {
|
||||
s.Request(nil, "GET", "/", nil, 0)
|
||||
if user, ok := s.contextData["userLogged"].(*db.User); ok {
|
||||
return user
|
||||
if v, ok := s.ContextData("userLogged"); ok {
|
||||
if user, ok := v.(*db.User); ok {
|
||||
return user
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) TestCtxData(t *testing.T, expected echo.Map) {
|
||||
for key, expectedValue := range expected {
|
||||
actualValue, exists := s.contextData[key]
|
||||
actualValue, exists := s.ContextData(key)
|
||||
require.True(t, exists, "Key %q not found in context data", key)
|
||||
require.Equal(t, expectedValue, actualValue, "Context data mismatch for key %q", key)
|
||||
}
|
||||
@@ -277,7 +298,7 @@ func Setup(t *testing.T) *Server {
|
||||
return func(c echo.Context) error {
|
||||
err := next(c)
|
||||
if data, ok := c.Request().Context().Value(context.DataKeyStr).(echo.Map); ok {
|
||||
s.contextData = data
|
||||
s.setContextData(data)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user