+ New

fierj

Public

Tiny personal git forge

← fierj / handlers.go
package main

import (
	"fmt"
	"html/template"
	"log/slog"
	"net"
	"net/http"
	"sort"
	"strings"
	"time"
)

type BreadcrumbItem struct {
	Name string
	URL  string
}

var funcMap = template.FuncMap{
	"pathJoin": func(parts ...string) string {
		var nonEmpty []string
		for _, p := range parts {
			if p != "" {
				nonEmpty = append(nonEmpty, p)
			}
		}
		return strings.Join(nonEmpty, "/")
	},
	"shortHash": func(h string) string {
		if len(h) > 8 {
			return h[:8]
		}
		return h
	},
	"hasSuffix": strings.HasSuffix,
	"timeAgo": func(ts string) string {
		t, err := time.Parse(time.RFC3339, ts)
		if err != nil {
			return ts
		}
		d := time.Since(t)
		switch {
		case d < time.Minute:
			return "just now"
		case d < time.Hour:
			m := int(d.Minutes())
			if m == 1 {
				return "1 minute ago"
			}
			return fmt.Sprintf("%d minutes ago", m)
		case d < 24*time.Hour:
			h := int(d.Hours())
			if h == 1 {
				return "1 hour ago"
			}
			return fmt.Sprintf("%d hours ago", h)
		case d < 30*24*time.Hour:
			days := int(d.Hours() / 24)
			if days == 1 {
				return "1 day ago"
			}
			return fmt.Sprintf("%d days ago", days)
		case d < 365*24*time.Hour:
			months := int(d.Hours() / 24 / 30)
			if months == 1 {
				return "1 month ago"
			}
			return fmt.Sprintf("%d months ago", months)
		default:
			return ts[:10]
		}
	},
}

func renderError(w http.ResponseWriter, tmpl *template.Template, code int, message string) {
	w.WriteHeader(code)
	tmpl.ExecuteTemplate(w, "error.html", map[string]any{
		"Code":    code,
		"Message": message,
	})
}

func Breadcrumb(path string) []BreadcrumbItem {
	if path == "" {
		return nil
	}
	parts := strings.Split(path, "/")
	items := make([]BreadcrumbItem, len(parts))
	cumulative := ""
	for i, p := range parts {
		if i > 0 {
			cumulative += "/"
		}
		cumulative += p
		items[i] = BreadcrumbItem{Name: p, URL: cumulative}
	}
	return items
}

func Repos(cfg Config, tmpl *template.Template) http.HandlerFunc {
	return func(w http.ResponseWriter, r *http.Request) {
		repos, err := ListRepos(cfg)
		if err != nil {
			renderError(w, tmpl, http.StatusInternalServerError, "failed to list repositories: "+err.Error())
			return
		}
		tmpl.ExecuteTemplate(w, "repos.html", map[string]any{
			"Repos": repos,
		})
	}
}

func Tree(cfg Config, tmpl *template.Template) http.HandlerFunc {
	return func(w http.ResponseWriter, r *http.Request) {
		type FileEntry struct {
			TreeEntry
			LastCommit *Commit
		}
		git := &Git{Dir: cfg.Dir, Name: r.PathValue("repo")}
		treePath := r.PathValue("path")
		ref := r.PathValue("ref")
		if ref == "" {
			ref = git.DefaultBranch()
		}
		cloneSSH := ""
		cloneHTTPS := ""
		if cfg.Host != "" {
			cloneHTTPS = fmt.Sprintf("https://%s/%s.git", cfg.Host, git.Name)
			// Include port in SSH URL if non-standard
			if _, port, _ := net.SplitHostPort(cfg.SSHAddr); port != "" && port != "22" {
				cloneSSH = fmt.Sprintf("ssh://git@%s:%s/%s.git", cfg.Host, port, git.Name)
			} else {
				cloneSSH = fmt.Sprintf("git@%s:%s.git", cfg.Host, git.Name)
			}
		}
		if git.IsEmpty() {
			tmpl.ExecuteTemplate(w, "tree.html", map[string]any{
				"Repo":        git.Repo(),
				"Ref":         ref,
				"Path":        "",
				"Entries":     []FileEntry{},
				"CloneSSH":    cloneSSH,
				"CloneHTTPS":  cloneHTTPS,
				"Description": git.Description(),
				"Branches":    []string{},
				"Tags":        []string{},
				"Commits":     0,
				"Empty":       true,
			})
			return
		}
		entries, err := git.List(ref, treePath)
		if err != nil {
			renderError(w, tmpl, http.StatusInternalServerError, "failed to list tree: "+err.Error())
			return
		}
		// directories first
		sort.Slice(entries, func(i, j int) bool {
			if entries[i].Type != entries[j].Type {
				return entries[i].Type == "tree"
			}
			return entries[i].Name < entries[j].Name
		})

		fileEntries := []FileEntry{}
		for _, e := range entries {
			fp := e.Name
			if treePath != "" {
				fp = treePath + "/" + e.Name
			}
			fe := FileEntry{TreeEntry: e, LastCommit: git.LastCommit(ref, fp)}
			fileEntries = append(fileEntries, fe)
		}
		readme := git.Readme(ref, treePath)
		tmpl.ExecuteTemplate(w, "tree.html", map[string]any{
			"Repo":        git.Repo(),
			"Ref":         ref,
			"Path":        treePath,
			"Breadcrumb":  Breadcrumb(treePath),
			"Entries":     fileEntries,
			"CloneSSH":    cloneSSH,
			"CloneHTTPS":  cloneHTTPS,
			"Description": git.Description(),
			"Branches":    git.Branches(),
			"Tags":        git.Tags(),
			"Commits":     git.CommitCount(ref),
			"Readme":      readme,
			"ActiveTab":   "code",
		})
	}
}

func Blob(cfg Config, tmpl *template.Template) http.HandlerFunc {
	return func(w http.ResponseWriter, r *http.Request) {
		git := &Git{Dir: cfg.Dir, Name: r.PathValue("repo")}
		filePath := r.PathValue("path")
		ref := r.PathValue("ref")
		if ref == "" {
			ref = git.DefaultBranch()
		}

		content, err := git.Blob(ref, filePath)
		if err != nil {
			renderError(w, tmpl, http.StatusNotFound, err.Error())
			return
		}
		tmpl.ExecuteTemplate(w, "blob.html", map[string]any{
			"Repo":        git.Repo(),
			"Ref":         ref,
			"Path":        filePath,
			"Breadcrumb":  Breadcrumb(filePath),
			"Content":     content,
			"Description": git.Description(),
			"Branches":    git.Branches(),
			"Tags":        git.Tags(),
			"Commits":     git.CommitCount(ref),
			"ActiveTab":   "code",
		})
	}
}

func Log(cfg Config, tmpl *template.Template) http.HandlerFunc {
	return func(w http.ResponseWriter, r *http.Request) {
		git := &Git{Dir: cfg.Dir, Name: r.PathValue("repo")}
		ref := r.PathValue("ref")
		commits, err := git.Log(ref, 50)
		if err != nil {
			renderError(w, tmpl, http.StatusInternalServerError, err.Error())
			return
		}
		data := map[string]any{
			"Repo":        git.Repo(),
			"Ref":         ref,
			"CommitList":  commits,
			"Description": git.Description(),
			"Branches":    git.Branches(),
			"Tags":        git.Tags(),
			"Commits":     git.CommitCount(ref),
			"ActiveTab":   "commits",
		}
		tmpl.ExecuteTemplate(w, "log.html", data)
	}
}

func Diff(cfg Config, tmpl *template.Template) http.HandlerFunc {
	return func(w http.ResponseWriter, r *http.Request) {
		git := &Git{Dir: cfg.Dir, Name: r.PathValue("repo")}
		ref := r.PathValue("ref")
		commit, err := git.Diff(ref)
		if err != nil || len(commit) == 0 {
			renderError(w, tmpl, http.StatusNotFound, "commit not found")
			return
		}
		if err := tmpl.ExecuteTemplate(w, "commit.html", map[string]any{
			"Repo":        git.Repo(),
			"Ref":         ref,
			"Diff":        commit,
			"Hash":        ref,
			"Description": git.Description(),
			"Branches":    git.Branches(),
			"Tags":        git.Tags(),
			"Commits":     git.CommitCount(ref),
			"ActiveTab":   "commits",
		}); err != nil {
			slog.Error("failed to execute template", "error", err)
		}
	}
}

func NewRepoGet(tmpl *template.Template) http.HandlerFunc {
	return func(w http.ResponseWriter, r *http.Request) {
		tmpl.ExecuteTemplate(w, "new_repo.html", nil)
	}
}

func NewRepoPost(cfg Config, tmpl *template.Template) http.HandlerFunc {
	return func(w http.ResponseWriter, r *http.Request) {
		name := strings.TrimSpace(r.FormValue("name"))
		desc := strings.TrimSpace(r.FormValue("description"))
		importURL := strings.TrimSpace(r.FormValue("import_url"))

		if importURL != "" {
			importedName, err := ImportRepo(cfg, importURL, desc)
			if err != nil {
				renderError(w, tmpl, http.StatusBadRequest, "failed to import: "+err.Error())
				return
			}
			http.Redirect(w, r, "/"+importedName, http.StatusSeeOther)
			return
		}

		if name == "" {
			renderError(w, tmpl, http.StatusBadRequest, "repository name is required")
			return
		}
		if err := InitRepo(cfg, name, desc); err != nil {
			renderError(w, tmpl, http.StatusBadRequest, "failed to create repository: "+err.Error())
			return
		}
		http.Redirect(w, r, "/"+name, http.StatusSeeOther)
	}
}

func Refs(cfg Config, tmpl *template.Template) http.HandlerFunc {
	return func(w http.ResponseWriter, r *http.Request) {
		git := &Git{Dir: cfg.Dir, Name: r.PathValue("repo")}
		ref := git.DefaultBranch()
		tmpl.ExecuteTemplate(w, "refs.html", map[string]any{
			"Repo":        git.Repo(),
			"Ref":         ref,
			"Branches":    git.Branches(),
			"Tags":        git.Tags(),
			"Description": git.Description(),
			"Commits":     git.CommitCount(ref),
			"ActiveTab":   "refs",
		})
	}
}