caddy-git-server

Provides a git_server caddy module for serving git repositories.

package gitserver

import (
	_ "embed"
	"html/template"
	"io"
	"net/http"
	"os"
	"path/filepath"
	"strings"
	"time"

	"github.com/caddyserver/caddy/v2"
	"github.com/caddyserver/caddy/v2/modules/caddyhttp"
	"github.com/go-git/go-git/v5"
	"github.com/go-git/go-git/v5/plumbing"
	"github.com/go-git/go-git/v5/plumbing/object"
	"go.uber.org/zap"
)

// Default Page Templates
//

//go:embed templates/base.html
var template_base string

//go:embed templates/404.html
var template_page_404 string

//go:embed templates/home.html
var template_page_home string

//go:embed templates/blob.html
var template_page_blob string

//go:embed templates/tree.html
var template_page_tree string

//go:embed templates/log.html
var template_page_log string

// Static assets
//
//go:embed static/git-icon.b64
var static_gitIcon string

var template_pages = map[string]*string{
	"home": &template_page_home,
	"blob": &template_page_blob,
	"tree": &template_page_tree,
	"log":  &template_page_log,
}

var static_assets = StaticAssets{
	GitIcon: static_gitIcon,
}

type GitBrowser struct {
	Name        string
	Tagline     string
	Description string
	Path        string
	Host        string
	CloneURL    string
	Now         string
	Scheme      string
	Page        string
	Root        string

	Branches []GitRef
	Tags     []GitRef

	Commits []GitCommit

	Files []GitFile

	// Static assets
	Assets StaticAssets
}

type GitRef struct {
	// SHA1 hash
	Hash string
	// Ref type string, either 'refs/heads' or 'refs/tags
	Type string
	// Name of branch or tag
	Name string
}

type GitCommit struct {
	// SHA1 commit hash
	Hash string
	//Author of commit
	Author string
	// Committer of commit
	Committer string
	// Commit message
	Message string
	// Creation date (done by Author)
	Date string
}

type GitFile struct {
	Name   string
	Mode   string
	Commit GitCommit
}

type StaticAssets struct {
	GitIcon string
}

func (gsrv *GitServer) serveGitBrowser(repoPath string, w http.ResponseWriter, r *http.Request, next caddyhttp.Handler) error {

	// We can assume the repo exists, so go ahead and open it
	repo, err := git.PlainOpen(repoPath)
	if err != nil {
		return caddyhttp.Error(http.StatusInternalServerError, err)
	}

	// Setup function map
	fm := template.FuncMap{
		"split": strings.Split,
	}

	// Decide which base template to use (default embedded or user defined)
	// User template must be named "base.html" and be in the template_dir
	templateBaseStr := &template_base
	templateBaseName := "default"
	if gsrv.TemplateDir != "" {
		tbn := filepath.Join(gsrv.TemplateDir, "base.html")
		userBase, err := os.ReadFile(tbn)
		if err == nil {
			// Convert the read file into a string and set the new filename
			user_template_base := string(userBase)
			templateBaseStr = &user_template_base
			templateBaseName = tbn
		}
	}
	// Load up our base template
	browseTemplate, err := template.New("browse").Funcs(fm).Parse(*templateBaseStr)
	if err != nil {
		return caddyhttp.Error(http.StatusInternalServerError, err)
	}

	// Decide which page to load and read template file if necessary
	// Page is determined by the path segment following the repository.
	// Any path after that is path arguments, currently only the reference
	root := r.Context().Value(caddy.ReplacerCtxKey).(*caddy.Replacer).ReplaceAll(gsrv.Root, ".")
	pfx := strings.TrimPrefix(strings.TrimSuffix(strings.TrimPrefix(repoPath, root), ".git"), "/")
	pageName, _, defined := strings.Cut(strings.TrimPrefix(strings.TrimPrefix(strings.TrimPrefix(r.URL.Path, "/"), pfx), "/"), "/")
	if !defined && pageName == "" {
		pageName = "home"
	}
	// fmt.Println("looking for page", pageName)
	templatePageStr := template_pages[pageName]
	templatePageName := "default-" + pageName
	if gsrv.TemplateDir != "" {
		tpn := filepath.Join(gsrv.TemplateDir, pageName+".html")
		userPage, err := os.ReadFile(tpn)
		if err == nil {
			user_template_page := string(userPage)
			templatePageStr = &user_template_page
			templatePageName = tpn
		}
	}

	// If we couldn't find a page template, use the 404 page
	if templatePageStr == nil {
		templatePageStr = &template_page_404
		templatePageName = "default-404"
		if gsrv.TemplateDir != "" {
			tpn := filepath.Join(gsrv.TemplateDir, "404.html")
			user404, err := os.ReadFile(tpn)
			if err == nil {
				// Use user 404 page if one is found
				user_template_page := string(user404)
				templatePageStr = &user_template_page
				templatePageName = tpn
			}
		}
	}

	// Load up our page template
	browseTemplate.Parse(*templatePageStr)

	// Create our template data object
	gb := GitBrowser{
		Name:   strings.TrimSuffix(filepath.Base(repoPath), ".git"),
		Path:   r.URL.Path,
		Page:   pageName,
		Host:   r.Host,
		Now:    time.Now().UTC().Format(time.UnixDate),
		Assets: static_assets,
		Root:   pfx,
	}

	// Open the description file
	file, err := os.Open(filepath.Join(repoPath, "description"))
	if err != nil {
		return err
	}
	defer file.Close()

	// Read the full description file (keep it short)
	descBytes, err := io.ReadAll(file)
	if err != nil {
		// No description file?
		return caddyhttp.Error(http.StatusInternalServerError, err)
	}

	// Get first line as tagline, rest of file is the long description
	gb.Tagline, gb.Description, _ = strings.Cut(string(descBytes), "\n")

	// Set the scheme if it is empty. This is for generating a proper clone url
	if r.URL.Scheme == "" {
		if r.TLS == nil {
			r.URL.Scheme = "http"
		} else {
			r.URL.Scheme = "https"
		}
	}

	// Construct the clone url
	cloneUrl := r.URL.Scheme + "://" + r.Host + "/" + pfx + ".git"
	gb.CloneURL = cloneUrl

	// Extract branches from repo
	branches, err := repo.Branches()
	if err != nil {
		return caddyhttp.Error(http.StatusInternalServerError, err)
	}
	branches.ForEach(func(r *plumbing.Reference) error {
		b := GitRef{
			Hash: r.Hash().String(),
			Type: r.Type().String(),
			Name: r.Name().Short(),
		}
		gb.Branches = append(gb.Branches, b)
		return nil
	})

	// Extract tags
	tags, err := repo.Tags()
	if err != nil {
		return caddyhttp.Error(http.StatusInternalServerError, err)
	}
	tags.ForEach(func(r *plumbing.Reference) error {
		t := GitRef{
			Hash: r.Hash().String(),
			Type: r.Type().String(),
			Name: r.Name().Short(),
		}
		gb.Tags = append(gb.Tags, t)
		return nil
	})

	if pageName == "log" {
		// Extract commits if needed
		ref, err := repo.Head()
		if err == nil {
			commits, _ := repo.Log(&git.LogOptions{From: ref.Hash()})
			commits.ForEach(func(c *object.Commit) error {
				commit := GitCommit{
					Hash:      c.Hash.String(),
					Author:    c.Author.String(),
					Committer: c.Committer.String(),
					Message:   c.Message,
					Date:      c.Author.When.String(),
				}
				gb.Commits = append(gb.Commits, commit)
				return nil
			})
		}

	} else if pageName == "tree" {
		// Get list of files if needed
		ref, err := repo.Head()
		if err == nil {
			refCommit, _ := repo.CommitObject(ref.Hash())
			tree, _ := refCommit.Tree()
			for _, entry := range tree.Entries {
				f := GitFile{
					Name:   entry.Name,
					Mode:   entry.Mode.String(),
					Commit: GitCommit{Message: "Initial Commit - Added all files."},
				}
				gb.Files = append(gb.Files, f)
			}
		}
	}

	gsrv.logger.Info("serving git browser",
		zap.String("request_path", r.URL.Path),
		zap.String("git_repo", repoPath),
		zap.String("query", r.URL.RawQuery),
		zap.String("template_base", templateBaseName),
		zap.String("template_page", templatePageName),
	)

	// Fun with headers
	w.Header().Set("Content-Type", "text/html; charset=utf-8")

	// Write to connection
	err = browseTemplate.Execute(w, gb)
	if err != nil {
		return caddyhttp.Error(http.StatusInternalServerError, err)
	}
	// fmt.Fprintf(w, "<html><h1>%s</html></h1>", refString)

	return nil
}