a self-hosted git server in one binary — everyone reads, only the owner writes
e2e_test.go | 212 +++++++++++++++++++++++++++
gitcmd.go | 320 +++++++++++++++++++++++++++++++++++++++++
githttp.go | 189 ++++++++++++++++++++++++
main.go | 101 +++++++++++++
server.go | 42 ++++++
static/style.css | 124 ++++++++++++++++
templates/blob.html | 14 ++
templates/commit.html | 11 ++
templates/commits.html | 18 +++
templates/index.html | 21 +++
templates/notfound.html | 6 +
templates/parts.html | 35 +++++
templates/refs.html | 30 ++++
templates/repo.html | 32 +++++
templates/tree.html | 20 +++
web.go | 372 ++++++++++++++++++++++++++++++++++++++++++++++++
16 files changed, 1547 insertions(+)
diff --git a/e2e_test.go b/e2e_test.go
new file mode 100644
index 0000000..ab841c4
--- /dev/null
+++ b/e2e_test.go
@@ -0,0 +1,212 @@
+package main
+
+import (
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "strings"
+ "testing"
+)
+
+const testToken = "e2e-test-token"
+
+func newTestServer(t *testing.T) (*httptest.Server, *Store) {
+ t.Helper()
+ store := &Store{Root: t.TempDir()}
+ srv := &Server{Store: store, Token: testToken}
+ ts := httptest.NewServer(srv.handler())
+ t.Cleanup(ts.Close)
+ return ts, store
+}
+
+// gitCmd runs git isolated from the developer's global config and credentials.
+func gitCmd(t *testing.T, dir string, args ...string) (string, error) {
+ t.Helper()
+ base := []string{"-c", "user.name=e2e", "-c", "user.email=e2e@test", "-c", "init.defaultBranch=master"}
+ cmd := exec.Command("git", append(base, args...)...)
+ cmd.Dir = dir
+ cmd.Env = append(os.Environ(),
+ "GIT_TERMINAL_PROMPT=0",
+ "GIT_CONFIG_GLOBAL=/dev/null",
+ "GIT_CONFIG_SYSTEM=/dev/null",
+ "GIT_ASKPASS=/usr/bin/false",
+ )
+ out, err := cmd.CombinedOutput()
+ return string(out), err
+}
+
+func mustGit(t *testing.T, dir string, args ...string) string {
+ t.Helper()
+ out, err := gitCmd(t, dir, args...)
+ if err != nil {
+ t.Fatalf("git %s: %v\n%s", strings.Join(args, " "), err, out)
+ }
+ return out
+}
+
+// withToken embeds the owner token as basic-auth credentials in a clone URL.
+func withToken(t *testing.T, raw string) string {
+ t.Helper()
+ u, err := url.Parse(raw)
+ if err != nil {
+ t.Fatal(err)
+ }
+ u.User = url.UserPassword("owner", testToken)
+ return u.String()
+}
+
+func get(t *testing.T, url string) (int, string) {
+ t.Helper()
+ resp, err := http.Get(url)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer resp.Body.Close()
+ body, _ := io.ReadAll(resp.Body)
+ return resp.StatusCode, string(body)
+}
+
+func TestCloneAndPushRoundtrip(t *testing.T) {
+ ts, store := newTestServer(t)
+ if err := store.create("demo", "a test repository"); err != nil {
+ t.Fatal(err)
+ }
+
+ work := t.TempDir()
+ mustGit(t, work, "clone", ts.URL+"/demo.git", "clone1")
+ clone1 := filepath.Join(work, "clone1")
+
+ if err := os.WriteFile(filepath.Join(clone1, "hello.txt"), []byte("hello from frieren\n"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ mustGit(t, clone1, "add", "hello.txt")
+ mustGit(t, clone1, "commit", "-m", "add hello")
+
+ // Push without the token must be rejected.
+ if out, err := gitCmd(t, clone1, "push", "origin", "master"); err == nil {
+ t.Fatalf("anonymous push succeeded, want auth failure:\n%s", out)
+ }
+
+ // Push with the token must land.
+ mustGit(t, clone1, "push", withToken(t, ts.URL+"/demo.git"), "master")
+
+ // A fresh anonymous clone sees the pushed content.
+ mustGit(t, work, "clone", ts.URL+"/demo.git", "clone2")
+ got, err := os.ReadFile(filepath.Join(work, "clone2", "hello.txt"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if string(got) != "hello from frieren\n" {
+ t.Fatalf("clone content = %q", got)
+ }
+}
+
+func TestPushToCreate(t *testing.T) {
+ ts, store := newTestServer(t)
+
+ work := t.TempDir()
+ mustGit(t, work, "init", "fresh")
+ dir := filepath.Join(work, "fresh")
+ if err := os.WriteFile(filepath.Join(dir, "a.txt"), []byte("a\n"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ mustGit(t, dir, "add", ".")
+ mustGit(t, dir, "commit", "-m", "first")
+
+ // Creating a repo anonymously must fail.
+ if out, err := gitCmd(t, dir, "push", ts.URL+"/newrepo.git", "master"); err == nil {
+ t.Fatalf("anonymous push-to-create succeeded:\n%s", out)
+ }
+ if store.exists("newrepo") {
+ t.Fatal("repository created by unauthenticated push")
+ }
+
+ // With the token the repository springs into existence.
+ mustGit(t, dir, "push", withToken(t, ts.URL+"/newrepo.git"), "master")
+ if !store.exists("newrepo") {
+ t.Fatal("push-to-create did not create the repository")
+ }
+
+ // Invalid names never become directories.
+ if out, err := gitCmd(t, dir, "push", withToken(t, ts.URL+"/.hidden.git"), "master"); err == nil {
+ t.Fatalf("push to invalid repo name succeeded:\n%s", out)
+ }
+}
+
+func TestWebPages(t *testing.T) {
+ ts, store := newTestServer(t)
+ if err := store.create("site", "web smoke test"); err != nil {
+ t.Fatal(err)
+ }
+
+ work := t.TempDir()
+ mustGit(t, work, "clone", ts.URL+"/site.git", "w")
+ dir := filepath.Join(work, "w")
+ os.MkdirAll(filepath.Join(dir, "docs"), 0o755)
+ os.WriteFile(filepath.Join(dir, "README.md"), []byte("# site\nreadme body here\n"), 0o644)
+ os.WriteFile(filepath.Join(dir, "docs", "guide.txt"), []byte("guide line one\n"), 0o644)
+ mustGit(t, dir, "add", ".")
+ mustGit(t, dir, "commit", "-m", "add docs")
+ mustGit(t, dir, "push", withToken(t, ts.URL+"/site.git"), "master")
+
+ for _, tc := range []struct{ path, want string }{
+ {"/", "site"},
+ {"/site", "readme body here"},
+ {"/site/tree/master/docs", "guide.txt"},
+ {"/site/blob/master/docs/guide.txt", "guide line one"},
+ {"/site/raw/master/docs/guide.txt", "guide line one"},
+ {"/site/commits", "add docs"},
+ {"/site/refs", "master"},
+ } {
+ code, body := get(t, ts.URL+tc.path)
+ if code != http.StatusOK {
+ t.Errorf("GET %s = %d", tc.path, code)
+ continue
+ }
+ if !strings.Contains(body, tc.want) {
+ t.Errorf("GET %s: missing %q", tc.path, tc.want)
+ }
+ }
+
+ // Commit page renders the diff.
+ commits, err := store.log(t.Context(), "site", "master", 10)
+ if err != nil || len(commits) == 0 {
+ t.Fatalf("log: %v", err)
+ }
+ code, body := get(t, ts.URL+"/site/commit/"+commits[0].Hash)
+ if code != http.StatusOK || !strings.Contains(body, "guide line one") {
+ t.Errorf("commit page: code %d, diff shown: %v", code, strings.Contains(body, "guide line one"))
+ }
+
+ // Traversal and junk stay 404.
+ for _, path := range []string{"/nope", "/site/blob/master/../../etc/passwd", "/site/raw/master/%2e%2e/x"} {
+ if code, _ := get(t, ts.URL+path); code != http.StatusNotFound {
+ t.Errorf("GET %s = %d, want 404", path, code)
+ }
+ }
+}
+
+func TestReadOnlyWithoutToken(t *testing.T) {
+ store := &Store{Root: t.TempDir()}
+ srv := &Server{Store: store, Token: ""}
+ ts := httptest.NewServer(srv.handler())
+ defer ts.Close()
+
+ work := t.TempDir()
+ mustGit(t, work, "init", "r")
+ dir := filepath.Join(work, "r")
+ os.WriteFile(filepath.Join(dir, "x"), []byte("x"), 0o644)
+ mustGit(t, dir, "add", ".")
+ mustGit(t, dir, "commit", "-m", "x")
+
+ // Even a well-formed credential is refused when no token is configured.
+ u, _ := url.Parse(ts.URL + "/r.git")
+ u.User = url.UserPassword("owner", "anything")
+ if out, err := gitCmd(t, dir, "push", u.String(), "master"); err == nil {
+ t.Fatalf("push succeeded on tokenless server:\n%s", out)
+ }
+}
diff --git a/gitcmd.go b/gitcmd.go
new file mode 100644
index 0000000..faaab78
--- /dev/null
+++ b/gitcmd.go
@@ -0,0 +1,320 @@
+package main
+
+import (
+ "bytes"
+ "context"
+ "fmt"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "regexp"
+ "sort"
+ "strconv"
+ "strings"
+ "time"
+)
+
+// All repository access goes through the real git binary: frieren owns HTTP,
+// auth, and rendering, while git owns object storage and the pack protocol.
+
+var repoNameRe = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]*$`)
+
+// reservedNames are path roots the web UI claims for itself.
+var reservedNames = map[string]bool{"static": true}
+
+func validRepoName(name string) bool {
+ return repoNameRe.MatchString(name) && !reservedNames[name] && len(name) <= 100
+}
+
+func validRef(ref string) bool {
+ if ref == "" || len(ref) > 250 || strings.HasPrefix(ref, "-") || strings.Contains(ref, "..") {
+ return false
+ }
+ for _, r := range ref {
+ if r < 0x20 || r == 0x7f || r == ' ' || r == '~' || r == '^' || r == ':' || r == '\\' {
+ return false
+ }
+ }
+ return true
+}
+
+func validPath(p string) bool {
+ if p == "" {
+ return true
+ }
+ if strings.HasPrefix(p, "/") || strings.Contains(p, "\x00") {
+ return false
+ }
+ for _, seg := range strings.Split(p, "/") {
+ if seg == "" || seg == "." || seg == ".." {
+ return false
+ }
+ }
+ return true
+}
+
+type Store struct {
+ Root string
+}
+
+func (s *Store) repoPath(name string) string {
+ return filepath.Join(s.Root, name+".git")
+}
+
+func (s *Store) exists(name string) bool {
+ if !validRepoName(name) {
+ return false
+ }
+ fi, err := os.Stat(filepath.Join(s.repoPath(name), "HEAD"))
+ return err == nil && fi.Mode().IsRegular()
+}
+
+func (s *Store) create(name, description string) error {
+ if !validRepoName(name) {
+ return fmt.Errorf("invalid repository name %q", name)
+ }
+ if s.exists(name) {
+ return fmt.Errorf("repository %q already exists", name)
+ }
+ if _, err := runGit(context.Background(), s.Root, nil, "init", "--bare", "-b", "master", "--quiet", name+".git"); err != nil {
+ return err
+ }
+ if description != "" {
+ return os.WriteFile(filepath.Join(s.repoPath(name), "description"), []byte(description+"\n"), 0o644)
+ }
+ return nil
+}
+
+// runGit executes git with -C dir and returns stdout. Stderr is folded into
+// the error so callers can surface git's own explanation.
+func runGit(ctx context.Context, dir string, stdin []byte, args ...string) ([]byte, error) {
+ full := append([]string{"-C", dir}, args...)
+ cmd := exec.CommandContext(ctx, "git", full...)
+ if stdin != nil {
+ cmd.Stdin = bytes.NewReader(stdin)
+ }
+ var out, errb bytes.Buffer
+ cmd.Stdout = &out
+ cmd.Stderr = &errb
+ if err := cmd.Run(); err != nil {
+ return nil, fmt.Errorf("git %s: %w: %s", strings.Join(args, " "), err, strings.TrimSpace(errb.String()))
+ }
+ return out.Bytes(), nil
+}
+
+type RepoInfo struct {
+ Name string
+ Description string
+ Default string
+ LastCommit time.Time
+ Empty bool
+}
+
+func (s *Store) open(name string) (*RepoInfo, error) {
+ if !s.exists(name) {
+ return nil, fmt.Errorf("no such repository %q", name)
+ }
+ info := &RepoInfo{Name: name, Default: "master"}
+ dir := s.repoPath(name)
+ if b, err := os.ReadFile(filepath.Join(dir, "description")); err == nil {
+ d := strings.TrimSpace(string(b))
+ if !strings.HasPrefix(d, "Unnamed repository") {
+ info.Description = d
+ }
+ }
+ if b, err := runGit(context.Background(), dir, nil, "symbolic-ref", "--short", "HEAD"); err == nil {
+ info.Default = strings.TrimSpace(string(b))
+ }
+ b, err := runGit(context.Background(), dir, nil,
+ "for-each-ref", "--sort=-committerdate", "--count=1", "--format=%(committerdate:unix)", "refs/heads")
+ if err != nil || len(bytes.TrimSpace(b)) == 0 {
+ info.Empty = true
+ return info, nil
+ }
+ if unix, err := strconv.ParseInt(strings.TrimSpace(string(b)), 10, 64); err == nil {
+ info.LastCommit = time.Unix(unix, 0)
+ }
+ return info, nil
+}
+
+func (s *Store) list() []*RepoInfo {
+ entries, err := os.ReadDir(s.Root)
+ if err != nil {
+ return nil
+ }
+ var repos []*RepoInfo
+ for _, e := range entries {
+ if !e.IsDir() || !strings.HasSuffix(e.Name(), ".git") {
+ continue
+ }
+ name := strings.TrimSuffix(e.Name(), ".git")
+ if info, err := s.open(name); err == nil {
+ repos = append(repos, info)
+ }
+ }
+ sort.Slice(repos, func(i, j int) bool { return repos[i].LastCommit.After(repos[j].LastCommit) })
+ return repos
+}
+
+type TreeEntry struct {
+ Mode string
+ Type string // blob, tree, commit (submodule)
+ Hash string
+ Size int64 // -1 for trees
+ Name string
+}
+
+func (s *Store) lsTree(ctx context.Context, repo, ref, path string) ([]TreeEntry, error) {
+ spec := ref
+ if path != "" {
+ spec += ":" + path
+ }
+ out, err := runGit(ctx, s.repoPath(repo), nil, "ls-tree", "-z", "-l", spec)
+ if err != nil {
+ return nil, err
+ }
+ var entries []TreeEntry
+ for _, rec := range bytes.Split(out, []byte{0}) {
+ if len(rec) == 0 {
+ continue
+ }
+ meta, name, ok := bytes.Cut(rec, []byte{'\t'})
+ if !ok {
+ continue
+ }
+ f := strings.Fields(string(meta))
+ if len(f) != 4 {
+ continue
+ }
+ size := int64(-1)
+ if f[3] != "-" {
+ size, _ = strconv.ParseInt(f[3], 10, 64)
+ }
+ entries = append(entries, TreeEntry{Mode: f[0], Type: f[1], Hash: f[2], Size: size, Name: string(name)})
+ }
+ sort.Slice(entries, func(i, j int) bool {
+ if (entries[i].Type == "tree") != (entries[j].Type == "tree") {
+ return entries[i].Type == "tree"
+ }
+ return entries[i].Name < entries[j].Name
+ })
+ return entries, nil
+}
+
+const maxBlobBytes = 2 << 20 // 2 MiB shown in the web UI; raw endpoint streams everything
+
+func (s *Store) catBlob(ctx context.Context, repo, ref, path string) ([]byte, error) {
+ return runGit(ctx, s.repoPath(repo), nil, "cat-file", "blob", ref+":"+path)
+}
+
+type Commit struct {
+ Hash string
+ Short string
+ Author string
+ When time.Time
+ Subject string
+}
+
+const logFormat = "%H%x1f%h%x1f%an%x1f%at%x1f%s%x1e"
+
+func parseCommits(out []byte) []Commit {
+ var commits []Commit
+ for _, rec := range bytes.Split(out, []byte{0x1e}) {
+ rec = bytes.TrimSpace(rec)
+ if len(rec) == 0 {
+ continue
+ }
+ f := strings.Split(string(rec), "\x1f")
+ if len(f) != 5 {
+ continue
+ }
+ unix, _ := strconv.ParseInt(f[3], 10, 64)
+ commits = append(commits, Commit{Hash: f[0], Short: f[1], Author: f[2], When: time.Unix(unix, 0), Subject: f[4]})
+ }
+ return commits
+}
+
+func (s *Store) log(ctx context.Context, repo, ref string, limit int) ([]Commit, error) {
+ out, err := runGit(ctx, s.repoPath(repo), nil,
+ "log", "--format="+logFormat, "-n", strconv.Itoa(limit), ref, "--")
+ if err != nil {
+ return nil, err
+ }
+ return parseCommits(out), nil
+}
+
+func (s *Store) commit(ctx context.Context, repo, hash string) (*Commit, error) {
+ out, err := runGit(ctx, s.repoPath(repo), nil, "show", "-s", "--format="+logFormat, hash, "--")
+ if err != nil {
+ return nil, err
+ }
+ commits := parseCommits(out)
+ if len(commits) == 0 {
+ return nil, fmt.Errorf("no such commit %q", hash)
+ }
+ return &commits[0], nil
+}
+
+const maxDiffBytes = 1 << 20 // 1 MiB of rendered patch per commit page
+
+func (s *Store) patch(ctx context.Context, repo, hash string) (string, bool, error) {
+ out, err := runGit(ctx, s.repoPath(repo), nil,
+ "show", "--format=", "--stat", "--patch", "--no-color", hash, "--")
+ if err != nil {
+ return "", false, err
+ }
+ if len(out) > maxDiffBytes {
+ return string(out[:maxDiffBytes]), true, nil
+ }
+ return string(out), false, nil
+}
+
+type Ref struct {
+ Name string
+ Short string
+ When time.Time
+ Subject string
+}
+
+func (s *Store) refs(ctx context.Context, repo, kind string) ([]Ref, error) {
+ out, err := runGit(ctx, s.repoPath(repo), nil,
+ "for-each-ref", "--sort=-creatordate",
+ "--format=%(refname:short)%1f%(objectname:short)%1f%(creatordate:unix)%1f%(subject)%1e", "refs/"+kind)
+ if err != nil {
+ return nil, err
+ }
+ var refs []Ref
+ for _, rec := range bytes.Split(out, []byte{0x1e}) {
+ rec = bytes.TrimSpace(rec)
+ if len(rec) == 0 {
+ continue
+ }
+ f := strings.Split(string(rec), "\x1f")
+ if len(f) != 4 {
+ continue
+ }
+ unix, _ := strconv.ParseInt(f[2], 10, 64)
+ refs = append(refs, Ref{Name: f[0], Short: f[1], When: time.Unix(unix, 0), Subject: f[3]})
+ }
+ return refs, nil
+}
+
+// readme returns the first README-ish blob at the root of ref, if any.
+func (s *Store) readme(ctx context.Context, repo, ref string) (name string, body []byte) {
+ entries, err := s.lsTree(ctx, repo, ref, "")
+ if err != nil {
+ return "", nil
+ }
+ for _, e := range entries {
+ if e.Type != "blob" {
+ continue
+ }
+ switch strings.ToLower(e.Name) {
+ case "readme.md", "readme", "readme.txt":
+ if b, err := s.catBlob(ctx, repo, ref, e.Name); err == nil && len(b) <= maxBlobBytes {
+ return e.Name, b
+ }
+ }
+ }
+ return "", nil
+}
diff --git a/githttp.go b/githttp.go
new file mode 100644
index 0000000..5fcb2e3
--- /dev/null
+++ b/githttp.go
@@ -0,0 +1,189 @@
+package main
+
+import (
+ "compress/gzip"
+ "crypto/sha256"
+ "crypto/subtle"
+ "fmt"
+ "io"
+ "log"
+ "net/http"
+ "os"
+ "os/exec"
+)
+
+// Smart HTTP endpoints. frieren speaks just enough of the transport to route
+// and authenticate, then hands the byte stream to git's own plumbing:
+//
+// GET /{repo}(.git)/info/refs?service=git-upload-pack → ref advertisement (anonymous)
+// POST /{repo}(.git)/git-upload-pack → clone/fetch (anonymous)
+// GET /{repo}(.git)/info/refs?service=git-receive-pack → push advertisement (owner only)
+// POST /{repo}(.git)/git-receive-pack → push (owner only)
+//
+// A push to a repository that doesn't exist yet creates it (owner only).
+
+func pktLine(s string) string {
+ return fmt.Sprintf("%04x%s", len(s)+4, s)
+}
+
+// authorized reports whether the request carries the owner token as the HTTP
+// Basic password. The username is ignored. With no token configured the
+// server is read-only for everyone, owner included.
+func (srv *Server) authorized(r *http.Request) bool {
+ if srv.Token == "" {
+ return false
+ }
+ _, pass, ok := r.BasicAuth()
+ if !ok {
+ return false
+ }
+ want := sha256.Sum256([]byte(srv.Token))
+ got := sha256.Sum256([]byte(pass))
+ return subtle.ConstantTimeCompare(want[:], got[:]) == 1
+}
+
+func requireAuth(w http.ResponseWriter) {
+ w.Header().Set("WWW-Authenticate", `Basic realm="frieren"`)
+ http.Error(w, "authentication required", http.StatusUnauthorized)
+}
+
+// ensureRepo resolves the repo for a push, creating it on first push.
+func (srv *Server) ensureRepo(w http.ResponseWriter, name string) bool {
+ if srv.Store.exists(name) {
+ return true
+ }
+ if !validRepoName(name) {
+ http.Error(w, "invalid repository name", http.StatusBadRequest)
+ return false
+ }
+ if err := srv.Store.create(name, ""); err != nil {
+ log.Printf("create %s: %v", name, err)
+ http.Error(w, "could not create repository", http.StatusInternalServerError)
+ return false
+ }
+ log.Printf("created repository %s.git", name)
+ return true
+}
+
+func (srv *Server) infoRefs(w http.ResponseWriter, r *http.Request) {
+ name := repoParam(r)
+ service := r.URL.Query().Get("service")
+ switch service {
+ case "git-upload-pack":
+ if !srv.Store.exists(name) {
+ http.NotFound(w, r)
+ return
+ }
+ case "git-receive-pack":
+ if !srv.authorized(r) {
+ requireAuth(w)
+ return
+ }
+ if !srv.ensureRepo(w, name) {
+ return
+ }
+ default:
+ http.Error(w, "smart HTTP only", http.StatusForbidden)
+ return
+ }
+
+ sub := service[len("git-"):]
+ cmd := exec.CommandContext(r.Context(), "git", sub, "--stateless-rpc", "--advertise-refs", srv.Store.repoPath(name))
+ cmd.Env = gitEnv(r)
+ out, err := cmd.Output()
+ if err != nil {
+ log.Printf("%s advertise %s: %v", sub, name, err)
+ http.Error(w, "git error", http.StatusInternalServerError)
+ return
+ }
+ w.Header().Set("Content-Type", "application/x-"+service+"-advertisement")
+ w.Header().Set("Cache-Control", "no-cache")
+ io.WriteString(w, pktLine("# service="+service+"\n"))
+ io.WriteString(w, "0000")
+ w.Write(out)
+}
+
+func (srv *Server) uploadPack(w http.ResponseWriter, r *http.Request) {
+ name := repoParam(r)
+ if !srv.Store.exists(name) {
+ http.NotFound(w, r)
+ return
+ }
+ srv.serviceRPC(w, r, name, "upload-pack")
+}
+
+func (srv *Server) receivePack(w http.ResponseWriter, r *http.Request) {
+ name := repoParam(r)
+ if !srv.authorized(r) {
+ requireAuth(w)
+ return
+ }
+ if !srv.ensureRepo(w, name) {
+ return
+ }
+ srv.serviceRPC(w, r, name, "receive-pack")
+}
+
+func (srv *Server) serviceRPC(w http.ResponseWriter, r *http.Request, name, sub string) {
+ body := io.Reader(r.Body)
+ if r.Header.Get("Content-Encoding") == "gzip" {
+ gz, err := gzip.NewReader(body)
+ if err != nil {
+ http.Error(w, "bad gzip body", http.StatusBadRequest)
+ return
+ }
+ defer gz.Close()
+ body = gz
+ }
+
+ cmd := exec.CommandContext(r.Context(), "git", sub, "--stateless-rpc", srv.Store.repoPath(name))
+ cmd.Env = gitEnv(r)
+ cmd.Stdin = body
+ cmd.Stderr = os.Stderr
+
+ stdout, err := cmd.StdoutPipe()
+ if err != nil {
+ http.Error(w, "git error", http.StatusInternalServerError)
+ return
+ }
+ if err := cmd.Start(); err != nil {
+ log.Printf("%s %s: %v", sub, name, err)
+ http.Error(w, "git error", http.StatusInternalServerError)
+ return
+ }
+
+ w.Header().Set("Content-Type", "application/x-git-"+sub+"-result")
+ w.Header().Set("Cache-Control", "no-cache")
+ io.Copy(newFlushWriter(w), stdout)
+ if err := cmd.Wait(); err != nil {
+ // Headers are already sent; all we can do is log.
+ log.Printf("%s %s: %v", sub, name, err)
+ }
+}
+
+// gitEnv forwards the client's Git-Protocol header so protocol v2 works.
+func gitEnv(r *http.Request) []string {
+ env := os.Environ()
+ if p := r.Header.Get("Git-Protocol"); p != "" {
+ env = append(env, "GIT_PROTOCOL="+p)
+ }
+ return env
+}
+
+type flushWriter struct {
+ w io.Writer
+ f http.Flusher
+}
+
+func newFlushWriter(w http.ResponseWriter) io.Writer {
+ if f, ok := w.(http.Flusher); ok {
+ return &flushWriter{w: w, f: f}
+ }
+ return w
+}
+
+func (fw *flushWriter) Write(p []byte) (int, error) {
+ n, err := fw.w.Write(p)
+ fw.f.Flush()
+ return n, err
+}
diff --git a/main.go b/main.go
new file mode 100644
index 0000000..aa74941
--- /dev/null
+++ b/main.go
@@ -0,0 +1,101 @@
+package main
+
+import (
+ "crypto/rand"
+ "encoding/hex"
+ "flag"
+ "fmt"
+ "log"
+ "net/http"
+ "os"
+ "path/filepath"
+)
+
+var version = "dev"
+
+const usage = `frieren — a single-binary, self-hosted git server.
+Anyone can browse and clone; only the holder of the token can push.
+
+Usage:
+ frieren serve [-addr :7420] [-root DIR] [-token TOKEN]
+ frieren init <name> [description] create an empty repository under the root
+ frieren token generate a random owner token
+ frieren version
+
+Environment:
+ FRIEREN_ADDR listen address (default :7420)
+ FRIEREN_ROOT repository directory (default ./repos)
+ FRIEREN_TOKEN owner token; unset = server is read-only for everyone
+`
+
+func envOr(key, fallback string) string {
+ if v := os.Getenv(key); v != "" {
+ return v
+ }
+ return fallback
+}
+
+func main() {
+ log.SetFlags(log.LstdFlags)
+ if len(os.Args) < 2 {
+ fmt.Fprint(os.Stderr, usage)
+ os.Exit(2)
+ }
+ switch os.Args[1] {
+ case "serve":
+ serve(os.Args[2:])
+ case "init":
+ initRepo(os.Args[2:])
+ case "token":
+ buf := make([]byte, 32)
+ rand.Read(buf)
+ fmt.Println(hex.EncodeToString(buf))
+ case "version":
+ fmt.Println("frieren", version)
+ default:
+ fmt.Fprint(os.Stderr, usage)
+ os.Exit(2)
+ }
+}
+
+func serve(args []string) {
+ fs := flag.NewFlagSet("serve", flag.ExitOnError)
+ addr := fs.String("addr", envOr("FRIEREN_ADDR", ":7420"), "listen address")
+ root := fs.String("root", envOr("FRIEREN_ROOT", "./repos"), "repository directory")
+ token := fs.String("token", os.Getenv("FRIEREN_TOKEN"), "owner token (push access)")
+ fs.Parse(args)
+
+ absRoot, err := filepath.Abs(*root)
+ if err != nil {
+ log.Fatal(err)
+ }
+ if err := os.MkdirAll(absRoot, 0o755); err != nil {
+ log.Fatal(err)
+ }
+
+ srv := &Server{Store: &Store{Root: absRoot}, Token: *token}
+ if srv.Token == "" {
+ log.Print("WARNING: no token configured (FRIEREN_TOKEN) — pushing is disabled, serving read-only")
+ }
+ log.Printf("frieren %s serving %s on %s", version, absRoot, *addr)
+ log.Fatal(http.ListenAndServe(*addr, srv.handler()))
+}
+
+func initRepo(args []string) {
+ fs := flag.NewFlagSet("init", flag.ExitOnError)
+ root := fs.String("root", envOr("FRIEREN_ROOT", "./repos"), "repository directory")
+ fs.Parse(args)
+ if fs.NArg() < 1 {
+ fmt.Fprint(os.Stderr, usage)
+ os.Exit(2)
+ }
+ name, desc := fs.Arg(0), fs.Arg(1)
+ if err := os.MkdirAll(*root, 0o755); err != nil {
+ log.Fatal(err)
+ }
+ store := &Store{Root: *root}
+ if err := store.create(name, desc); err != nil {
+ log.Fatal(err)
+ }
+ fmt.Printf("created %s\n", store.repoPath(name))
+}
diff --git a/server.go b/server.go
new file mode 100644
index 0000000..fc2f5bf
--- /dev/null
+++ b/server.go
@@ -0,0 +1,42 @@
+package main
+
+import (
+ "net/http"
+ "strings"
+)
+
+type Server struct {
+ Store *Store
+ Token string
+}
+
+// repoParam extracts the repository name from the route, accepting both
+// /name and /name.git so one URL works in the browser and in git.
+func repoParam(r *http.Request) string {
+ return strings.TrimSuffix(r.PathValue("repo"), ".git")
+}
+
+func (srv *Server) handler() http.Handler {
+ mux := http.NewServeMux()
+
+ // Smart HTTP (git clients)
+ mux.HandleFunc("GET /{repo}/info/refs", srv.infoRefs)
+ mux.HandleFunc("POST /{repo}/git-upload-pack", srv.uploadPack)
+ mux.HandleFunc("POST /{repo}/git-receive-pack", srv.receivePack)
+
+ // Web UI (browsers, read-only)
+ mux.HandleFunc("GET /{$}", srv.indexPage)
+ mux.HandleFunc("GET /static/style.css", srv.styleCSS)
+ mux.HandleFunc("GET /{repo}", srv.repoPage)
+ mux.HandleFunc("GET /{repo}/{$}", srv.repoPage)
+ mux.HandleFunc("GET /{repo}/tree/{ref}", srv.treePage)
+ mux.HandleFunc("GET /{repo}/tree/{ref}/{path...}", srv.treePage)
+ mux.HandleFunc("GET /{repo}/blob/{ref}/{path...}", srv.blobPage)
+ mux.HandleFunc("GET /{repo}/raw/{ref}/{path...}", srv.rawFile)
+ mux.HandleFunc("GET /{repo}/commits", srv.commitsPage)
+ mux.HandleFunc("GET /{repo}/commits/{ref}", srv.commitsPage)
+ mux.HandleFunc("GET /{repo}/commit/{hash}", srv.commitPage)
+ mux.HandleFunc("GET /{repo}/refs", srv.refsPage)
+
+ return mux
+}
diff --git a/static/style.css b/static/style.css
new file mode 100644
index 0000000..d1f0f9e
--- /dev/null
+++ b/static/style.css
@@ -0,0 +1,124 @@
+:root {
+ --bg: #fdfdfc;
+ --fg: #1c1e21;
+ --muted: #6b7280;
+ --line: #e5e7eb;
+ --accent: #0f766e;
+ --card: #f4f5f4;
+ --add: #dcfce7;
+ --add-fg: #14532d;
+ --del: #fee2e2;
+ --del-fg: #7f1d1d;
+ --hunk: #eff6ff;
+ --hunk-fg: #1e40af;
+}
+@media (prefers-color-scheme: dark) {
+ :root {
+ --bg: #0c0c0f;
+ --fg: #d7dae0;
+ --muted: #8b93a1;
+ --line: #26282e;
+ --accent: #5eead4;
+ --card: #131418;
+ --add: #0d2818;
+ --add-fg: #86efac;
+ --del: #2d1214;
+ --del-fg: #fca5a5;
+ --hunk: #11203a;
+ --hunk-fg: #93c5fd;
+ }
+}
+
+* { box-sizing: border-box; }
+body {
+ margin: 0;
+ background: var(--bg);
+ color: var(--fg);
+ font: 15px/1.55 system-ui, -apple-system, "Segoe UI", sans-serif;
+}
+main { max-width: 960px; margin: 0 auto; padding: 0 1rem 3rem; }
+a { color: var(--accent); text-decoration: none; }
+a:hover { text-decoration: underline; }
+code, pre, .hash, .mode, .clone { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
+
+header.site {
+ border-bottom: 1px solid var(--line);
+ padding: 0.7rem 1rem;
+ margin-bottom: 1.5rem;
+}
+.wordmark {
+ font-weight: 700;
+ letter-spacing: 0.06em;
+ color: var(--fg);
+}
+.wordmark::before { content: "❄ "; color: var(--accent); }
+footer.site {
+ border-top: 1px solid var(--line);
+ color: var(--muted);
+ font-size: 0.8rem;
+ padding: 1rem;
+ margin-top: 2rem;
+ text-align: center;
+}
+
+h1 { font-size: 1.25rem; margin: 0 0 0.25rem; }
+h1 a { color: var(--fg); }
+h1.home { margin-bottom: 1rem; }
+h2 { font-size: 1rem; margin: 1.5rem 0 0.5rem; }
+.desc { color: var(--muted); margin: 0 0 0.5rem; }
+
+.tabs { display: flex; align-items: center; gap: 1rem; border-bottom: 1px solid var(--line); padding: 0.4rem 0; margin-bottom: 1rem; flex-wrap: wrap; }
+.tabs a { color: var(--muted); }
+.tabs a.on { color: var(--fg); font-weight: 600; }
+.tabs .ref { font-size: 0.8rem; color: var(--accent); border: 1px solid var(--line); border-radius: 3px; padding: 0 0.4rem; }
+.clone { margin-left: auto; font-size: 0.8rem; color: var(--muted); background: var(--card); padding: 0.15rem 0.5rem; border-radius: 3px; user-select: all; }
+
+table { border-collapse: collapse; width: 100%; }
+td { padding: 0.35rem 0.75rem 0.35rem 0; border-bottom: 1px solid var(--line); vertical-align: baseline; }
+tr:last-child td { border-bottom: 0; }
+td.mode { color: var(--muted); font-size: 0.75rem; width: 6.5ch; }
+td.size, td.when { color: var(--muted); font-size: 0.85rem; text-align: right; white-space: nowrap; width: 1%; }
+td.hash { width: 9ch; }
+td.author { color: var(--muted); white-space: nowrap; }
+td.name { white-space: nowrap; }
+.repos td.desc, td.subject { color: var(--muted); max-width: 0; width: 99%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.repos td.name { font-weight: 600; }
+
+.crumbs { color: var(--muted); }
+.crumbs .filemeta { float: right; font-size: 0.85rem; }
+
+pre {
+ background: var(--card);
+ border: 1px solid var(--line);
+ border-radius: 4px;
+ padding: 0.75rem 1rem;
+ overflow-x: auto;
+ font-size: 0.85rem;
+ line-height: 1.5;
+}
+pre.code { counter-reset: l; padding-left: 0; }
+pre.code .cl::before {
+ counter-increment: l;
+ content: counter(l);
+ display: inline-block;
+ width: 3.5ch;
+ margin-right: 1.5ch;
+ padding-right: 0.5ch;
+ text-align: right;
+ color: var(--muted);
+ user-select: none;
+}
+
+pre.diff { padding-left: 1rem; }
+.d-meta { color: var(--muted); }
+.d-file { font-weight: 600; }
+.d-hunk { display: inline-block; width: 100%; background: var(--hunk); color: var(--hunk-fg); }
+.d-add { display: inline-block; width: 100%; background: var(--add); color: var(--add-fg); }
+.d-del { display: inline-block; width: 100%; background: var(--del); color: var(--del-fg); }
+
+.commitmeta p { margin: 0.15rem 0; color: var(--muted); }
+.commitmeta code { font-size: 0.8rem; }
+
+.empty { color: var(--muted); margin: 2rem 0; }
+.readme { margin-top: 2rem; }
+.readme pre { white-space: pre-wrap; }
diff --git a/templates/blob.html b/templates/blob.html
new file mode 100644
index 0000000..9f19ce5
--- /dev/null
+++ b/templates/blob.html
@@ -0,0 +1,14 @@
+{{define "blob"}}{{template "pagehead" .}}
+{{template "repohead" .}}
+{{$p := .}}
+<p class="crumbs"><a href="/{{.Repo.Name}}">{{.Repo.Name}}</a>{{range .Parents}} / <a href="/{{$p.Repo.Name}}/tree/{{pesc $p.Ref}}/{{.Path}}">{{.Name}}</a>{{end}} / <strong>{{.FileName}}</strong>
+ <span class="filemeta">{{size .Bytes}} · <a href="/{{.Repo.Name}}/raw/{{pesc .Ref}}/{{.RawPath}}">raw</a></span></p>
+{{if .Binary}}
+<div class="empty"><p>Binary file ({{size .Bytes}}). <a href="/{{.Repo.Name}}/raw/{{pesc .Ref}}/{{.RawPath}}">Download raw.</a></p></div>
+{{else if .Truncated}}
+<div class="empty"><p>File is too large to display ({{size .Bytes}}). <a href="/{{.Repo.Name}}/raw/{{pesc .Ref}}/{{.RawPath}}">View raw.</a></p></div>
+{{else}}
+<pre class="code"><code>{{range .Lines}}<span class="cl">{{.}}
+</span>{{end}}</code></pre>
+{{end}}
+{{template "pagefoot" .}}{{end}}
diff --git a/templates/commit.html b/templates/commit.html
new file mode 100644
index 0000000..acc487d
--- /dev/null
+++ b/templates/commit.html
@@ -0,0 +1,11 @@
+{{define "commit"}}{{template "pagehead" .}}
+{{template "repohead" .}}
+<div class="commitmeta">
+ <h2>{{.Commit.Subject}}</h2>
+ <p><code>{{.Commit.Hash}}</code></p>
+ <p>{{.Commit.Author}} · {{.Commit.When.Format "Jan 2, 2006 15:04"}} ({{ago .Commit.When}})</p>
+</div>
+<pre class="diff"><code>{{range .Diff}}<span class="d-{{.Class}}">{{.Text}}
+</span>{{end}}</code></pre>
+{{if .Truncated}}<p class="empty">Diff truncated — view the full change locally with <code>git show {{.Commit.Short}}</code>.</p>{{end}}
+{{template "pagefoot" .}}{{end}}
diff --git a/templates/commits.html b/templates/commits.html
new file mode 100644
index 0000000..1c171fa
--- /dev/null
+++ b/templates/commits.html
@@ -0,0 +1,18 @@
+{{define "commits"}}{{template "pagehead" .}}
+{{template "repohead" .}}
+{{$p := .}}
+{{if .Commits}}
+<table class="commits">
+ {{range .Commits}}
+ <tr>
+ <td class="hash"><a href="/{{$p.Repo.Name}}/commit/{{.Hash}}">{{.Short}}</a></td>
+ <td class="subject"><a href="/{{$p.Repo.Name}}/commit/{{.Hash}}">{{.Subject}}</a></td>
+ <td class="author">{{.Author}}</td>
+ <td class="when">{{ago .When}}</td>
+ </tr>
+ {{end}}
+</table>
+{{else}}
+<div class="empty"><p>No commits yet.</p></div>
+{{end}}
+{{template "pagefoot" .}}{{end}}
diff --git a/templates/index.html b/templates/index.html
new file mode 100644
index 0000000..05c1ff7
--- /dev/null
+++ b/templates/index.html
@@ -0,0 +1,21 @@
+{{define "index"}}{{template "pagehead" .}}
+<h1 class="home">repositories</h1>
+{{if .Repos}}
+<table class="repos">
+ {{range .Repos}}
+ <tr>
+ <td class="name"><a href="/{{.Name}}">{{.Name}}</a></td>
+ <td class="desc">{{.Description}}</td>
+ <td class="when">{{if .Empty}}empty{{else}}{{ago .LastCommit}}{{end}}</td>
+ </tr>
+ {{end}}
+</table>
+{{else}}
+<div class="empty">
+ <p>No repositories yet. The owner creates one by pushing to it:</p>
+ <pre>git remote add frieren {{.Base}}/myrepo.git
+git push frieren master</pre>
+ <p>git will ask for credentials — any username, the owner token as the password.</p>
+</div>
+{{end}}
+{{template "pagefoot" .}}{{end}}
diff --git a/templates/notfound.html b/templates/notfound.html
new file mode 100644
index 0000000..2cd8e46
--- /dev/null
+++ b/templates/notfound.html
@@ -0,0 +1,6 @@
+{{define "notfound"}}{{template "pagehead" .}}
+<div class="empty">
+ <h1>404</h1>
+ <p>Nothing here. <a href="/">Back to the repositories.</a></p>
+</div>
+{{template "pagefoot" .}}{{end}}
diff --git a/templates/parts.html b/templates/parts.html
new file mode 100644
index 0000000..645a145
--- /dev/null
+++ b/templates/parts.html
@@ -0,0 +1,35 @@
+{{define "pagehead"}}<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<title>{{.Title}}</title>
+<link rel="stylesheet" href="/static/style.css">
+<link rel="icon" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><text y=%22.9em%22 font-size=%2290%22>❄</text></svg>">
+</head>
+<body>
+<header class="site">
+ <a class="wordmark" href="/">frieren</a>
+</header>
+<main>
+{{end}}
+
+{{define "pagefoot"}}
+</main>
+<footer class="site">served by <a href="https://github.com/justin06lee/frieren">frieren</a> — clone freely; only the owner writes.</footer>
+</body>
+</html>{{end}}
+
+{{define "repohead"}}
+<div class="repohead">
+ <h1><a href="/">~</a> / <a href="/{{.Repo.Name}}">{{.Repo.Name}}</a></h1>
+ {{if .Repo.Description}}<p class="desc">{{.Repo.Description}}</p>{{end}}
+ <nav class="tabs">
+ <a {{if eq .Tab "files"}}class="on"{{end}} href="/{{.Repo.Name}}">files</a>
+ <a {{if eq .Tab "commits"}}class="on"{{end}} href="/{{.Repo.Name}}/commits">commits</a>
+ <a {{if eq .Tab "refs"}}class="on"{{end}} href="/{{.Repo.Name}}/refs">refs</a>
+ {{if .Ref}}<span class="ref" title="ref">{{.Ref}}</span>{{end}}
+ <code class="clone">git clone {{.CloneURL}}</code>
+ </nav>
+</div>
+{{end}}
diff --git a/templates/refs.html b/templates/refs.html
new file mode 100644
index 0000000..1ab63c5
--- /dev/null
+++ b/templates/refs.html
@@ -0,0 +1,30 @@
+{{define "refs"}}{{template "pagehead" .}}
+{{template "repohead" .}}
+{{$p := .}}
+<h2>branches</h2>
+{{if .Branches}}
+<table class="commits">
+ {{range .Branches}}
+ <tr>
+ <td class="name"><a href="/{{$p.Repo.Name}}/tree/{{pesc .Name}}">{{.Name}}</a></td>
+ <td class="hash"><a href="/{{$p.Repo.Name}}/commits/{{pesc .Name}}">{{.Short}}</a></td>
+ <td class="subject">{{.Subject}}</td>
+ <td class="when">{{ago .When}}</td>
+ </tr>
+ {{end}}
+</table>
+{{else}}<p class="empty">none</p>{{end}}
+<h2>tags</h2>
+{{if .Tags}}
+<table class="commits">
+ {{range .Tags}}
+ <tr>
+ <td class="name">{{.Name}}</td>
+ <td class="hash">{{.Short}}</td>
+ <td class="subject">{{.Subject}}</td>
+ <td class="when">{{ago .When}}</td>
+ </tr>
+ {{end}}
+</table>
+{{else}}<p class="empty">none</p>{{end}}
+{{template "pagefoot" .}}{{end}}
diff --git a/templates/repo.html b/templates/repo.html
new file mode 100644
index 0000000..7cb2a33
--- /dev/null
+++ b/templates/repo.html
@@ -0,0 +1,32 @@
+{{define "repo"}}{{template "pagehead" .}}
+{{template "repohead" .}}
+{{if .Repo.Empty}}
+<div class="empty">
+ <p>This repository is empty. The owner fills it with:</p>
+ <pre>git remote add frieren {{.CloneURL}}
+git push frieren master</pre>
+</div>
+{{else}}
+<table class="tree">
+ {{$p := .}}
+ {{range .Entries}}
+ <tr>
+ <td class="mode">{{.Mode}}</td>
+ {{if eq .Type "tree"}}
+ <td class="name dir"><a href="/{{$p.Repo.Name}}/tree/{{pesc $p.Ref}}/{{.Name}}">{{.Name}}/</a></td>
+ <td class="size"></td>
+ {{else}}
+ <td class="name"><a href="/{{$p.Repo.Name}}/blob/{{pesc $p.Ref}}/{{.Name}}">{{.Name}}</a></td>
+ <td class="size">{{size .Size}}</td>
+ {{end}}
+ </tr>
+ {{end}}
+</table>
+{{if .ReadmeName}}
+<section class="readme">
+ <h2>{{.ReadmeName}}</h2>
+ <pre>{{.Readme}}</pre>
+</section>
+{{end}}
+{{end}}
+{{template "pagefoot" .}}{{end}}
diff --git a/templates/tree.html b/templates/tree.html
new file mode 100644
index 0000000..b9af223
--- /dev/null
+++ b/templates/tree.html
@@ -0,0 +1,20 @@
+{{define "tree"}}{{template "pagehead" .}}
+{{template "repohead" .}}
+{{$p := .}}
+<p class="crumbs"><a href="/{{.Repo.Name}}">{{.Repo.Name}}</a>{{range .Crumbs}} / <a href="/{{$p.Repo.Name}}/tree/{{pesc $p.Ref}}/{{.Path}}">{{.Name}}</a>{{end}}</p>
+<table class="tree">
+ {{$base := ""}}{{if .Dir}}{{$base = printf "%s/" .Dir}}{{end}}
+ {{range .Entries}}
+ <tr>
+ <td class="mode">{{.Mode}}</td>
+ {{if eq .Type "tree"}}
+ <td class="name dir"><a href="/{{$p.Repo.Name}}/tree/{{pesc $p.Ref}}/{{$base}}{{.Name}}">{{.Name}}/</a></td>
+ <td class="size"></td>
+ {{else}}
+ <td class="name"><a href="/{{$p.Repo.Name}}/blob/{{pesc $p.Ref}}/{{$base}}{{.Name}}">{{.Name}}</a></td>
+ <td class="size">{{size .Size}}</td>
+ {{end}}
+ </tr>
+ {{end}}
+</table>
+{{template "pagefoot" .}}{{end}}
diff --git a/web.go b/web.go
new file mode 100644
index 0000000..0e22055
--- /dev/null
+++ b/web.go
@@ -0,0 +1,372 @@
+package main
+
+import (
+ "bytes"
+ "embed"
+ "fmt"
+ "html/template"
+ "log"
+ "net/http"
+ "net/url"
+ "strings"
+ "time"
+)
+
+//go:embed templates/*.html
+var templateFS embed.FS
+
+//go:embed static/style.css
+var styleSheet []byte
+
+var funcs = template.FuncMap{
+ "pesc": url.PathEscape,
+ "ago": timeAgo,
+ "size": byteSize,
+}
+
+var pages = template.Must(template.New("").Funcs(funcs).ParseFS(templateFS, "templates/*.html"))
+
+func timeAgo(t time.Time) string {
+ if t.IsZero() {
+ return ""
+ }
+ d := time.Since(t)
+ switch {
+ case d < time.Minute:
+ return "just now"
+ case d < time.Hour:
+ return fmt.Sprintf("%dm ago", int(d.Minutes()))
+ case d < 24*time.Hour:
+ return fmt.Sprintf("%dh ago", int(d.Hours()))
+ case d < 30*24*time.Hour:
+ return fmt.Sprintf("%dd ago", int(d.Hours()/24))
+ default:
+ return t.Format("Jan 2, 2006")
+ }
+}
+
+func byteSize(n int64) string {
+ switch {
+ case n < 0:
+ return ""
+ case n < 1024:
+ return fmt.Sprintf("%d B", n)
+ case n < 1<<20:
+ return fmt.Sprintf("%.1f KB", float64(n)/1024)
+ default:
+ return fmt.Sprintf("%.1f MB", float64(n)/(1<<20))
+ }
+}
+
+func (srv *Server) render(w http.ResponseWriter, name string, data any) {
+ var buf bytes.Buffer
+ if err := pages.ExecuteTemplate(&buf, name, data); err != nil {
+ log.Printf("render %s: %v", name, err)
+ http.Error(w, "template error", http.StatusInternalServerError)
+ return
+ }
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
+ buf.WriteTo(w)
+}
+
+func (srv *Server) styleCSS(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "text/css; charset=utf-8")
+ w.Header().Set("Cache-Control", "max-age=3600")
+ w.Write(styleSheet)
+}
+
+func baseURL(r *http.Request) string {
+ scheme := "http"
+ if r.TLS != nil {
+ scheme = "https"
+ }
+ if p := r.Header.Get("X-Forwarded-Proto"); p != "" {
+ scheme = p
+ }
+ return scheme + "://" + r.Host
+}
+
+// page is the data every template receives.
+type page struct {
+ Title string
+ Repo *RepoInfo
+ Ref string
+ Path string
+ CloneURL string
+ Tab string
+ Base string
+}
+
+func (srv *Server) newPage(r *http.Request, repo *RepoInfo, ref, tab string) page {
+ p := page{Title: "frieren", Tab: tab, Base: baseURL(r)}
+ if repo != nil {
+ p.Repo = repo
+ p.Ref = ref
+ p.Title = repo.Name + " · frieren"
+ p.CloneURL = baseURL(r) + "/" + repo.Name + ".git"
+ }
+ return p
+}
+
+// openRepo loads the repo named in the route or writes a 404.
+func (srv *Server) openRepo(w http.ResponseWriter, r *http.Request) *RepoInfo {
+ info, err := srv.Store.open(repoParam(r))
+ if err != nil {
+ srv.notFound(w, r)
+ return nil
+ }
+ return info
+}
+
+func (srv *Server) notFound(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(http.StatusNotFound)
+ srv.render(w, "notfound", page{Title: "not found · frieren"})
+}
+
+func (srv *Server) indexPage(w http.ResponseWriter, r *http.Request) {
+ srv.render(w, "index", struct {
+ page
+ Repos []*RepoInfo
+ }{srv.newPage(r, nil, "", ""), srv.Store.list()})
+}
+
+func (srv *Server) repoPage(w http.ResponseWriter, r *http.Request) {
+ repo := srv.openRepo(w, r)
+ if repo == nil {
+ return
+ }
+ data := struct {
+ page
+ Entries []TreeEntry
+ ReadmeName string
+ Readme string
+ }{page: srv.newPage(r, repo, repo.Default, "files")}
+ if !repo.Empty {
+ entries, err := srv.Store.lsTree(r.Context(), repo.Name, repo.Default, "")
+ if err == nil {
+ data.Entries = entries
+ }
+ if name, body := srv.Store.readme(r.Context(), repo.Name, repo.Default); name != "" && !isBinary(body) {
+ data.ReadmeName = name
+ data.Readme = string(body)
+ }
+ }
+ srv.render(w, "repo", data)
+}
+
+type crumb struct {
+ Name string
+ Path string
+}
+
+func crumbs(path string) []crumb {
+ if path == "" {
+ return nil
+ }
+ parts := strings.Split(path, "/")
+ out := make([]crumb, len(parts))
+ for i, part := range parts {
+ out[i] = crumb{Name: part, Path: strings.Join(parts[:i+1], "/")}
+ }
+ return out
+}
+
+func (srv *Server) treePage(w http.ResponseWriter, r *http.Request) {
+ repo := srv.openRepo(w, r)
+ if repo == nil {
+ return
+ }
+ ref, path := r.PathValue("ref"), r.PathValue("path")
+ if !validRef(ref) || !validPath(path) {
+ srv.notFound(w, r)
+ return
+ }
+ entries, err := srv.Store.lsTree(r.Context(), repo.Name, ref, path)
+ if err != nil {
+ srv.notFound(w, r)
+ return
+ }
+ srv.render(w, "tree", struct {
+ page
+ Entries []TreeEntry
+ Crumbs []crumb
+ Dir string
+ }{srv.newPage(r, repo, ref, "files"), entries, crumbs(path), path})
+}
+
+func isBinary(b []byte) bool {
+ if len(b) > 8000 {
+ b = b[:8000]
+ }
+ return bytes.IndexByte(b, 0) >= 0
+}
+
+func (srv *Server) blobPage(w http.ResponseWriter, r *http.Request) {
+ repo := srv.openRepo(w, r)
+ if repo == nil {
+ return
+ }
+ ref, path := r.PathValue("ref"), r.PathValue("path")
+ if !validRef(ref) || !validPath(path) || path == "" {
+ srv.notFound(w, r)
+ return
+ }
+ blob, err := srv.Store.catBlob(r.Context(), repo.Name, ref, path)
+ if err != nil {
+ srv.notFound(w, r)
+ return
+ }
+ all := crumbs(path)
+ data := struct {
+ page
+ Parents []crumb
+ FileName string
+ RawPath string
+ Lines []string
+ Bytes int64
+ Binary bool
+ Truncated bool
+ }{
+ page: srv.newPage(r, repo, ref, "files"),
+ Parents: all[:len(all)-1],
+ FileName: all[len(all)-1].Name,
+ RawPath: path,
+ Bytes: int64(len(blob)),
+ }
+ switch {
+ case isBinary(blob):
+ data.Binary = true
+ case len(blob) > maxBlobBytes:
+ data.Truncated = true
+ default:
+ data.Lines = strings.Split(strings.TrimSuffix(string(blob), "\n"), "\n")
+ }
+ srv.render(w, "blob", data)
+}
+
+func (srv *Server) rawFile(w http.ResponseWriter, r *http.Request) {
+ repo := srv.openRepo(w, r)
+ if repo == nil {
+ return
+ }
+ ref, path := r.PathValue("ref"), r.PathValue("path")
+ if !validRef(ref) || !validPath(path) || path == "" {
+ http.NotFound(w, r)
+ return
+ }
+ blob, err := srv.Store.catBlob(r.Context(), repo.Name, ref, path)
+ if err != nil {
+ http.NotFound(w, r)
+ return
+ }
+ // Never let the browser interpret repository content as HTML.
+ w.Header().Set("X-Content-Type-Options", "nosniff")
+ if isBinary(blob) {
+ w.Header().Set("Content-Type", "application/octet-stream")
+ } else {
+ w.Header().Set("Content-Type", "text/plain; charset=utf-8")
+ }
+ w.Write(blob)
+}
+
+func (srv *Server) commitsPage(w http.ResponseWriter, r *http.Request) {
+ repo := srv.openRepo(w, r)
+ if repo == nil {
+ return
+ }
+ ref := r.PathValue("ref")
+ if ref == "" {
+ ref = repo.Default
+ }
+ if !validRef(ref) {
+ srv.notFound(w, r)
+ return
+ }
+ var commits []Commit
+ if !repo.Empty {
+ var err error
+ commits, err = srv.Store.log(r.Context(), repo.Name, ref, 100)
+ if err != nil {
+ srv.notFound(w, r)
+ return
+ }
+ }
+ srv.render(w, "commits", struct {
+ page
+ Commits []Commit
+ }{srv.newPage(r, repo, ref, "commits"), commits})
+}
+
+type diffLine struct {
+ Class string
+ Text string
+}
+
+func classifyPatch(patch string) []diffLine {
+ if patch == "" {
+ return nil
+ }
+ lines := strings.Split(strings.TrimSuffix(patch, "\n"), "\n")
+ out := make([]diffLine, len(lines))
+ for i, l := range lines {
+ class := "ctx"
+ switch {
+ case strings.HasPrefix(l, "diff --git") || strings.HasPrefix(l, "index ") ||
+ strings.HasPrefix(l, "new file") || strings.HasPrefix(l, "deleted file") ||
+ strings.HasPrefix(l, "similarity ") || strings.HasPrefix(l, "rename "):
+ class = "meta"
+ case strings.HasPrefix(l, "+++") || strings.HasPrefix(l, "---"):
+ class = "file"
+ case strings.HasPrefix(l, "@@"):
+ class = "hunk"
+ case strings.HasPrefix(l, "+"):
+ class = "add"
+ case strings.HasPrefix(l, "-"):
+ class = "del"
+ }
+ out[i] = diffLine{Class: class, Text: l}
+ }
+ return out
+}
+
+func (srv *Server) commitPage(w http.ResponseWriter, r *http.Request) {
+ repo := srv.openRepo(w, r)
+ if repo == nil {
+ return
+ }
+ hash := r.PathValue("hash")
+ if !validRef(hash) {
+ srv.notFound(w, r)
+ return
+ }
+ commit, err := srv.Store.commit(r.Context(), repo.Name, hash)
+ if err != nil {
+ srv.notFound(w, r)
+ return
+ }
+ patch, truncated, err := srv.Store.patch(r.Context(), repo.Name, commit.Hash)
+ if err != nil {
+ srv.notFound(w, r)
+ return
+ }
+ srv.render(w, "commit", struct {
+ page
+ Commit *Commit
+ Diff []diffLine
+ Truncated bool
+ }{srv.newPage(r, repo, "", "commits"), commit, classifyPatch(patch), truncated})
+}
+
+func (srv *Server) refsPage(w http.ResponseWriter, r *http.Request) {
+ repo := srv.openRepo(w, r)
+ if repo == nil {
+ return
+ }
+ branches, _ := srv.Store.refs(r.Context(), repo.Name, "heads")
+ tags, _ := srv.Store.refs(r.Context(), repo.Name, "tags")
+ srv.render(w, "refs", struct {
+ page
+ Branches []Ref
+ Tags []Ref
+ }{srv.newPage(r, repo, "", "refs"), branches, tags})
+}