Goit

Simple and lightweight Git web server
git clone http://git.omkov.net/Goit
Log | Tree | Refs | README | Download

AuthorJakob Wakeling <[email protected]>
Date2023-07-18 09:31:14
Commite08e053ce424bcb899dfb39b0028011762819ce9
Parentae5fc19f6ebb7260278962f9099a6f7aa4b6d577

Implement initial Git smart HTTP functionality

Diffstat

M main.go | 47 ++++++++++++++++++++++++-----------------------
A res/repo_log.html | 27 +++++++++++++++++++++++++++
M res/res.go | 3 +++
A src/git.go | 194 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
M src/goit.go | 3 +++
M src/repo.go | 47 +++++++++++++++++++++++++++++++++++++++++++++++
M src/util.go | 6 ++++++

7 files changed, 304 insertions, 23 deletions

diff --git a/main.go b/main.go
index 6110bde..2977ff6 100644
--- a/main.go
+++ b/main.go
@@ -22,31 +22,32 @@ func main() {
 		defer g.Close()
 	}
 
-	mx := mux.NewRouter()
-	mx.StrictSlash(true)
+	h := mux.NewRouter()
+	h.StrictSlash(true)
 
-	mx.Path("/").HandlerFunc(g.HandleIndex)
-	mx.Path("/user/login").Methods("GET", "POST").HandlerFunc(g.HandleUserLogin)
-	mx.Path("/user/logout").Methods("GET", "POST").HandlerFunc(g.HandleUserLogout)
-	// mx.Path("/user/settings").Methods("GET").HandlerFunc()
-	mx.Path("/repo/create").Methods("GET", "POST").HandlerFunc(g.HandleRepoCreate)
-	// mx.Path("/repo/delete").Methods("POST").HandlerFunc()
-	// mx.Path("/admin/settings").Methods("GET").HandlerFunc()
-	mx.Path("/admin/user").Methods("GET").HandlerFunc(g.HandleAdminUserIndex)
-	// mx.Path("/admin/repos").Methods("GET").HandlerFunc()
-	mx.Path("/admin/user/create").Methods("GET", "POST").HandlerFunc(g.HandleAdminUserCreate)
-	// mx.Path("/admin/user/edit").Methods("GET", "POST").HandlerFunc()
+	h.Path("/").HandlerFunc(g.HandleIndex)
+	h.Path("/user/login").Methods("GET", "POST").HandlerFunc(g.HandleUserLogin)
+	h.Path("/user/logout").Methods("GET", "POST").HandlerFunc(g.HandleUserLogout)
+	// h.Path("/user/settings").Methods("GET").HandlerFunc()
+	h.Path("/repo/create").Methods("GET", "POST").HandlerFunc(g.HandleRepoCreate)
+	// h.Path("/repo/delete").Methods("POST").HandlerFunc()
+	// h.Path("/admin/settings").Methods("GET").HandlerFunc()
+	h.Path("/admin/user").Methods("GET").HandlerFunc(g.HandleAdminUserIndex)
+	// h.Path("/admin/repos").Methods("GET").HandlerFunc()
+	h.Path("/admin/user/create").Methods("GET", "POST").HandlerFunc(g.HandleAdminUserCreate)
+	// h.Path("/admin/user/edit").Methods("GET", "POST").HandlerFunc()
 
-	rm := mx.Path("/{repo}/").Subrouter()
-	// rm.Path("/").Methods("GET").HandlerFunc()
-	// rm.Path("/log").Methods("GET").HandlerFunc()
-	// rm.Path("/tree").Methods("GET").HandlerFunc()
-	// rm.Path("/refs").Methods("GET").HandlerFunc()
+	h.Path("/{repo}/").Methods(http.MethodGet).HandlerFunc(g.HandleRepoLog)
+	h.Path("/{repo}/log").Methods(http.MethodGet).HandlerFunc(g.HandleRepoLog)
+	// h.Path("/{repo}/tree").Methods(http.MethodGet).HandlerFunc(g.HandleRepoTree)
+	// h.Path("/{repo}/refs").Methods(http.MethodGet).HandlerFunc(g.HandleRepoRefs)
+	h.Path("/{repo}/info/refs").Methods(http.MethodGet).HandlerFunc(goit.HandleInfoRefs)
+	h.Path("/{repo}/git-upload-pack").Methods(http.MethodPost).HandlerFunc(goit.HandleUploadPack)
+	h.Path("/{repo}/git-receive-pack").Methods(http.MethodPost).HandlerFunc(goit.HandleReceivePack)
 
-	mx.Path("/static/style.css").Methods(http.MethodGet).HandlerFunc(handleStyle)
+	h.Path("/static/style.css").Methods(http.MethodGet).HandlerFunc(handleStyle)
 
-	mx.PathPrefix("/").HandlerFunc(http.NotFound)
-	rm.PathPrefix("/").HandlerFunc(http.NotFound)
+	h.PathPrefix("/").HandlerFunc(http.NotFound)
 
 	/* Create a ticker to periodically cleanup expired sessions */
 	tick := time.NewTicker(1 * time.Hour)
@@ -56,14 +57,14 @@ func main() {
 		}
 	}()
 
-	if err := http.ListenAndServe(":8080", logHttp(mx)); err != nil {
+	if err := http.ListenAndServe(":8080", logHttp(h)); err != nil {
 		log.Fatalln("[HTTP]", err)
 	}
 }
 
 func logHttp(handler http.Handler) http.Handler {
 	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
-		log.Println("[HTTP]", r.RemoteAddr, r.Method, r.URL)
+		log.Println("[HTTP]", r.RemoteAddr, r.Method, r.URL.String())
 		handler.ServeHTTP(w, r)
 	})
 }
diff --git a/res/repo_log.html b/res/repo_log.html
new file mode 100644
index 0000000..4dbd5b1
--- /dev/null
+++ b/res/repo_log.html
@@ -0,0 +1,27 @@
+<!DOCTYPE html>
+<head>
+	<meta charset="UTF-8">
+	<title>Repositories</title>
+	<meta name="viewport" content="width=device-width, initial-scale=1.0">
+	<link rel="stylesheet" type="text/css" href="/static/style.css">
+</head>
+<body>
+	<table>
+		<thead>
+			<tr>
+				<td><b>Date</b></td>
+				<td><b>Message</b></td>
+				<td><b>Author</b></td>
+			</tr>
+		</thead>
+		<tbody>
+		{{range .Commits}}
+			<tr>
+				<td>{{.Date}}</a></td>
+				<td>{{.Message}}</td>
+				<td>{{.Author}}</td>
+			</tr>
+		{{end}}
+		</tbody>
+	</table>
+</body>
diff --git a/res/res.go b/res/res.go
index 8819dec..3acb4e9 100644
--- a/res/res.go
+++ b/res/res.go
@@ -11,6 +11,9 @@ var UserLogin string
 //go:embed repo_create.html
 var RepoCreate string
 
+//go:embed repo_log.html
+var RepoLog string
+
 //go:embed user_create.html
 var UserCreate string
 
diff --git a/src/git.go b/src/git.go
new file mode 100644
index 0000000..a5c6373
--- /dev/null
+++ b/src/git.go
@@ -0,0 +1,194 @@
+// git.go
+// Copyright (C) 2023, Jakob Wakeling
+// All rights reserved.
+
+package goit
+
+import (
+	"bytes"
+	"compress/gzip"
+	"io"
+	"log"
+	"net/http"
+	"os"
+	"os/exec"
+	"strconv"
+	"strings"
+
+	"github.com/gorilla/mux"
+)
+
+type GitCommand struct {
+	prog string
+	args []string
+	dir  string
+	env  []string
+}
+
+func HandleInfoRefs(w http.ResponseWriter, r *http.Request) {
+	service := r.FormValue("service")
+	repo := httpBase(w, r, service)
+	if repo == nil {
+		return
+	}
+
+	c := NewCommand(strings.TrimPrefix(service, "git-"), "--stateless-rpc", "--advertise-refs", ".")
+	c.AddEnv(os.Environ()...)
+	c.AddEnv("GIT_PROTOCOL=version=2")
+	c.dir = "./" + repo.Name + ".git"
+
+	refs, _, err := c.Run(nil, nil)
+	if err != nil {
+		log.Println("[Git]", err.Error())
+		http.Error(w, "500 Internal Server Error", http.StatusInternalServerError)
+		return
+	}
+
+	w.Header().Add("Content-Type", "application/x-"+service+"-advertisement")
+	w.Header().Set("Expires", "Thu, 01 Jan 1970 00:00:00 GMT")
+	w.Header().Set("Pragma", "no-cache")
+	w.Header().Set("Cache-Control", "no-cache, max-age=0, must-revalidate")
+	w.WriteHeader(http.StatusOK)
+
+	w.Write(pktLine("# service=" + service + "\n"))
+	w.Write(pktFlush())
+	w.Write(refs)
+}
+
+func HandleUploadPack(w http.ResponseWriter, r *http.Request) {
+	repo := httpBase(w, r, "git-upload-pack")
+	if repo == nil {
+		return
+	}
+
+	serviceRPC(w, r, "git-upload-pack", repo)
+}
+
+func HandleReceivePack(w http.ResponseWriter, r *http.Request) {
+	repo := httpBase(w, r, "git-receive-pack")
+	if repo == nil {
+		return
+	}
+
+	serviceRPC(w, r, "git-receive-pack", repo)
+}
+
+func httpBase(w http.ResponseWriter, r *http.Request, service string) *Repo {
+	reponame := mux.Vars(r)["repo"]
+
+	var isPull bool
+	switch service {
+	case "git-upload-pack":
+		isPull = true
+	case "git-receive-pack":
+		isPull = false
+	default:
+		http.Error(w, "404 Not Found", http.StatusNotFound)
+		return nil
+	}
+
+	if r.Header.Get("Git-Protocol") != "version=2" {
+		http.Error(w, "403 Forbidden", http.StatusForbidden)
+		return nil
+	}
+
+	repo, err := GetRepoByName(db, reponame)
+	if err != nil {
+		http.Error(w, "500 Internal Server Error", http.StatusInternalServerError)
+		return nil
+	} else if repo == nil {
+		http.Error(w, "404 Not Found", http.StatusNotFound)
+		return nil
+	}
+
+	/* Require authentication other than for public pull */
+	if repo.IsPrivate || !isPull {
+		/* TODO authentcate */
+		http.Error(w, "401 Unauthorized", http.StatusUnauthorized)
+		return nil
+	}
+
+	return repo
+}
+
+func serviceRPC(w http.ResponseWriter, r *http.Request, service string, repo *Repo) {
+	defer func() {
+		if err := r.Body.Close(); err != nil {
+			log.Println("[GitRPC]", err.Error())
+		}
+	}()
+
+	if r.Header.Get("Content-Type") != "application/x-"+service+"-request" {
+		log.Println("[GitRPC]", "Content-Type mismatch")
+		http.Error(w, "401 Unauthorized", http.StatusUnauthorized)
+		return
+	}
+
+	body := r.Body
+	if r.Header.Get("Content-Encoding") == "gzip" {
+		if b, err := gzip.NewReader(r.Body); err != nil {
+			log.Println("[GitRPC]", err.Error())
+			http.Error(w, "500 Internal Server Error", http.StatusInternalServerError)
+			return
+		} else {
+			body = b
+		}
+	}
+
+	c := NewCommand(strings.TrimPrefix(service, "git-"), "--stateless-rpc", ".")
+	c.AddEnv(os.Environ()...)
+	c.AddEnv("GIT_PROTOCOL=version=2")
+	c.dir = "./" + repo.Name + ".git"
+
+	w.Header().Add("Content-Type", "application/x-"+service+"-result")
+	w.WriteHeader(http.StatusOK)
+
+	if _, _, err := c.Run(body, w); err != nil {
+		log.Println("[GitRPC]", err.Error())
+		http.Error(w, "500 Internal Server Error", http.StatusInternalServerError)
+		return
+	}
+}
+
+func pktLine(str string) []byte {
+	PanicIf(len(str) > 65516, "pktLine: Payload exceeds maximum length")
+	s := strconv.FormatUint(uint64(len(str)+4), 16)
+	s = strings.Repeat("0", 4-len(s)%4) + s
+	return []byte(s + str)
+}
+
+func pktFlush() []byte { return []byte("0000") }
+
+func NewCommand(args ...string) *GitCommand {
+	return &GitCommand{prog: "git", args: args}
+}
+
+func (C *GitCommand) AddArgs(args ...string) {
+	C.args = append(C.args, args...)
+}
+
+func (C *GitCommand) AddEnv(env ...string) {
+	C.env = append(C.env, env...)
+}
+
+func (C *GitCommand) Run(in io.Reader, out io.Writer) ([]byte, []byte, error) {
+	c := exec.Command(C.prog, C.args...)
+	c.Dir = C.dir
+	c.Env = C.env
+	c.Stdin = in
+
+	stdout := &bytes.Buffer{}
+	stderr := &bytes.Buffer{}
+	c.Stdout = stdout
+	c.Stderr = os.Stderr
+
+	if out != nil {
+		c.Stdout = out
+	}
+
+	if err := c.Run(); err != nil {
+		return nil, stderr.Bytes(), err
+	}
+
+	return stdout.Bytes(), stderr.Bytes(), nil
+}
diff --git a/src/goit.go b/src/goit.go
index 17c535a..7c40cdd 100644
--- a/src/goit.go
+++ b/src/goit.go
@@ -12,6 +12,8 @@ import (
 	_ "github.com/mattn/go-sqlite3"
 )
 
+var db *sql.DB
+
 type Goit struct {
 	db *sql.DB
 }
@@ -23,6 +25,7 @@ func InitGoit() (g *Goit, err error) {
 	if g.db, err = sql.Open("sqlite3", "./goit.db"); err != nil {
 		return nil, fmt.Errorf("[SQL:open] %w", err)
 	}
+	db = g.db
 
 	if _, err = g.db.Exec(
 		`CREATE TABLE IF NOT EXISTS users (
diff --git a/src/repo.go b/src/repo.go
index 04db773..bfbf8ce 100644
--- a/src/repo.go
+++ b/src/repo.go
@@ -11,8 +11,12 @@ import (
 	"log"
 	"net/http"
 	"strings"
+	"time"
 
 	"github.com/Jamozed/Goit/res"
+	"github.com/go-git/go-git/v5"
+	"github.com/go-git/go-git/v5/plumbing/object"
+	"github.com/gorilla/mux"
 )
 
 type Repo struct {
@@ -28,11 +32,13 @@ type Repo struct {
 var (
 	repoIndex  *template.Template
 	repoCreate *template.Template
+	repoLog    *template.Template
 )
 
 func init() {
 	repoIndex = template.Must(template.New("repo_index").Parse(res.RepoIndex))
 	repoCreate = template.Must(template.New("repo_create").Parse(res.RepoCreate))
+	repoLog = template.Must(template.New("repo_log").Parse(res.RepoLog))
 }
 
 func (g *Goit) HandleIndex(w http.ResponseWriter, r *http.Request) {
@@ -103,6 +109,47 @@ func (g *Goit) HandleRepoCreate(w http.ResponseWriter, r *http.Request) {
 	}
 }
 
+func (g *Goit) HandleRepoLog(w http.ResponseWriter, r *http.Request) {
+	reponame := mux.Vars(r)["repo"]
+
+	type row struct{ Date, Message, Author string }
+	commits := []row{}
+
+	if gr, err := git.PlainOpen("./" + reponame + ".git"); err != nil {
+		log.Println("[Repo:Open]", err.Error())
+		http.Error(w, "500 internal server error", http.StatusInternalServerError)
+	} else if ref, err := gr.Head(); err != nil {
+		log.Println("[Repo:Head]", err.Error())
+		http.Error(w, "500 internal server error", http.StatusInternalServerError)
+	} else if iter, err := gr.Log(&git.LogOptions{From: ref.Hash()}); err != nil {
+		log.Println("[Repo:Log]", err.Error())
+		http.Error(w, "500 internal server error", http.StatusInternalServerError)
+	} else if err := iter.ForEach(func(c *object.Commit) error {
+		commits = append(commits, row{c.Author.When.UTC().Format(time.RFC3339), c.Message, c.Author.Name})
+		return nil
+	}); err != nil {
+		log.Println("[Repo:Log]", err.Error())
+		http.Error(w, "500 internal server error", http.StatusInternalServerError)
+	}
+
+	repoLog.Execute(w, struct{ Commits []row }{commits})
+}
+
+func GetRepoByName(db *sql.DB, name string) (*Repo, error) {
+	r := &Repo{}
+
+	err := db.QueryRow(
+		"SELECT id, owner_id, name, name_lower, description, default_branch, is_private FROM repos WHERE name = ?", name,
+	).Scan(&r.Id, &r.OwnerId, &r.Name, &r.NameLower, &r.Description, &r.DefaultBranch, &r.IsPrivate)
+	if errors.Is(err, sql.ErrNoRows) {
+		return nil, nil
+	} else if err != nil {
+		return nil, err
+	}
+
+	return r, nil
+}
+
 func RepoExists(db *sql.DB, name string) (bool, error) {
 	if err := db.QueryRow(
 		"SELECT name FROM repos WHERE name_lower = ?", strings.ToLower(name),
diff --git a/src/util.go b/src/util.go
index 40255f1..a273c72 100644
--- a/src/util.go
+++ b/src/util.go
@@ -24,6 +24,12 @@ func SliceContains[T comparable](s []T, e T) bool {
 	return false
 }
 
+func PanicIf(cond bool, v any) {
+	if cond {
+		panic(v)
+	}
+}
+
 /* Return the named cookie or nil if not found. */
 func Cookie(r *http.Request, name string) *http.Cookie {
 	if c, err := r.Cookie(name); err != nil {