bidet / internal / handlers / handlers.go

@ 3e75cb58402f36226938f1fa6092c501520c6c22 | history


package handlers

import (
	"bytes"
	"compress/gzip"
	"embed"
	"fmt"
	"html/template"
	"io"
	"net/http"
	"path"
	"slices"
	"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"
	}
	return Server{Config: c, CSS: css}
}

type Server struct {
	*Config
	CSS []byte
}

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) {
	tmpl := template.Must(template.New("").
		Funcs(template.FuncMap{
			"css": func() template.CSS {
				return template.CSS(s.CSS)
			},
			"humanize": func(t time.Time) string {
				return humanize.Time(t)
			},
			"rfc3339": func(t time.Time) string {
				return t.Format(time.RFC3339)
			},
		}).
		ParseFS(
			Templates,
			"templates/base.tmpl",
			"templates/"+tmplName+".tmpl",
		),
	)
	var buf bytes.Buffer
	if err := tmpl.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 := core.List(s.Dir)
	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
}

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

func (s Server) open(name string) (*core.Repo, error) {
	return core.Open(path.Join(s.Dir, name))
}

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 {
		tags = append(tags, TagMeta{ref.Name().Short()})
		return nil
	})
	if err != nil {
		s.error(w, err)
		return
	}
	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
	Ref     string
	Commits []CommitMeta
}

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, _, err := repo.ParseRoute(r.PathValue("path"))
	if err != nil {
		http.NotFound(w, nil)
		return
	}
	iter, err := repo.Log(&git.LogOptions{
		From: commit.Hash,
	})
	if err != nil {
		s.error(w, err)
		return
	}
	commits := []CommitMeta{}
	err = iter.ForEach(func(c *object.Commit) error {
		commits = append(commits, CommitMeta{
			Hash:    c.Hash.String(),
			Author:  fmtAuthor(c.Author.Name, c.Author.Email),
			When:    c.Author.When,
			Message: c.Message,
		})
		return nil
	})
	if err != nil {
		s.error(w, err)
		return
	}
	slices.SortFunc(commits, func(a CommitMeta, b CommitMeta) int {
		return -1 * a.When.Compare(b.When)
	})
	data := CommitsData{
		Repo:    repoName,
		Ref:     commit.RefName,
		Commits: commits,
	}
	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 {
		s.error(w, err)
		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 {
		s.error(w, err)
		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
	}
	var refLabel string
	if commit.RefName != "" {
		refLabel = commit.RefName
	} else {
		refLabel = commit.Hash.String()
	}
	data := BlobData{
		Repo:     repoName,
		RefLabel: refLabel,
		Path:     filePath,
		Crumbs:   breadcrumbs(repoName, 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 {
		s.error(w, err)
		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)
	}
	var refLabel string
	if commit.RefName != "" {
		refLabel = commit.RefName
	} else {
		refLabel = commit.Hash.String()
	}
	data := TreeData{
		Hostname:   s.Hostname,
		Repo:       repoName,
		RefLabel:   refLabel,
		Ref:        commit.RefName,
		HeadRef:    headRef,
		Path:       treePath,
		Crumbs:     breadcrumbs(repoName, 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) {
	repoName := r.PathValue("name")
	repo, err := git.PlainOpen(repoName)
	if err != nil {
		s.error(w, err)
		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) {
	repoName := r.PathValue("name")
	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(repoName, 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 {
		s.error(w, err)
		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)
}