@ main | history
package handlers
import (
"bufio"
"bytes"
"compress/gzip"
"context"
"embed"
"errors"
"fmt"
"html/template"
"io"
"net/http"
"os/exec"
"path"
"regexp"
"slices"
"strconv"
"strings"
"time"
"github.com/dustin/go-humanize"
"github.com/go-git/go-git/v6"
"github.com/go-git/go-git/v6/plumbing"
"github.com/go-git/go-git/v6/plumbing/filemode"
"github.com/go-git/go-git/v6/plumbing/object"
"github.com/go-git/go-git/v6/plumbing/transport"
"nmyk.io/bidet/internal/core"
)
type Config struct {
Port int
Hostname string
Greeting string
Dir string
CSSLocation string
}
func NewServer(c *Config) Server {
css := getCSS(c.CSSLocation)
if css == nil {
css = defaultCSS
}
if c.Hostname == "" {
c.Hostname = "repos"
}
entries, err := Templates.ReadDir("templates")
if err != nil {
panic(err)
}
templates := make(map[string]*template.Template)
for _, t := range entries {
if t.Name() == "base.tmpl" {
continue
}
tmpl := template.Must(template.New("").
Funcs(template.FuncMap{
"css": func() template.CSS {
return template.CSS(css)
},
"humanize": humanize.Time,
"rfc3339": func(t time.Time) string {
return t.Format(time.RFC3339)
},
"sub": func(a int, b int) int {
return a - b
},
}).
ParseFS(
Templates,
path.Join("templates", "base.tmpl"),
path.Join("templates", t.Name()),
),
)
key := strings.TrimSuffix(t.Name(), ".tmpl")
templates[key] = tmpl
}
return Server{Config: c, templates: templates}
}
type Server struct {
*Config
templates map[string]*template.Template
}
func (s Server) Routes() *http.ServeMux {
mux := http.NewServeMux()
mux.HandleFunc("GET /", s.ListRepos)
mux.HandleFunc("GET /{name}", s.Repo)
mux.HandleFunc("POST /{name}", s.Repo)
mux.HandleFunc("GET /{name}/{path...}", s.Repo)
mux.HandleFunc("POST /{name}/{path...}", s.Repo)
mux.HandleFunc("GET /{name}/refs", s.Refs)
mux.HandleFunc("GET /{name}/refs/{type}", s.Refs)
mux.HandleFunc("GET /{name}/tree/{path...}", s.Tree)
mux.HandleFunc("GET /{name}/blob/{path...}", s.Blob)
mux.HandleFunc("GET /{name}/commits/{path...}", s.Commits)
mux.HandleFunc("GET /{name}/commit/{hash}", s.Commit)
return mux
}
//go:embed templates/*.tmpl
var Templates embed.FS
//go:embed static/style.css
var defaultCSS []byte
func (s Server) error(w http.ResponseWriter, err error) {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
func (s Server) Serve(w http.ResponseWriter, tmplName string, data any) {
var buf bytes.Buffer
if err := s.templates[tmplName].ExecuteTemplate(&buf, tmplName, data); err != nil {
s.error(w, err)
return
}
w.WriteHeader(http.StatusOK)
buf.WriteTo(w)
}
type IndexData struct {
Title string
Header string
Repos []RepoMeta
}
type RepoMeta struct {
Name string
Updated time.Time
}
func (s Server) ListRepos(w http.ResponseWriter, _ *http.Request) {
repos, err := s.List()
if err != nil {
s.error(w, err)
return
}
var repoList []RepoMeta
for _, repo := range repos {
ref, err := repo.Head()
if err != nil {
s.error(w, err)
return
}
commit, err := repo.CommitObject(ref.Hash())
if err != nil {
s.error(w, err)
return
}
lastUpdated := commit.Committer.When
meta := RepoMeta{
repo.Name,
lastUpdated,
}
repoList = append(repoList, meta)
}
slices.SortFunc(repoList, func(a RepoMeta, b RepoMeta) int {
// order by updateTime descending
return -1 * a.Updated.Compare(b.Updated)
})
data := IndexData{
Title: s.Hostname,
Header: s.Greeting,
Repos: repoList,
}
s.Serve(w, "repos", data)
}
type BranchMeta struct {
Name string
}
type TagMeta struct {
Name string
Annotation string
When time.Time
}
type RefsData struct {
Repo string
Type string
Branches []BranchMeta
Tags []TagMeta
}
func (s Server) Refs(w http.ResponseWriter, r *http.Request) {
repoName := r.PathValue("name")
repo, err := s.Open(repoName)
if err != nil {
s.error(w, err)
return
}
branchIter, err := repo.Branches()
if err != nil {
s.error(w, err)
return
}
var branches []BranchMeta
err = branchIter.ForEach(func(ref *plumbing.Reference) error {
branches = append(branches, BranchMeta{ref.Name().Short()})
return nil
})
if err != nil {
s.error(w, err)
return
}
tagIter, err := repo.Tags()
if err != nil {
s.error(w, err)
return
}
var tags []TagMeta
err = tagIter.ForEach(func(ref *plumbing.Reference) error {
var t TagMeta
tagObj, err := repo.TagObject(ref.Hash())
switch err {
case nil:
t.Annotation = tagObj.Message
t.When = tagObj.Tagger.When
case plumbing.ErrObjectNotFound:
commit, err := repo.CommitObject(ref.Hash())
if err != nil {
return err
}
t.When = commit.Committer.When
default:
return err
}
t.Name = ref.Name().Short()
tags = append(tags, t)
return nil
})
if err != nil {
s.error(w, err)
return
}
slices.SortFunc(tags, func(a TagMeta, b TagMeta) int {
return -1 * a.When.Compare(b.When)
})
refType := r.PathValue("type")
if !slices.Contains([]string{"branches", "tags"}, refType) {
refType = "branches"
}
data := RefsData{
Repo: repoName,
Type: refType,
Branches: branches,
Tags: tags,
}
s.Serve(w, "refs", data)
}
type CommitMeta struct {
Hash string
Author string
When time.Time
Message string
}
type CommitsData struct {
Repo string
RefLabel string
Commits []CommitMeta
Path string
Crumbs []crumb
PrevPage *Cursor
NextPage *Cursor
}
const commitsPageSize = 35
var page = regexp.MustCompile(`^([0-9a-fA-F]+) ([0-9]+)$`)
type Cursor struct {
AnchorHash string
Offset int
}
// parseCursor parses a cursor string like "<hash>+<offset>"
func parseCursor(s string) *Cursor {
m := page.FindStringSubmatch(s)
if m == nil {
return nil
}
hash := m[1]
offset, err := strconv.Atoi(m[2])
if err != nil {
return nil
}
return &Cursor{
AnchorHash: hash,
Offset: offset,
}
}
func (s Server) Commits(w http.ResponseWriter, r *http.Request) {
repoName := r.PathValue("name")
repo, err := s.Open(repoName)
if err != nil {
s.error(w, err)
return
}
commit, filePath, err := repo.ParseRoute(r.PathValue("path"))
if err != nil {
http.NotFound(w, r)
return
}
beforeParam := r.URL.Query().Get("before")
afterParam := r.URL.Query().Get("after")
if beforeParam != "" && afterParam != "" {
http.NotFound(w, r)
return
}
beforeCursor := parseCursor(beforeParam)
afterCursor := parseCursor(afterParam)
isBefore := beforeCursor != nil
isAfter := afterCursor != nil
var cursor *Cursor
if isBefore && beforeCursor.Offset < commitsPageSize {
http.NotFound(w, r)
return
} else if isBefore {
cursor = beforeCursor
} else if isAfter {
cursor = afterCursor
}
args := []string{
"log",
"--pretty=format:%H",
commit.Hash.String(),
}
if filePath != "" {
args = append(args, "--", filePath)
}
ctx, cancel := context.WithCancel(r.Context())
defer cancel()
cmd := exec.CommandContext(ctx, "git", args...)
repoPath, err := s.repoPath(repoName)
if err != nil {
http.NotFound(w, r)
return
}
cmd.Dir = repoPath
stdout, err := cmd.StdoutPipe()
if err != nil {
s.error(w, err)
return
}
if err := cmd.Start(); err != nil {
s.error(w, err)
return
}
scanner := bufio.NewScanner(stdout)
var commits []CommitMeta
var stopEarly bool
var start, offset, end int
if isBefore {
start = cursor.Offset - commitsPageSize
end = cursor.Offset - 1
} else if isAfter {
start = cursor.Offset + 1
end = cursor.Offset + commitsPageSize
} else {
start = 0
end = commitsPageSize - 1
}
for scanner.Scan() {
if offset >= end {
stopEarly = true
cancel()
break
}
hashStr := scanner.Text()
c, err := repo.CommitObject(plumbing.NewHash(hashStr))
if err != nil {
s.error(w, err)
return
}
if offset >= start && offset < end {
commits = append(commits, CommitMeta{
Hash: c.Hash.String(),
Author: fmtAuthor(c.Author.Name, c.Author.Email),
When: c.Author.When,
Message: c.Message,
})
}
offset++
}
if err := scanner.Err(); err != nil && !errors.Is(err, context.Canceled) {
s.error(w, err)
return
}
_ = cmd.Wait()
var next, prev *Cursor
if len(commits) > 0 {
if isBefore {
prev = &Cursor{
AnchorHash: cursor.AnchorHash,
Offset: cursor.Offset - commitsPageSize,
}
next = &Cursor{
AnchorHash: cursor.AnchorHash,
Offset: cursor.Offset - 1,
}
} else if isAfter {
prev = &Cursor{
AnchorHash: cursor.AnchorHash,
Offset: cursor.Offset + 1,
}
next = &Cursor{
AnchorHash: cursor.AnchorHash,
Offset: cursor.Offset + commitsPageSize,
}
} else {
prev = nil
next = &Cursor{
AnchorHash: commit.Hash.String(),
Offset: commitsPageSize - 1,
}
}
}
if prev != nil && prev.Offset < commitsPageSize {
prev = nil
}
if !stopEarly {
next = nil
}
data := CommitsData{
Repo: repoName,
RefLabel: commit.RefLabel(),
Commits: commits,
Path: filePath,
Crumbs: breadcrumbs("commits", repoName, commit.RefLabel(), filePath),
PrevPage: prev,
NextPage: next,
}
s.Serve(w, "commits", data)
}
type CommitData struct {
CommitMeta
Repo string
Parents []string
Patch string
}
func (s Server) Commit(w http.ResponseWriter, r *http.Request) {
repoName := r.PathValue("name")
repo, err := s.Open(repoName)
if err != nil {
http.NotFound(w, r)
return
}
hashStr := r.PathValue("hash")
hash := plumbing.NewHash(hashStr)
commit, err := repo.CommitObject(hash)
if err != nil {
s.error(w, err)
return
}
var (
parents []string
patch string
i int
)
iter := commit.Parents()
err = iter.ForEach(func(c *object.Commit) error {
if i == 0 {
p, err := c.Patch(commit)
if err != nil {
return err
}
patch = p.String()
}
parents = append(parents, string(c.Hash.String()))
return nil
})
if err != nil {
s.error(w, err)
return
}
data := CommitData{
CommitMeta: CommitMeta{
Hash: commit.Hash.String(),
Author: fmtAuthor(commit.Author.Name, commit.Author.Email),
When: commit.Author.When,
Message: commit.Message,
},
Repo: repoName,
Parents: parents,
Patch: patch,
}
s.Serve(w, "commit", data)
}
type BlobData struct {
Repo string
RefLabel string
Path string
Crumbs []crumb
Content string
}
func (s Server) Blob(w http.ResponseWriter, r *http.Request) {
repoName := r.PathValue("name")
repo, err := s.Open(repoName)
if err != nil {
http.NotFound(w, r)
return
}
commit, filePath, err := repo.ParseRoute(r.PathValue("path"))
if err != nil {
http.NotFound(w, nil)
return
}
tree, err := commit.Tree()
if err != nil {
s.error(w, err)
return
}
entry, err := tree.FindEntry(filePath)
if err != nil {
http.NotFound(w, nil)
return
}
content, err := repo.Load(entry)
if err != nil {
s.error(w, err)
return
}
data := BlobData{
Repo: repoName,
RefLabel: commit.RefLabel(),
Path: filePath,
Crumbs: breadcrumbs("tree", repoName, commit.RefLabel(), filePath),
Content: content,
}
s.Serve(w, "blob", data)
}
type TreeData struct {
Hostname string
Repo string
Ref string
RefLabel string
Commit string
HeadRef string
Path string
Crumbs []crumb
Entries []EntryMeta
ReadmeName string
Readme string
}
type EntryMeta struct {
Name string
Path string
IsDir bool
}
func (s Server) Tree(w http.ResponseWriter, r *http.Request) {
repoName := r.PathValue("name")
repo, err := s.Open(repoName)
if err != nil {
http.NotFound(w, r)
return
}
urlPath := r.PathValue("path")
commit, treePath, err := repo.ParseRoute(urlPath)
if err != nil {
http.NotFound(w, nil)
return
}
head, err := repo.Head()
if err != nil {
s.error(w, err)
return
}
headRef := head.Name().Short()
if commit.RefName == headRef && treePath == "" {
s.RepoTree(w, r)
return
}
rootTree, err := commit.Tree()
if err != nil {
s.error(w, err)
return
}
var tree *object.Tree
if treePath != "" {
treeEntry, err := rootTree.FindEntry(treePath)
if err != nil {
http.NotFound(w, r)
return
}
tree, err = repo.TreeObject(treeEntry.Hash)
if err != nil {
s.error(w, err)
return
}
} else {
tree = rootTree
}
var entries []EntryMeta
if treePath != "" {
entries = []EntryMeta{
{
Name: "..",
Path: path.Dir(treePath),
IsDir: true,
},
}
}
var readme, readmeName string
for _, e := range tree.Entries {
fullPath := path.Join(treePath, e.Name)
em := EntryMeta{
Name: e.Name,
Path: fullPath,
IsDir: e.Mode == filemode.Dir,
}
if strings.HasPrefix(strings.ToLower(e.Name), "readme") {
readmeName = e.Name
var err error
readme, err = repo.Load(&e)
if err != nil {
s.error(w, err)
return
}
}
entries = append(entries, em)
}
data := TreeData{
Hostname: s.Hostname,
Repo: repoName,
RefLabel: commit.RefLabel(),
HeadRef: headRef,
Path: treePath,
Crumbs: breadcrumbs("tree", repoName, commit.RefLabel(), treePath),
Entries: entries,
ReadmeName: readmeName,
Readme: readme,
}
s.Serve(w, "tree", data)
}
func (s Server) Repo(w http.ResponseWriter, r *http.Request) {
p := r.PathValue("path")
q := r.URL.RawQuery
m := r.Method
if q == "service=git-receive-pack" {
w.WriteHeader(http.StatusMethodNotAllowed)
w.Write([]byte("bidet is for output, not input 💦"))
return
}
switch {
case p == "info/refs" && q == "service=git-upload-pack" && m == http.MethodGet:
s.InfoRefs(w, r)
case p == "git-upload-pack" && m == http.MethodPost:
s.UploadPack(w, r)
case m == http.MethodGet:
s.RepoTree(w, r)
default:
http.NotFound(w, r)
}
}
func (s Server) InfoRefs(w http.ResponseWriter, r *http.Request) {
repoDir := r.PathValue("name")
repoPath, err := s.repoPath(repoDir)
if err != nil {
http.NotFound(w, r)
return
}
// repoPath already ends in .git
repoLocation, cut := strings.CutSuffix(repoPath, dotGit)
if !cut {
http.NotFound(w, r)
return
}
repo, err := git.PlainOpen(repoLocation)
if err != nil {
http.NotFound(w, r)
return
}
storage := repo.Storer
service := transport.Service(r.URL.Query().Get("service"))
w.Header().Add("Content-Type", fmt.Sprintf("application/x-%s-advertisement", service))
w.Header().Add("Cache-Control", "no-cache")
w.WriteHeader(http.StatusOK)
if err := transport.AdvertiseReferences(r.Context(), storage, w, service, true); err != nil {
s.error(w, err)
return
}
}
func (s Server) UploadPack(w http.ResponseWriter, r *http.Request) {
repoDir := r.PathValue("name")
repoPath, err := s.repoPath(repoDir)
if err != nil {
http.NotFound(w, r)
return
}
// repoPath already ends in .git
repoLocation, cut := strings.CutSuffix(repoPath, dotGit)
if !cut {
http.NotFound(w, r)
return
}
var bodyReader io.ReadCloser
switch strings.ToLower(r.Header.Get("Content-Encoding")) {
case "gzip":
var err error
bodyReader, err = gzip.NewReader(r.Body)
if err != nil {
s.error(w, err)
return
}
defer bodyReader.Close()
default:
bodyReader = r.Body
}
w.Header().Add("Content-Type", fmt.Sprintf("application/x-%s-result", r.PathValue("path")))
w.Header().Add("Cache-Control", "no-cache")
w.WriteHeader(http.StatusOK)
if err := core.UploadPack(repoLocation, bodyReader, w); err != nil {
s.error(w, err)
return
}
}
func (s Server) RepoTree(w http.ResponseWriter, r *http.Request) {
repoName := r.PathValue("name")
repo, err := s.Open(repoName)
if err != nil {
http.NotFound(w, r)
return
}
ref, err := repo.Head()
if err != nil {
s.error(w, err)
return
}
commit, err := repo.CommitObject(ref.Hash())
if err != nil {
s.error(w, err)
return
}
tree, err := commit.Tree()
if err != nil {
s.error(w, err)
return
}
refName := ref.Name().Short()
var entries []EntryMeta
var readme, readmeName string
for _, e := range tree.Entries {
em := EntryMeta{
Name: e.Name,
Path: e.Name,
IsDir: e.Mode == filemode.Dir,
}
if strings.HasPrefix(strings.ToLower(e.Name), "readme") {
readmeName = e.Name
var err error
readme, err = repo.Load(&e)
if err != nil {
s.error(w, err)
return
}
}
entries = append(entries, em)
}
data := TreeData{
Hostname: s.Hostname,
Repo: repoName,
Ref: refName,
Entries: entries,
ReadmeName: readmeName,
Readme: readme,
}
s.Serve(w, "repo", data)
}