New Sign in

fierj

Public

Tiny personal git forge

← fierj / repo_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 withUser(r *http.Request, data map[string]any) map[string]any {
	if data == nil {
		data = map[string]any{}
	}
	data["User"] = User(r)
	return data
}

// checkRepoAccess returns true if the current user can view this repo.
// Private repos require login. Writes 404 or redirects to login if denied.
func checkRepoAccess(w http.ResponseWriter, r *http.Request, git *Git) bool {
	if git.IsPrivate() && User(r) == "" {
		http.Redirect(w, r, "/login", http.StatusSeeOther)
		return false
	}
	return true
}

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
		}
		// Filter private repos for anonymous users.
		user := User(r)
		visible := make([]*Git, 0, len(repos))
		for _, repo := range repos {
			if !repo.IsPrivate() || user != "" {
				visible = append(visible, repo)
			}
		}
		tmpl.ExecuteTemplate(w, "repo_list.html", map[string]any{
			"Repos": visible,
			"User":  user,
		})
	}
}

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")}
		if !checkRepoAccess(w, r, git) {
			return
		}
		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, "repo_tree.html", withUser(r, map[string]any{
				"Repo":        git.Repo(),
				"IsPrivate":   git.IsPrivate(),
				"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, "repo_tree.html", withUser(r, map[string]any{
			"Repo":        git.Repo(),
			"IsPrivate":   git.IsPrivate(),
			"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")}
		if !checkRepoAccess(w, r, git) {
			return
		}
		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, "repo_blob.html", withUser(r, map[string]any{
			"Repo":        git.Repo(),
			"IsPrivate":   git.IsPrivate(),
			"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")}
		if !checkRepoAccess(w, r, git) {
			return
		}
		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{
			"User":        User(r),
			"Repo":        git.Repo(),
			"IsPrivate":   git.IsPrivate(),
			"Ref":         ref,
			"CommitList":  commits,
			"Description": git.Description(),
			"Branches":    git.Branches(),
			"Tags":        git.Tags(),
			"Commits":     git.CommitCount(ref),
			"ActiveTab":   "commits",
		}
		tmpl.ExecuteTemplate(w, "repo_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")}
		if !checkRepoAccess(w, r, git) {
			return
		}
		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, "repo_commit.html", withUser(r, map[string]any{
			"Repo":        git.Repo(),
			"IsPrivate":   git.IsPrivate(),
			"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) {
		if User(r) == "" {
			http.Redirect(w, r, "/login", http.StatusSeeOther)
			return
		}
		tmpl.ExecuteTemplate(w, "repo_new.html", map[string]any{"User": User(r)})
	}
}

func NewRepoPost(cfg Config, tmpl *template.Template) http.HandlerFunc {
	return func(w http.ResponseWriter, r *http.Request) {
		if User(r) == "" {
			http.Redirect(w, r, "/login", http.StatusSeeOther)
			return
		}
		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")}
		if !checkRepoAccess(w, r, git) {
			return
		}
		ref := git.DefaultBranch()
		tmpl.ExecuteTemplate(w, "repo_refs.html", withUser(r, map[string]any{
			"Repo":        git.Repo(),
			"IsPrivate":   git.IsPrivate(),
			"Ref":         ref,
			"Branches":    git.Branches(),
			"Tags":        git.Tags(),
			"Description": git.Description(),
			"Commits":     git.CommitCount(ref),
			"ActiveTab":   "refs",
		}))
	}
}

func SettingsGet(cfg Config, tmpl *template.Template) http.HandlerFunc {
	return func(w http.ResponseWriter, r *http.Request) {
		if User(r) == "" {
			http.Redirect(w, r, "/login", http.StatusSeeOther)
			return
		}
		git := &Git{Dir: cfg.Dir, Name: r.PathValue("repo")}
		meta := git.LoadMeta()
		ref := git.DefaultBranch()
		tmpl.ExecuteTemplate(w, "repo_settings.html", map[string]any{
			"Repo":              git.Repo(),
			"Ref":               ref,
			"IsPrivate":         meta.IsPrivate,
			"Description":       meta.Description,
			"Branches":          git.Branches(),
			"Tags":              git.Tags(),
			"Commits":           git.CommitCount(ref),
			"AuthorizedKeys":    strings.Join(meta.AuthorizedKeys, "\n"),
			"ProtectedBranches": strings.Join(meta.ProtectedBranches, "\n"),
			"ActiveTab":         "settings",
			"User":              User(r),
		})
	}
}

func SettingsPost(cfg Config, tmpl *template.Template) http.HandlerFunc {
	return func(w http.ResponseWriter, r *http.Request) {
		if User(r) == "" {
			http.Redirect(w, r, "/login", http.StatusSeeOther)
			return
		}
		git := &Git{Dir: cfg.Dir, Name: r.PathValue("repo")}
		meta := git.LoadMeta()
		meta.Description = strings.TrimSpace(r.FormValue("description"))
		meta.IsPrivate = r.FormValue("is_private") == "true"

		// Parse authorized keys: one key per line, skip empty lines
		rawKeys := strings.TrimSpace(r.FormValue("authorized_keys"))
		if rawKeys != "" {
			meta.AuthorizedKeys = nil
			for _, k := range strings.Split(rawKeys, "\n") {
				k = strings.TrimSpace(k)
				if k != "" {
					meta.AuthorizedKeys = append(meta.AuthorizedKeys, k)
				}
			}
		} else {
			meta.AuthorizedKeys = nil
		}

		// Parse protected branches
		rawBranches := strings.TrimSpace(r.FormValue("protected_branches"))
		if rawBranches != "" {
			meta.ProtectedBranches = nil
			for _, b := range strings.Split(rawBranches, "\n") {
				b = strings.TrimSpace(b)
				if b != "" {
					meta.ProtectedBranches = append(meta.ProtectedBranches, b)
				}
			}
		} else {
			meta.ProtectedBranches = nil
		}

		if err := git.SaveMeta(meta); err != nil {
			renderError(w, tmpl, http.StatusInternalServerError, "failed to save settings: "+err.Error())
			return
		}
		http.Redirect(w, r, "/"+git.Name+"/settings", http.StatusSeeOther)
	}
}