fierj
PublicTiny personal git forge
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)
}
}