caddy-git-server

Caddy module that provides a git server.

package gitserver

import (
	"embed"
	_ "embed"
	"html/template"
	"io"
	"io/fs"
	"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/h2non/filetype"
	"github.com/russross/blackfriday/v2"

	"github.com/go-git/go-git/v5/config"
	"github.com/go-git/go-git/v5/plumbing"

	"go.uber.org/zap"
)

// Default Page Templates
//
//go:embed templates
var template_dir embed.FS

// Static assets
//
//go:embed assets
var asset_dir embed.FS

type GitBrowserPage interface{}

type GitBrowser struct {
	Name        string
	Tagline     string
	Description string
	Path        string
	Host        string
	CloneURL    string
	Now         string
	Scheme      string
	Page        string
	PageArgs    string
	Root        string
	RefString   string
	Query       template.URL

	ListedDefault bool

	PageData GitBrowserPage

	Repo     *git.Repository
	RefHash  *plumbing.Hash
	Branches []GitRef
	Tags     []GitRef
}

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 GitBrowserRoot struct {
	GitBrowserPage
	Repositories map[string]string
}

type GitBrowserAssetFile interface {
	Close()
	Read(buf []byte)
}

var dateFmt string = "2006-01-02" // 15:04:05"

func (gsrv *GitServer) ServeBrowser(w http.ResponseWriter, r *http.Request, next caddyhttp.Handler) error {
	gsrv.logger.Info("handling web browser",
		// zap.String("repo_path", repoPath),
		zap.String("req_path", r.URL.Path))

	// Check for root path
	if r.RequestURI == "/" {
		println("Handle root")
		repl := r.Context().Value(caddy.ReplacerCtxKey).(*caddy.Replacer)
		root := repl.ReplaceAll(gsrv.Root, ".")
		gsrv.updateRepositories(root)
		return gsrv.serveRootBrowser(root, w, r, next)
	}

	// Handle embedded assets /assets/*
	if strings.HasPrefix(r.RequestURI, "/assets/") {
		asset := strings.TrimPrefix(r.RequestURI, "/assets/")
		println("Handling asset::::", asset)
		return gsrv.serveBrowseAsset(asset, w, r, next)
	}

	// Redirect /<repo>.git to /<repo>
	requestPath := strings.TrimSuffix(r.URL.Path, "/")
	if strings.HasSuffix(requestPath, ".git") {
		println("Handle redirect")
		http.Redirect(w, r, strings.TrimSuffix(requestPath, ".git"), http.StatusPermanentRedirect)
		return nil
	}

	// Get repo path on disk
	repoPath, err := gsrv.getRepoPath(r)
	if err != nil {
		gsrv.logger.Error("repository does not exist", zap.String("url", r.URL.Path))
		return next.ServeHTTP(w, r)
	}

	// Pass it on to the browse handler
	return gsrv.serveGitBrowser(repoPath, w, r, next)
}

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

	templateRoot, err := getTemplateStr("root.html", gsrv.TemplateDir)
	if err != nil {
		return caddyhttp.Error(http.StatusInternalServerError, err)
	}

	rootTemplate, err := template.New("root").Parse(templateRoot)
	if err != nil {
		return caddyhttp.Error(http.StatusInternalServerError, err)
	}

	gb := GitBrowser{
		Host: r.Host,
	}

	pageData := new(GitBrowserRoot)
	pageData.Repositories = make(map[string]string)
	for _, repoName := range gsrv.repositories {
		cfg_file, err := os.Open(filepath.Join(root, repoName+".git", "config"))
		if err != nil {
			println("Couldn't open config file:", err.Error())
			cfg_file.Close()
			continue
		}
		cfg, err := config.ReadConfig(cfg_file)
		if err != nil {
			println("Error reading config file:", err.Error())
			cfg_file.Close()
			continue
		}
		cfg_file.Close()

		// if cfg.Raw.HasSection("caddy-git-server") {
		listed := cfg.Raw.Section("caddy-git-server").Option("listed")
		if listed == "true" || (gsrv.ListedDefault && (listed != "false")) {
			desc := ""
			desc_bytes, err := os.ReadFile(filepath.Join(root, repoName+".git", "description"))
			if err == nil {
				desc = string(desc_bytes)
			}
			pageData.Repositories[repoName] = desc
		}
		// }
	}
	gb.PageData = pageData

	// Set content type header
	w.Header().Set("Content-Type", "text/html; charset=utf-8")

	err = rootTemplate.Execute(w, gb)
	if err != nil {
		return caddyhttp.Error(http.StatusInternalServerError, err)
	}

	return nil
}

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

	println("git browser")
	// 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,
		// "cut":   strings.Cut,
		"string": func(b []byte) string {
			return string(b)
		},
		"markdown": func(b []byte) template.HTML {
			marked := blackfriday.Run(b)
			return template.HTML(marked)
		},
		"dir": func(path string) string {
			return filepath.Dir(path)
		},
		"base": func(path string) string {
			return filepath.Base(path)
		},
		"sub": func(a, b int) int {
			return a - b
		},
	}

	// Decide which base template to use (default embedded or user defined)
	// User template must be named "base.html" and be in the template_dir
	templateBase, err := getTemplateStr("base.html", gsrv.TemplateDir)
	if err != nil {
		println("err  1")
		return caddyhttp.Error(http.StatusInternalServerError, err)
	}

	println("0.5")

	// Load up our base template .Funcs(fm)
	browseTemplate, err := template.New("browse").Funcs(fm).Parse(templateBase)
	println("yes")
	if err != nil {
		println("err2")
		return caddyhttp.Error(http.StatusInternalServerError, err)
	}

	println("1")

	// 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, pageArgs, defined := strings.Cut(strings.TrimPrefix(strings.TrimPrefix(strings.TrimPrefix(r.URL.Path, "/"), pfx), "/"), "/")

	var templatePage string

	if !defined && pageName == "" {
		pageName = "home"
		templatePage, err = getTemplateStr("tree.html", gsrv.TemplateDir)
	} else {
		templatePage, err = getTemplateStr(pageName+".html", gsrv.TemplateDir)
	}
	// fmt.Println("looking for page", pageName)
	if err != nil {
		templatePage, err = getTemplateStr("404.html", gsrv.TemplateDir)
		// templatePageName = "default-404"
		if err != nil {
			return caddyhttp.Error(http.StatusInternalServerError, err)
		}
	}

	// Load up our page template
	browseTemplate.Parse(templatePage)

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

		Repo: repo,
	}

	// 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
	// If description file has the default content, display nothing.
	gb.Tagline, gb.Description, _ = strings.Cut(string(descBytes), "\n")
	if gb.Tagline == "Unnamed repository; edit this file 'description' to name the repository." {
		gb.Tagline = ""
	}

	println("2")

	// 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

	var refStr = "HEAD"
	if r.URL.Query().Has("ref") {
		refStr = r.URL.Query().Get("ref")
	} else if r.URL.Query().Has("branch") {
		refStr = "refs/heads/" + r.URL.Query().Get("branch")
	} else if r.URL.Query().Has("tag") {
		refStr = "refs/tags/" + r.URL.Query().Get("tag")
	}
	gb.RefString = refStr

	rev, err := repo.ResolveRevision(plumbing.Revision(refStr))
	if err != nil {
		return caddyhttp.Error(503, err)
	}
	gb.RefHash = rev

	// Extract branches from repo
	branches, err := gb.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 := gb.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
	})

	println("3")

	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", pageName+".html"),
		// zap.String("template_base", templateBaseName),
		// zap.String("template_page", templatePageName),
	)

	if pageName == "log" {
		gb.browseLog()
	} else if pageName == "tree" || pageName == "home" {
		gb.browseTree()
	} else if pageName == "blob" {
		gb.browseBlob()
		// } else if pageName == "home" {
		// 	gb.browseHome()
	} else if pageName == "diff" {
		println("diff")
		gb.browseDiff()
	} else if pageName == "raw" {
		gb.browseRaw()
		// we just serve the raw file content after setting the mimetype
		gb.serveRaw(w)
		return nil
	}

	// Set content type header
	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
}

func (gsrv *GitServer) serveBrowseAsset(asset string, w http.ResponseWriter, r *http.Request, next caddyhttp.Handler) error {
	asset_file, err := getAssetFile(asset, gsrv.AssetDir)
	println("serveBrowseAsset")
	if err != nil {
		gsrv.logger.Info("unable to find asset file", zap.String("asset_file", asset))
		return next.ServeHTTP(w, r)
	}

	var asset_mime string

	fileext := filepath.Ext(asset)
	if fileext == ".js" {
		asset_mime = "application/javascript"
	} else if fileext == ".css" {
		asset_mime = "text/css"
	} else if fileext == ".png" {
		asset_mime = "image/png"
	} else {
		// Read file header into buffer to determine type
		fileHead := make([]byte, 261)
		asset_file.Read(fileHead)
		fileType, err := filetype.Match(fileHead)
		if err != nil {
			fileType.MIME.Type = "text/plain"
		}
		asset_mime = fileType.MIME.Value
		// Reopen the asset file to seek to beginning
		asset_file.Close()
		asset_file, err = getAssetFile(asset, gsrv.AssetDir)
		if err != nil {
			println("fail to reopen file")
			return next.ServeHTTP(w, r)
		}
	}

	println("Asset MIMEType:", asset_mime)

	w.Header().Set("Content-Type", asset_mime)

	io.Copy(w, asset_file)
	asset_file.Close()
	return nil
}

func getAssetFile(asset string, user_asset_dir string) (fs.File, error) {

	// Try to open asset from asset_dir first
	if user_asset_dir != "" {
		user_asset_file, err := os.Open(filepath.Join(user_asset_dir, asset))
		if err == nil {
			return user_asset_file, nil
		}
	}

	println("Using builtin asset")

	asset_file, err := asset_dir.Open(filepath.Join("assets", asset))
	if err != nil {
		println("fail to open builtin asset")
		return nil, err
	}

	println("success to open builtin asset")

	return asset_file, nil
}

func getTemplateStr(template string, user_template_dir string) (string, error) {

	if user_template_dir != "" {
		user_template, err := os.ReadFile(filepath.Join(user_template_dir, template))
		if err == nil {
			// Return the user template
			println("using user template")
			return string(user_template), nil
		}
	}

	println("using embedded template")

	default_template, err := template_dir.ReadFile(filepath.Join("templates", template))
	if err != nil {
		println("fail to use builtin template")
		return "", caddyhttp.Error(http.StatusInternalServerError, err)
	}
	return string(default_template), nil
}