Author | Jakob Wakeling <[email protected]> |
Date | 2023-07-18 09:31:14 |
Commit | e08e053ce424bcb899dfb39b0028011762819ce9 |
Parent | ae5fc19f6ebb7260278962f9099a6f7aa4b6d577 |
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 {