bidet / main.go

@ e4c00fc2a01331bb0b70d8f881ce63a4d7dbccbf | history


package main

import (
	"bytes"
	"embed"
	"html/template"
	"log"
	"net/http"
	"os"
	"path"
	"slices"
	"strings"

	git "github.com/go-git/go-git/v5"
	"github.com/go-git/go-git/v5/plumbing"
	"github.com/go-git/go-git/v5/plumbing/filemode"
	"github.com/go-git/go-git/v5/plumbing/object"
)

//go:embed templates/*.tmpl
var templates embed.FS

const addr = ":8080"
const DOT_GIT = ".git"

func main() {
	log.Printf("Serving on %s\n", addr)
	log.Fatal(http.ListenAndServe(addr, router()))
}

type Repo struct {
	*git.Repository
	Name string
}

func router() *http.ServeMux {
	mux := http.NewServeMux()
	mux.HandleFunc("GET /", listRepos)
	mux.HandleFunc("GET /{name}", repoIndex)
	mux.HandleFunc("GET /{name}/refs", refs)
	mux.HandleFunc("GET /{name}/refs/{type}", refs)
	mux.HandleFunc("GET /{name}/tree/{ref}", repoTree)
	mux.HandleFunc("GET /{name}/tree/{ref}/{path...}", repoTree)
	mux.HandleFunc("GET /{name}/blob/{ref}/{path...}", blob)
	return mux
}

func serve(w http.ResponseWriter, name string, data any) {
	tmpl := template.Must(template.ParseFS(
		templates,
		"templates/base.tmpl",
		"templates/"+name+".tmpl",
	))
	var buf bytes.Buffer
	if err := tmpl.ExecuteTemplate(&buf, name, data); err != nil {
		http.Error(w, err.Error(), 500)
		return
	}
	w.WriteHeader(http.StatusOK)
	buf.WriteTo(w)
}

func listRepos(w http.ResponseWriter, _ *http.Request) {
	entries, err := os.ReadDir(".")
	if err != nil {
		http.Error(w, err.Error(), 500)
		return
	}
	var repos []string
	isBareRepo := func(e os.DirEntry) bool {
		return (e.IsDir() &&
			strings.HasSuffix(e.Name(), DOT_GIT) &&
			e.Name() != DOT_GIT)
	}
	for _, e := range entries {
		if isBareRepo(e) {
			repos = append(repos, strings.TrimSuffix(e.Name(), DOT_GIT))
		}
	}
	serve(w, "repos", repos)
}

func load(repo *git.Repository, entry *object.TreeEntry) (string, error) {
	file, err := repo.BlobObject(entry.Hash)
	if err != nil {
		return "", err
	}
	reader, err := file.Reader()
	if err != nil {
		return "", err
	}
	defer reader.Close()

	content := make([]byte, file.Size)
	reader.Read(content)
	return string(content), nil
}

type BranchMeta struct {
	Name string
}

type TagMeta struct {
	Name string
}

type RefsData struct {
	Repo     string
	Type     string
	Branches []BranchMeta
	Tags     []TagMeta
}

func refs(w http.ResponseWriter, r *http.Request) {
	repoName := r.PathValue("name")
	if _, err := os.Stat(repoName + DOT_GIT); os.IsNotExist(err) {
		http.NotFound(w, r)
		return
	}
	repo, err := git.PlainOpen(repoName + DOT_GIT)
	if err != nil {
		http.Error(w, err.Error(), 500)
		return
	}
	branchIter, err := repo.Branches()
	if err != nil {
		http.Error(w, err.Error(), 500)
	}
	var branches []BranchMeta
	err = branchIter.ForEach(func(ref *plumbing.Reference) error {
		branches = append(branches, BranchMeta{ref.Name().Short()})
		return nil
	})
	if err != nil {
		http.Error(w, err.Error(), 500)
	}
	tagIter, err := repo.Tags()
	if err != nil {
		http.Error(w, err.Error(), 500)
	}
	var tags []TagMeta
	err = tagIter.ForEach(func(ref *plumbing.Reference) error {
		tags = append(tags, TagMeta{ref.Name().Short()})
		return nil
	})
	if err != nil {
		http.Error(w, err.Error(), 500)
	}
	refType := r.PathValue("type")
	if !slices.Contains([]string{"branches", "tags"}, refType) {
		refType = "branches"
	}
	data := RefsData{
		Repo:     repoName,
		Type:     refType,
		Branches: branches,
		Tags:     tags,
	}
	serve(w, "refs", data)
}

func blob(w http.ResponseWriter, r *http.Request) {
	repoName := r.PathValue("name")
	if _, err := os.Stat(repoName + DOT_GIT); os.IsNotExist(err) {
		http.NotFound(w, r)
		return
	}
	repo, err := git.PlainOpen(repoName + DOT_GIT)
	if err != nil {
		http.Error(w, err.Error(), 500)
		return
	}
	refName := r.PathValue("ref")
	ref, err := resolve(repo, refName)
	if err != nil {
		http.NotFound(w, r)
		return
	}
	commit, err := repo.CommitObject(ref.Hash())
	if err != nil {
		http.Error(w, err.Error(), 500)
		return
	}
	tree, err := commit.Tree()
	if err != nil {
		http.Error(w, err.Error(), 500)
		return
	}
	filePath := r.PathValue("path")
	entry, err := tree.FindEntry(filePath)
	if err != nil {
		http.NotFound(w, nil)
		return
	}
	content, err := load(repo, entry)
	if err != nil {
		http.Error(w, err.Error(), 500)
	}
	data := struct {
		Repo    string
		Ref     string
		Base    string
		Crumbs  []Crumb
		Content string
	}{
		Repo:    repoName,
		Ref:     refName,
		Base:    filePath,
		Crumbs:  breadcrumbs(repoName, refName, filePath),
		Content: content,
	}
	serve(w, "blob", data)
}

type TreeData struct {
	Repo       string
	Ref        string
	HeadRef    string
	Base       string
	Crumbs     []Crumb
	Entries    []EntryMeta
	ReadmeName string
	Readme     string
}

type EntryMeta struct {
	Name  string
	Path  string
	IsDir bool
}

func resolve(repo *git.Repository, refName string) (*plumbing.Reference, error) {
	branchRef := plumbing.ReferenceName(path.Join("refs", "heads", refName))
	ref, err := repo.Reference(branchRef, true)
	if err == nil {
		return ref, nil
	}
	tagRef := plumbing.ReferenceName(path.Join("refs", "tags", refName))
	ref, err = repo.Reference(tagRef, true)
	if err != nil {
		return nil, err
	}
	// Resolve annotated tag to commit hash
	obj, err := repo.Object(plumbing.AnyObject, ref.Hash())
	if err != nil {
		return nil, err
	}
	if tagObj, ok := obj.(*object.Tag); ok {
		ref = plumbing.NewHashReference(ref.Name(), tagObj.Target)
	}
	return ref, nil
}

func repoTree(w http.ResponseWriter, r *http.Request) {
	repoName := r.PathValue("name")
	if _, err := os.Stat(repoName + DOT_GIT); os.IsNotExist(err) {
		http.NotFound(w, r)
		return
	}
	repo, err := git.PlainOpen(repoName + DOT_GIT)
	if err != nil {
		http.Error(w, err.Error(), 500)
		return
	}
	refName := r.PathValue("ref")
	head, err := repo.Head()
	if err != nil {
		http.Error(w, "Cannot resolve HEAD state", 500)
	}
	headRef := head.Name().Short()
	treePath := r.PathValue("path")
	if refName == headRef && treePath == "" {
		repoIndex(w, r)
		return
	}
	ref, err := resolve(repo, refName)
	if err != nil {
		http.NotFound(w, r)
		return
	}
	commit, err := repo.CommitObject(ref.Hash())
	if err != nil {
		http.Error(w, err.Error(), 500)
		return
	}
	rootTree, err := commit.Tree()
	if err != nil {
		http.Error(w, err.Error(), 500)
		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 {
			http.Error(w, err.Error(), 500)
		}
	} 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 = load(repo, &e)
			if err != nil {
				http.Error(w, err.Error(), 500)
			}
		}
		entries = append(entries, em)
	}
	data := TreeData{
		Repo:       repoName,
		Ref:        refName,
		HeadRef:    headRef,
		Base:       treePath,
		Crumbs:     breadcrumbs(repoName, refName, treePath),
		Entries:    entries,
		ReadmeName: readmeName,
		Readme:     readme,
	}
	serve(w, "tree", data)
}

type Crumb struct {
	Name   string
	URL    string
	IsLast bool
}

func breadcrumbs(repoName string, refName string, p string) []Crumb {
	var crumbs []Crumb
	last := true
	cur := p
	for {
		base := path.Base(cur)
		if base == "." || base == "/" {
			break
		}
		crumbs = append(crumbs, Crumb{
			Name:   base,
			URL:    path.Join(repoName, "tree", refName, cur),
			IsLast: last,
		})
		last = false
		next := path.Dir(cur)
		if next == cur {
			break
		}
		cur = next
	}
	slices.Reverse(crumbs)
	return crumbs
}

func repoIndex(w http.ResponseWriter, r *http.Request) {
	repoName := r.PathValue("name")
	if _, err := os.Stat(repoName + DOT_GIT); os.IsNotExist(err) {
		http.NotFound(w, r)
		return
	}
	repo, err := git.PlainOpen(repoName + DOT_GIT)
	if err != nil {
		http.Error(w, err.Error(), 500)
		return
	}
	ref, err := repo.Head()
	if err != nil {
		http.Error(w, "Cannot resolve HEAD", 500)
		return
	}
	commit, err := repo.CommitObject(ref.Hash())
	if err != nil {
		http.Error(w, err.Error(), 500)
		return
	}
	tree, err := commit.Tree()
	if err != nil {
		http.Error(w, err.Error(), 500)
		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 = load(repo, &e)
			if err != nil {
				http.Error(w, err.Error(), 500)
			}
		}
		entries = append(entries, em)
	}
	data := TreeData{
		Repo:       repoName,
		Ref:        refName,
		Entries:    entries,
		ReadmeName: readmeName,
		Readme:     readme,
	}
	serve(w, "repo", data)
}