frierena personal git archive

frieren

a self-hosted git server in one binary — everyone reads, only the owner writes

feat: single-binary git server with smart HTTP and read-only web UI

81c694348fc919ff32a3f94a1317dba38b42365a

justin06lee · Aug 18, 2026, 10:08 PM (5h ago)

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(+)
e2e_test.go+2120
@@ -0,0 +1,212 @@
1+package main
2+
3+import (
4+ "io"
5+ "net/http"
6+ "net/http/httptest"
7+ "net/url"
8+ "os"
9+ "os/exec"
10+ "path/filepath"
11+ "strings"
12+ "testing"
13+)
14+
15+const testToken = "e2e-test-token"
16+
17+func newTestServer(t *testing.T) (*httptest.Server, *Store) {
18+ t.Helper()
19+ store := &Store{Root: t.TempDir()}
20+ srv := &Server{Store: store, Token: testToken}
21+ ts := httptest.NewServer(srv.handler())
22+ t.Cleanup(ts.Close)
23+ return ts, store
24+}
25+
26+// gitCmd runs git isolated from the developer's global config and credentials.
27+func gitCmd(t *testing.T, dir string, args ...string) (string, error) {
28+ t.Helper()
29+ base := []string{"-c", "user.name=e2e", "-c", "user.email=e2e@test", "-c", "init.defaultBranch=master"}
30+ cmd := exec.Command("git", append(base, args...)...)
31+ cmd.Dir = dir
32+ cmd.Env = append(os.Environ(),
33+ "GIT_TERMINAL_PROMPT=0",
34+ "GIT_CONFIG_GLOBAL=/dev/null",
35+ "GIT_CONFIG_SYSTEM=/dev/null",
36+ "GIT_ASKPASS=/usr/bin/false",
37+ )
38+ out, err := cmd.CombinedOutput()
39+ return string(out), err
40+}
41+
42+func mustGit(t *testing.T, dir string, args ...string) string {
43+ t.Helper()
44+ out, err := gitCmd(t, dir, args...)
45+ if err != nil {
46+ t.Fatalf("git %s: %v\n%s", strings.Join(args, " "), err, out)
47+ }
48+ return out
49+}
50+
51+// withToken embeds the owner token as basic-auth credentials in a clone URL.
52+func withToken(t *testing.T, raw string) string {
53+ t.Helper()
54+ u, err := url.Parse(raw)
55+ if err != nil {
56+ t.Fatal(err)
57+ }
58+ u.User = url.UserPassword("owner", testToken)
59+ return u.String()
60+}
61+
62+func get(t *testing.T, url string) (int, string) {
63+ t.Helper()
64+ resp, err := http.Get(url)
65+ if err != nil {
66+ t.Fatal(err)
67+ }
68+ defer resp.Body.Close()
69+ body, _ := io.ReadAll(resp.Body)
70+ return resp.StatusCode, string(body)
71+}
72+
73+func TestCloneAndPushRoundtrip(t *testing.T) {
74+ ts, store := newTestServer(t)
75+ if err := store.create("demo", "a test repository"); err != nil {
76+ t.Fatal(err)
77+ }
78+
79+ work := t.TempDir()
80+ mustGit(t, work, "clone", ts.URL+"/demo.git", "clone1")
81+ clone1 := filepath.Join(work, "clone1")
82+
83+ if err := os.WriteFile(filepath.Join(clone1, "hello.txt"), []byte("hello from frieren\n"), 0o644); err != nil {
84+ t.Fatal(err)
85+ }
86+ mustGit(t, clone1, "add", "hello.txt")
87+ mustGit(t, clone1, "commit", "-m", "add hello")
88+
89+ // Push without the token must be rejected.
90+ if out, err := gitCmd(t, clone1, "push", "origin", "master"); err == nil {
91+ t.Fatalf("anonymous push succeeded, want auth failure:\n%s", out)
92+ }
93+
94+ // Push with the token must land.
95+ mustGit(t, clone1, "push", withToken(t, ts.URL+"/demo.git"), "master")
96+
97+ // A fresh anonymous clone sees the pushed content.
98+ mustGit(t, work, "clone", ts.URL+"/demo.git", "clone2")
99+ got, err := os.ReadFile(filepath.Join(work, "clone2", "hello.txt"))
100+ if err != nil {
101+ t.Fatal(err)
102+ }
103+ if string(got) != "hello from frieren\n" {
104+ t.Fatalf("clone content = %q", got)
105+ }
106+}
107+
108+func TestPushToCreate(t *testing.T) {
109+ ts, store := newTestServer(t)
110+
111+ work := t.TempDir()
112+ mustGit(t, work, "init", "fresh")
113+ dir := filepath.Join(work, "fresh")
114+ if err := os.WriteFile(filepath.Join(dir, "a.txt"), []byte("a\n"), 0o644); err != nil {
115+ t.Fatal(err)
116+ }
117+ mustGit(t, dir, "add", ".")
118+ mustGit(t, dir, "commit", "-m", "first")
119+
120+ // Creating a repo anonymously must fail.
121+ if out, err := gitCmd(t, dir, "push", ts.URL+"/newrepo.git", "master"); err == nil {
122+ t.Fatalf("anonymous push-to-create succeeded:\n%s", out)
123+ }
124+ if store.exists("newrepo") {
125+ t.Fatal("repository created by unauthenticated push")
126+ }
127+
128+ // With the token the repository springs into existence.
129+ mustGit(t, dir, "push", withToken(t, ts.URL+"/newrepo.git"), "master")
130+ if !store.exists("newrepo") {
131+ t.Fatal("push-to-create did not create the repository")
132+ }
133+
134+ // Invalid names never become directories.
135+ if out, err := gitCmd(t, dir, "push", withToken(t, ts.URL+"/.hidden.git"), "master"); err == nil {
136+ t.Fatalf("push to invalid repo name succeeded:\n%s", out)
137+ }
138+}
139+
140+func TestWebPages(t *testing.T) {
141+ ts, store := newTestServer(t)
142+ if err := store.create("site", "web smoke test"); err != nil {
143+ t.Fatal(err)
144+ }
145+
146+ work := t.TempDir()
147+ mustGit(t, work, "clone", ts.URL+"/site.git", "w")
148+ dir := filepath.Join(work, "w")
149+ os.MkdirAll(filepath.Join(dir, "docs"), 0o755)
150+ os.WriteFile(filepath.Join(dir, "README.md"), []byte("# site\nreadme body here\n"), 0o644)
151+ os.WriteFile(filepath.Join(dir, "docs", "guide.txt"), []byte("guide line one\n"), 0o644)
152+ mustGit(t, dir, "add", ".")
153+ mustGit(t, dir, "commit", "-m", "add docs")
154+ mustGit(t, dir, "push", withToken(t, ts.URL+"/site.git"), "master")
155+
156+ for _, tc := range []struct{ path, want string }{
157+ {"/", "site"},
158+ {"/site", "readme body here"},
159+ {"/site/tree/master/docs", "guide.txt"},
160+ {"/site/blob/master/docs/guide.txt", "guide line one"},
161+ {"/site/raw/master/docs/guide.txt", "guide line one"},
162+ {"/site/commits", "add docs"},
163+ {"/site/refs", "master"},
164+ } {
165+ code, body := get(t, ts.URL+tc.path)
166+ if code != http.StatusOK {
167+ t.Errorf("GET %s = %d", tc.path, code)
168+ continue
169+ }
170+ if !strings.Contains(body, tc.want) {
171+ t.Errorf("GET %s: missing %q", tc.path, tc.want)
172+ }
173+ }
174+
175+ // Commit page renders the diff.
176+ commits, err := store.log(t.Context(), "site", "master", 10)
177+ if err != nil || len(commits) == 0 {
178+ t.Fatalf("log: %v", err)
179+ }
180+ code, body := get(t, ts.URL+"/site/commit/"+commits[0].Hash)
181+ if code != http.StatusOK || !strings.Contains(body, "guide line one") {
182+ t.Errorf("commit page: code %d, diff shown: %v", code, strings.Contains(body, "guide line one"))
183+ }
184+
185+ // Traversal and junk stay 404.
186+ for _, path := range []string{"/nope", "/site/blob/master/../../etc/passwd", "/site/raw/master/%2e%2e/x"} {
187+ if code, _ := get(t, ts.URL+path); code != http.StatusNotFound {
188+ t.Errorf("GET %s = %d, want 404", path, code)
189+ }
190+ }
191+}
192+
193+func TestReadOnlyWithoutToken(t *testing.T) {
194+ store := &Store{Root: t.TempDir()}
195+ srv := &Server{Store: store, Token: ""}
196+ ts := httptest.NewServer(srv.handler())
197+ defer ts.Close()
198+
199+ work := t.TempDir()
200+ mustGit(t, work, "init", "r")
201+ dir := filepath.Join(work, "r")
202+ os.WriteFile(filepath.Join(dir, "x"), []byte("x"), 0o644)
203+ mustGit(t, dir, "add", ".")
204+ mustGit(t, dir, "commit", "-m", "x")
205+
206+ // Even a well-formed credential is refused when no token is configured.
207+ u, _ := url.Parse(ts.URL + "/r.git")
208+ u.User = url.UserPassword("owner", "anything")
209+ if out, err := gitCmd(t, dir, "push", u.String(), "master"); err == nil {
210+ t.Fatalf("push succeeded on tokenless server:\n%s", out)
211+ }
212+}
gitcmd.go+3200
@@ -0,0 +1,320 @@
1+package main
2+
3+import (
4+ "bytes"
5+ "context"
6+ "fmt"
7+ "os"
8+ "os/exec"
9+ "path/filepath"
10+ "regexp"
11+ "sort"
12+ "strconv"
13+ "strings"
14+ "time"
15+)
16+
17+// All repository access goes through the real git binary: frieren owns HTTP,
18+// auth, and rendering, while git owns object storage and the pack protocol.
19+
20+var repoNameRe = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]*$`)
21+
22+// reservedNames are path roots the web UI claims for itself.
23+var reservedNames = map[string]bool{"static": true}
24+
25+func validRepoName(name string) bool {
26+ return repoNameRe.MatchString(name) && !reservedNames[name] && len(name) <= 100
27+}
28+
29+func validRef(ref string) bool {
30+ if ref == "" || len(ref) > 250 || strings.HasPrefix(ref, "-") || strings.Contains(ref, "..") {
31+ return false
32+ }
33+ for _, r := range ref {
34+ if r < 0x20 || r == 0x7f || r == ' ' || r == '~' || r == '^' || r == ':' || r == '\\' {
35+ return false
36+ }
37+ }
38+ return true
39+}
40+
41+func validPath(p string) bool {
42+ if p == "" {
43+ return true
44+ }
45+ if strings.HasPrefix(p, "/") || strings.Contains(p, "\x00") {
46+ return false
47+ }
48+ for _, seg := range strings.Split(p, "/") {
49+ if seg == "" || seg == "." || seg == ".." {
50+ return false
51+ }
52+ }
53+ return true
54+}
55+
56+type Store struct {
57+ Root string
58+}
59+
60+func (s *Store) repoPath(name string) string {
61+ return filepath.Join(s.Root, name+".git")
62+}
63+
64+func (s *Store) exists(name string) bool {
65+ if !validRepoName(name) {
66+ return false
67+ }
68+ fi, err := os.Stat(filepath.Join(s.repoPath(name), "HEAD"))
69+ return err == nil && fi.Mode().IsRegular()
70+}
71+
72+func (s *Store) create(name, description string) error {
73+ if !validRepoName(name) {
74+ return fmt.Errorf("invalid repository name %q", name)
75+ }
76+ if s.exists(name) {
77+ return fmt.Errorf("repository %q already exists", name)
78+ }
79+ if _, err := runGit(context.Background(), s.Root, nil, "init", "--bare", "-b", "master", "--quiet", name+".git"); err != nil {
80+ return err
81+ }
82+ if description != "" {
83+ return os.WriteFile(filepath.Join(s.repoPath(name), "description"), []byte(description+"\n"), 0o644)
84+ }
85+ return nil
86+}
87+
88+// runGit executes git with -C dir and returns stdout. Stderr is folded into
89+// the error so callers can surface git's own explanation.
90+func runGit(ctx context.Context, dir string, stdin []byte, args ...string) ([]byte, error) {
91+ full := append([]string{"-C", dir}, args...)
92+ cmd := exec.CommandContext(ctx, "git", full...)
93+ if stdin != nil {
94+ cmd.Stdin = bytes.NewReader(stdin)
95+ }
96+ var out, errb bytes.Buffer
97+ cmd.Stdout = &out
98+ cmd.Stderr = &errb
99+ if err := cmd.Run(); err != nil {
100+ return nil, fmt.Errorf("git %s: %w: %s", strings.Join(args, " "), err, strings.TrimSpace(errb.String()))
101+ }
102+ return out.Bytes(), nil
103+}
104+
105+type RepoInfo struct {
106+ Name string
107+ Description string
108+ Default string
109+ LastCommit time.Time
110+ Empty bool
111+}
112+
113+func (s *Store) open(name string) (*RepoInfo, error) {
114+ if !s.exists(name) {
115+ return nil, fmt.Errorf("no such repository %q", name)
116+ }
117+ info := &RepoInfo{Name: name, Default: "master"}
118+ dir := s.repoPath(name)
119+ if b, err := os.ReadFile(filepath.Join(dir, "description")); err == nil {
120+ d := strings.TrimSpace(string(b))
121+ if !strings.HasPrefix(d, "Unnamed repository") {
122+ info.Description = d
123+ }
124+ }
125+ if b, err := runGit(context.Background(), dir, nil, "symbolic-ref", "--short", "HEAD"); err == nil {
126+ info.Default = strings.TrimSpace(string(b))
127+ }
128+ b, err := runGit(context.Background(), dir, nil,
129+ "for-each-ref", "--sort=-committerdate", "--count=1", "--format=%(committerdate:unix)", "refs/heads")
130+ if err != nil || len(bytes.TrimSpace(b)) == 0 {
131+ info.Empty = true
132+ return info, nil
133+ }
134+ if unix, err := strconv.ParseInt(strings.TrimSpace(string(b)), 10, 64); err == nil {
135+ info.LastCommit = time.Unix(unix, 0)
136+ }
137+ return info, nil
138+}
139+
140+func (s *Store) list() []*RepoInfo {
141+ entries, err := os.ReadDir(s.Root)
142+ if err != nil {
143+ return nil
144+ }
145+ var repos []*RepoInfo
146+ for _, e := range entries {
147+ if !e.IsDir() || !strings.HasSuffix(e.Name(), ".git") {
148+ continue
149+ }
150+ name := strings.TrimSuffix(e.Name(), ".git")
151+ if info, err := s.open(name); err == nil {
152+ repos = append(repos, info)
153+ }
154+ }
155+ sort.Slice(repos, func(i, j int) bool { return repos[i].LastCommit.After(repos[j].LastCommit) })
156+ return repos
157+}
158+
159+type TreeEntry struct {
160+ Mode string
161+ Type string // blob, tree, commit (submodule)
162+ Hash string
163+ Size int64 // -1 for trees
164+ Name string
165+}
166+
167+func (s *Store) lsTree(ctx context.Context, repo, ref, path string) ([]TreeEntry, error) {
168+ spec := ref
169+ if path != "" {
170+ spec += ":" + path
171+ }
172+ out, err := runGit(ctx, s.repoPath(repo), nil, "ls-tree", "-z", "-l", spec)
173+ if err != nil {
174+ return nil, err
175+ }
176+ var entries []TreeEntry
177+ for _, rec := range bytes.Split(out, []byte{0}) {
178+ if len(rec) == 0 {
179+ continue
180+ }
181+ meta, name, ok := bytes.Cut(rec, []byte{'\t'})
182+ if !ok {
183+ continue
184+ }
185+ f := strings.Fields(string(meta))
186+ if len(f) != 4 {
187+ continue
188+ }
189+ size := int64(-1)
190+ if f[3] != "-" {
191+ size, _ = strconv.ParseInt(f[3], 10, 64)
192+ }
193+ entries = append(entries, TreeEntry{Mode: f[0], Type: f[1], Hash: f[2], Size: size, Name: string(name)})
194+ }
195+ sort.Slice(entries, func(i, j int) bool {
196+ if (entries[i].Type == "tree") != (entries[j].Type == "tree") {
197+ return entries[i].Type == "tree"
198+ }
199+ return entries[i].Name < entries[j].Name
200+ })
201+ return entries, nil
202+}
203+
204+const maxBlobBytes = 2 << 20 // 2 MiB shown in the web UI; raw endpoint streams everything
205+
206+func (s *Store) catBlob(ctx context.Context, repo, ref, path string) ([]byte, error) {
207+ return runGit(ctx, s.repoPath(repo), nil, "cat-file", "blob", ref+":"+path)
208+}
209+
210+type Commit struct {
211+ Hash string
212+ Short string
213+ Author string
214+ When time.Time
215+ Subject string
216+}
217+
218+const logFormat = "%H%x1f%h%x1f%an%x1f%at%x1f%s%x1e"
219+
220+func parseCommits(out []byte) []Commit {
221+ var commits []Commit
222+ for _, rec := range bytes.Split(out, []byte{0x1e}) {
223+ rec = bytes.TrimSpace(rec)
224+ if len(rec) == 0 {
225+ continue
226+ }
227+ f := strings.Split(string(rec), "\x1f")
228+ if len(f) != 5 {
229+ continue
230+ }
231+ unix, _ := strconv.ParseInt(f[3], 10, 64)
232+ commits = append(commits, Commit{Hash: f[0], Short: f[1], Author: f[2], When: time.Unix(unix, 0), Subject: f[4]})
233+ }
234+ return commits
235+}
236+
237+func (s *Store) log(ctx context.Context, repo, ref string, limit int) ([]Commit, error) {
238+ out, err := runGit(ctx, s.repoPath(repo), nil,
239+ "log", "--format="+logFormat, "-n", strconv.Itoa(limit), ref, "--")
240+ if err != nil {
241+ return nil, err
242+ }
243+ return parseCommits(out), nil
244+}
245+
246+func (s *Store) commit(ctx context.Context, repo, hash string) (*Commit, error) {
247+ out, err := runGit(ctx, s.repoPath(repo), nil, "show", "-s", "--format="+logFormat, hash, "--")
248+ if err != nil {
249+ return nil, err
250+ }
251+ commits := parseCommits(out)
252+ if len(commits) == 0 {
253+ return nil, fmt.Errorf("no such commit %q", hash)
254+ }
255+ return &commits[0], nil
256+}
257+
258+const maxDiffBytes = 1 << 20 // 1 MiB of rendered patch per commit page
259+
260+func (s *Store) patch(ctx context.Context, repo, hash string) (string, bool, error) {
261+ out, err := runGit(ctx, s.repoPath(repo), nil,
262+ "show", "--format=", "--stat", "--patch", "--no-color", hash, "--")
263+ if err != nil {
264+ return "", false, err
265+ }
266+ if len(out) > maxDiffBytes {
267+ return string(out[:maxDiffBytes]), true, nil
268+ }
269+ return string(out), false, nil
270+}
271+
272+type Ref struct {
273+ Name string
274+ Short string
275+ When time.Time
276+ Subject string
277+}
278+
279+func (s *Store) refs(ctx context.Context, repo, kind string) ([]Ref, error) {
280+ out, err := runGit(ctx, s.repoPath(repo), nil,
281+ "for-each-ref", "--sort=-creatordate",
282+ "--format=%(refname:short)%1f%(objectname:short)%1f%(creatordate:unix)%1f%(subject)%1e", "refs/"+kind)
283+ if err != nil {
284+ return nil, err
285+ }
286+ var refs []Ref
287+ for _, rec := range bytes.Split(out, []byte{0x1e}) {
288+ rec = bytes.TrimSpace(rec)
289+ if len(rec) == 0 {
290+ continue
291+ }
292+ f := strings.Split(string(rec), "\x1f")
293+ if len(f) != 4 {
294+ continue
295+ }
296+ unix, _ := strconv.ParseInt(f[2], 10, 64)
297+ refs = append(refs, Ref{Name: f[0], Short: f[1], When: time.Unix(unix, 0), Subject: f[3]})
298+ }
299+ return refs, nil
300+}
301+
302+// readme returns the first README-ish blob at the root of ref, if any.
303+func (s *Store) readme(ctx context.Context, repo, ref string) (name string, body []byte) {
304+ entries, err := s.lsTree(ctx, repo, ref, "")
305+ if err != nil {
306+ return "", nil
307+ }
308+ for _, e := range entries {
309+ if e.Type != "blob" {
310+ continue
311+ }
312+ switch strings.ToLower(e.Name) {
313+ case "readme.md", "readme", "readme.txt":
314+ if b, err := s.catBlob(ctx, repo, ref, e.Name); err == nil && len(b) <= maxBlobBytes {
315+ return e.Name, b
316+ }
317+ }
318+ }
319+ return "", nil
320+}
githttp.go+1890
@@ -0,0 +1,189 @@
1+package main
2+
3+import (
4+ "compress/gzip"
5+ "crypto/sha256"
6+ "crypto/subtle"
7+ "fmt"
8+ "io"
9+ "log"
10+ "net/http"
11+ "os"
12+ "os/exec"
13+)
14+
15+// Smart HTTP endpoints. frieren speaks just enough of the transport to route
16+// and authenticate, then hands the byte stream to git's own plumbing:
17+//
18+// GET /{repo}(.git)/info/refs?service=git-upload-pack → ref advertisement (anonymous)
19+// POST /{repo}(.git)/git-upload-pack → clone/fetch (anonymous)
20+// GET /{repo}(.git)/info/refs?service=git-receive-pack → push advertisement (owner only)
21+// POST /{repo}(.git)/git-receive-pack → push (owner only)
22+//
23+// A push to a repository that doesn't exist yet creates it (owner only).
24+
25+func pktLine(s string) string {
26+ return fmt.Sprintf("%04x%s", len(s)+4, s)
27+}
28+
29+// authorized reports whether the request carries the owner token as the HTTP
30+// Basic password. The username is ignored. With no token configured the
31+// server is read-only for everyone, owner included.
32+func (srv *Server) authorized(r *http.Request) bool {
33+ if srv.Token == "" {
34+ return false
35+ }
36+ _, pass, ok := r.BasicAuth()
37+ if !ok {
38+ return false
39+ }
40+ want := sha256.Sum256([]byte(srv.Token))
41+ got := sha256.Sum256([]byte(pass))
42+ return subtle.ConstantTimeCompare(want[:], got[:]) == 1
43+}
44+
45+func requireAuth(w http.ResponseWriter) {
46+ w.Header().Set("WWW-Authenticate", `Basic realm="frieren"`)
47+ http.Error(w, "authentication required", http.StatusUnauthorized)
48+}
49+
50+// ensureRepo resolves the repo for a push, creating it on first push.
51+func (srv *Server) ensureRepo(w http.ResponseWriter, name string) bool {
52+ if srv.Store.exists(name) {
53+ return true
54+ }
55+ if !validRepoName(name) {
56+ http.Error(w, "invalid repository name", http.StatusBadRequest)
57+ return false
58+ }
59+ if err := srv.Store.create(name, ""); err != nil {
60+ log.Printf("create %s: %v", name, err)
61+ http.Error(w, "could not create repository", http.StatusInternalServerError)
62+ return false
63+ }
64+ log.Printf("created repository %s.git", name)
65+ return true
66+}
67+
68+func (srv *Server) infoRefs(w http.ResponseWriter, r *http.Request) {
69+ name := repoParam(r)
70+ service := r.URL.Query().Get("service")
71+ switch service {
72+ case "git-upload-pack":
73+ if !srv.Store.exists(name) {
74+ http.NotFound(w, r)
75+ return
76+ }
77+ case "git-receive-pack":
78+ if !srv.authorized(r) {
79+ requireAuth(w)
80+ return
81+ }
82+ if !srv.ensureRepo(w, name) {
83+ return
84+ }
85+ default:
86+ http.Error(w, "smart HTTP only", http.StatusForbidden)
87+ return
88+ }
89+
90+ sub := service[len("git-"):]
91+ cmd := exec.CommandContext(r.Context(), "git", sub, "--stateless-rpc", "--advertise-refs", srv.Store.repoPath(name))
92+ cmd.Env = gitEnv(r)
93+ out, err := cmd.Output()
94+ if err != nil {
95+ log.Printf("%s advertise %s: %v", sub, name, err)
96+ http.Error(w, "git error", http.StatusInternalServerError)
97+ return
98+ }
99+ w.Header().Set("Content-Type", "application/x-"+service+"-advertisement")
100+ w.Header().Set("Cache-Control", "no-cache")
101+ io.WriteString(w, pktLine("# service="+service+"\n"))
102+ io.WriteString(w, "0000")
103+ w.Write(out)
104+}
105+
106+func (srv *Server) uploadPack(w http.ResponseWriter, r *http.Request) {
107+ name := repoParam(r)
108+ if !srv.Store.exists(name) {
109+ http.NotFound(w, r)
110+ return
111+ }
112+ srv.serviceRPC(w, r, name, "upload-pack")
113+}
114+
115+func (srv *Server) receivePack(w http.ResponseWriter, r *http.Request) {
116+ name := repoParam(r)
117+ if !srv.authorized(r) {
118+ requireAuth(w)
119+ return
120+ }
121+ if !srv.ensureRepo(w, name) {
122+ return
123+ }
124+ srv.serviceRPC(w, r, name, "receive-pack")
125+}
126+
127+func (srv *Server) serviceRPC(w http.ResponseWriter, r *http.Request, name, sub string) {
128+ body := io.Reader(r.Body)
129+ if r.Header.Get("Content-Encoding") == "gzip" {
130+ gz, err := gzip.NewReader(body)
131+ if err != nil {
132+ http.Error(w, "bad gzip body", http.StatusBadRequest)
133+ return
134+ }
135+ defer gz.Close()
136+ body = gz
137+ }
138+
139+ cmd := exec.CommandContext(r.Context(), "git", sub, "--stateless-rpc", srv.Store.repoPath(name))
140+ cmd.Env = gitEnv(r)
141+ cmd.Stdin = body
142+ cmd.Stderr = os.Stderr
143+
144+ stdout, err := cmd.StdoutPipe()
145+ if err != nil {
146+ http.Error(w, "git error", http.StatusInternalServerError)
147+ return
148+ }
149+ if err := cmd.Start(); err != nil {
150+ log.Printf("%s %s: %v", sub, name, err)
151+ http.Error(w, "git error", http.StatusInternalServerError)
152+ return
153+ }
154+
155+ w.Header().Set("Content-Type", "application/x-git-"+sub+"-result")
156+ w.Header().Set("Cache-Control", "no-cache")
157+ io.Copy(newFlushWriter(w), stdout)
158+ if err := cmd.Wait(); err != nil {
159+ // Headers are already sent; all we can do is log.
160+ log.Printf("%s %s: %v", sub, name, err)
161+ }
162+}
163+
164+// gitEnv forwards the client's Git-Protocol header so protocol v2 works.
165+func gitEnv(r *http.Request) []string {
166+ env := os.Environ()
167+ if p := r.Header.Get("Git-Protocol"); p != "" {
168+ env = append(env, "GIT_PROTOCOL="+p)
169+ }
170+ return env
171+}
172+
173+type flushWriter struct {
174+ w io.Writer
175+ f http.Flusher
176+}
177+
178+func newFlushWriter(w http.ResponseWriter) io.Writer {
179+ if f, ok := w.(http.Flusher); ok {
180+ return &flushWriter{w: w, f: f}
181+ }
182+ return w
183+}
184+
185+func (fw *flushWriter) Write(p []byte) (int, error) {
186+ n, err := fw.w.Write(p)
187+ fw.f.Flush()
188+ return n, err
189+}
main.go+1010
@@ -0,0 +1,101 @@
1+package main
2+
3+import (
4+ "crypto/rand"
5+ "encoding/hex"
6+ "flag"
7+ "fmt"
8+ "log"
9+ "net/http"
10+ "os"
11+ "path/filepath"
12+)
13+
14+var version = "dev"
15+
16+const usage = `frieren — a single-binary, self-hosted git server.
17+Anyone can browse and clone; only the holder of the token can push.
18+
19+Usage:
20+ frieren serve [-addr :7420] [-root DIR] [-token TOKEN]
21+ frieren init <name> [description] create an empty repository under the root
22+ frieren token generate a random owner token
23+ frieren version
24+
25+Environment:
26+ FRIEREN_ADDR listen address (default :7420)
27+ FRIEREN_ROOT repository directory (default ./repos)
28+ FRIEREN_TOKEN owner token; unset = server is read-only for everyone
29+`
30+
31+func envOr(key, fallback string) string {
32+ if v := os.Getenv(key); v != "" {
33+ return v
34+ }
35+ return fallback
36+}
37+
38+func main() {
39+ log.SetFlags(log.LstdFlags)
40+ if len(os.Args) < 2 {
41+ fmt.Fprint(os.Stderr, usage)
42+ os.Exit(2)
43+ }
44+ switch os.Args[1] {
45+ case "serve":
46+ serve(os.Args[2:])
47+ case "init":
48+ initRepo(os.Args[2:])
49+ case "token":
50+ buf := make([]byte, 32)
51+ rand.Read(buf)
52+ fmt.Println(hex.EncodeToString(buf))
53+ case "version":
54+ fmt.Println("frieren", version)
55+ default:
56+ fmt.Fprint(os.Stderr, usage)
57+ os.Exit(2)
58+ }
59+}
60+
61+func serve(args []string) {
62+ fs := flag.NewFlagSet("serve", flag.ExitOnError)
63+ addr := fs.String("addr", envOr("FRIEREN_ADDR", ":7420"), "listen address")
64+ root := fs.String("root", envOr("FRIEREN_ROOT", "./repos"), "repository directory")
65+ token := fs.String("token", os.Getenv("FRIEREN_TOKEN"), "owner token (push access)")
66+ fs.Parse(args)
67+
68+ absRoot, err := filepath.Abs(*root)
69+ if err != nil {
70+ log.Fatal(err)
71+ }
72+ if err := os.MkdirAll(absRoot, 0o755); err != nil {
73+ log.Fatal(err)
74+ }
75+
76+ srv := &Server{Store: &Store{Root: absRoot}, Token: *token}
77+ if srv.Token == "" {
78+ log.Print("WARNING: no token configured (FRIEREN_TOKEN) — pushing is disabled, serving read-only")
79+ }
80+ log.Printf("frieren %s serving %s on %s", version, absRoot, *addr)
81+ log.Fatal(http.ListenAndServe(*addr, srv.handler()))
82+}
83+
84+func initRepo(args []string) {
85+ fs := flag.NewFlagSet("init", flag.ExitOnError)
86+ root := fs.String("root", envOr("FRIEREN_ROOT", "./repos"), "repository directory")
87+ fs.Parse(args)
88+ if fs.NArg() < 1 {
89+ fmt.Fprint(os.Stderr, usage)
90+ os.Exit(2)
91+ }
92+ name, desc := fs.Arg(0), fs.Arg(1)
93+ if err := os.MkdirAll(*root, 0o755); err != nil {
94+ log.Fatal(err)
95+ }
96+ store := &Store{Root: *root}
97+ if err := store.create(name, desc); err != nil {
98+ log.Fatal(err)
99+ }
100+ fmt.Printf("created %s\n", store.repoPath(name))
101+}
server.go+420
@@ -0,0 +1,42 @@
1+package main
2+
3+import (
4+ "net/http"
5+ "strings"
6+)
7+
8+type Server struct {
9+ Store *Store
10+ Token string
11+}
12+
13+// repoParam extracts the repository name from the route, accepting both
14+// /name and /name.git so one URL works in the browser and in git.
15+func repoParam(r *http.Request) string {
16+ return strings.TrimSuffix(r.PathValue("repo"), ".git")
17+}
18+
19+func (srv *Server) handler() http.Handler {
20+ mux := http.NewServeMux()
21+
22+ // Smart HTTP (git clients)
23+ mux.HandleFunc("GET /{repo}/info/refs", srv.infoRefs)
24+ mux.HandleFunc("POST /{repo}/git-upload-pack", srv.uploadPack)
25+ mux.HandleFunc("POST /{repo}/git-receive-pack", srv.receivePack)
26+
27+ // Web UI (browsers, read-only)
28+ mux.HandleFunc("GET /{$}", srv.indexPage)
29+ mux.HandleFunc("GET /static/style.css", srv.styleCSS)
30+ mux.HandleFunc("GET /{repo}", srv.repoPage)
31+ mux.HandleFunc("GET /{repo}/{$}", srv.repoPage)
32+ mux.HandleFunc("GET /{repo}/tree/{ref}", srv.treePage)
33+ mux.HandleFunc("GET /{repo}/tree/{ref}/{path...}", srv.treePage)
34+ mux.HandleFunc("GET /{repo}/blob/{ref}/{path...}", srv.blobPage)
35+ mux.HandleFunc("GET /{repo}/raw/{ref}/{path...}", srv.rawFile)
36+ mux.HandleFunc("GET /{repo}/commits", srv.commitsPage)
37+ mux.HandleFunc("GET /{repo}/commits/{ref}", srv.commitsPage)
38+ mux.HandleFunc("GET /{repo}/commit/{hash}", srv.commitPage)
39+ mux.HandleFunc("GET /{repo}/refs", srv.refsPage)
40+
41+ return mux
42+}
static/style.css+1240
@@ -0,0 +1,124 @@
1+:root {
2+ --bg: #fdfdfc;
3+ --fg: #1c1e21;
4+ --muted: #6b7280;
5+ --line: #e5e7eb;
6+ --accent: #0f766e;
7+ --card: #f4f5f4;
8+ --add: #dcfce7;
9+ --add-fg: #14532d;
10+ --del: #fee2e2;
11+ --del-fg: #7f1d1d;
12+ --hunk: #eff6ff;
13+ --hunk-fg: #1e40af;
14+}
15+@media (prefers-color-scheme: dark) {
16+ :root {
17+ --bg: #0c0c0f;
18+ --fg: #d7dae0;
19+ --muted: #8b93a1;
20+ --line: #26282e;
21+ --accent: #5eead4;
22+ --card: #131418;
23+ --add: #0d2818;
24+ --add-fg: #86efac;
25+ --del: #2d1214;
26+ --del-fg: #fca5a5;
27+ --hunk: #11203a;
28+ --hunk-fg: #93c5fd;
29+ }
30+}
31+
32+* { box-sizing: border-box; }
33+body {
34+ margin: 0;
35+ background: var(--bg);
36+ color: var(--fg);
37+ font: 15px/1.55 system-ui, -apple-system, "Segoe UI", sans-serif;
38+}
39+main { max-width: 960px; margin: 0 auto; padding: 0 1rem 3rem; }
40+a { color: var(--accent); text-decoration: none; }
41+a:hover { text-decoration: underline; }
42+code, pre, .hash, .mode, .clone { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
43+
44+header.site {
45+ border-bottom: 1px solid var(--line);
46+ padding: 0.7rem 1rem;
47+ margin-bottom: 1.5rem;
48+}
49+.wordmark {
50+ font-weight: 700;
51+ letter-spacing: 0.06em;
52+ color: var(--fg);
53+}
54+.wordmark::before { content: "❄ "; color: var(--accent); }
55+footer.site {
56+ border-top: 1px solid var(--line);
57+ color: var(--muted);
58+ font-size: 0.8rem;
59+ padding: 1rem;
60+ margin-top: 2rem;
61+ text-align: center;
62+}
63+
64+h1 { font-size: 1.25rem; margin: 0 0 0.25rem; }
65+h1 a { color: var(--fg); }
66+h1.home { margin-bottom: 1rem; }
67+h2 { font-size: 1rem; margin: 1.5rem 0 0.5rem; }
68+.desc { color: var(--muted); margin: 0 0 0.5rem; }
69+
70+.tabs { display: flex; align-items: center; gap: 1rem; border-bottom: 1px solid var(--line); padding: 0.4rem 0; margin-bottom: 1rem; flex-wrap: wrap; }
71+.tabs a { color: var(--muted); }
72+.tabs a.on { color: var(--fg); font-weight: 600; }
73+.tabs .ref { font-size: 0.8rem; color: var(--accent); border: 1px solid var(--line); border-radius: 3px; padding: 0 0.4rem; }
74+.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; }
75+
76+table { border-collapse: collapse; width: 100%; }
77+td { padding: 0.35rem 0.75rem 0.35rem 0; border-bottom: 1px solid var(--line); vertical-align: baseline; }
78+tr:last-child td { border-bottom: 0; }
79+td.mode { color: var(--muted); font-size: 0.75rem; width: 6.5ch; }
80+td.size, td.when { color: var(--muted); font-size: 0.85rem; text-align: right; white-space: nowrap; width: 1%; }
81+td.hash { width: 9ch; }
82+td.author { color: var(--muted); white-space: nowrap; }
83+td.name { white-space: nowrap; }
84+.repos td.desc, td.subject { color: var(--muted); max-width: 0; width: 99%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
85+.repos td.name { font-weight: 600; }
86+
87+.crumbs { color: var(--muted); }
88+.crumbs .filemeta { float: right; font-size: 0.85rem; }
89+
90+pre {
91+ background: var(--card);
92+ border: 1px solid var(--line);
93+ border-radius: 4px;
94+ padding: 0.75rem 1rem;
95+ overflow-x: auto;
96+ font-size: 0.85rem;
97+ line-height: 1.5;
98+}
99+pre.code { counter-reset: l; padding-left: 0; }
100+pre.code .cl::before {
101+ counter-increment: l;
102+ content: counter(l);
103+ display: inline-block;
104+ width: 3.5ch;
105+ margin-right: 1.5ch;
106+ padding-right: 0.5ch;
107+ text-align: right;
108+ color: var(--muted);
109+ user-select: none;
110+}
111+
112+pre.diff { padding-left: 1rem; }
113+.d-meta { color: var(--muted); }
114+.d-file { font-weight: 600; }
115+.d-hunk { display: inline-block; width: 100%; background: var(--hunk); color: var(--hunk-fg); }
116+.d-add { display: inline-block; width: 100%; background: var(--add); color: var(--add-fg); }
117+.d-del { display: inline-block; width: 100%; background: var(--del); color: var(--del-fg); }
118+
119+.commitmeta p { margin: 0.15rem 0; color: var(--muted); }
120+.commitmeta code { font-size: 0.8rem; }
121+
122+.empty { color: var(--muted); margin: 2rem 0; }
123+.readme { margin-top: 2rem; }
124+.readme pre { white-space: pre-wrap; }
templates/blob.html+140
@@ -0,0 +1,14 @@
1+{{define "blob"}}{{template "pagehead" .}}
2+{{template "repohead" .}}
3+{{$p := .}}
4+<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>
5+ <span class="filemeta">{{size .Bytes}} · <a href="/{{.Repo.Name}}/raw/{{pesc .Ref}}/{{.RawPath}}">raw</a></span></p>
6+{{if .Binary}}
7+<div class="empty"><p>Binary file ({{size .Bytes}}). <a href="/{{.Repo.Name}}/raw/{{pesc .Ref}}/{{.RawPath}}">Download raw.</a></p></div>
8+{{else if .Truncated}}
9+<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>
10+{{else}}
11+<pre class="code"><code>{{range .Lines}}<span class="cl">{{.}}
12+</span>{{end}}</code></pre>
13+{{end}}
14+{{template "pagefoot" .}}{{end}}
templates/commit.html+110
@@ -0,0 +1,11 @@
1+{{define "commit"}}{{template "pagehead" .}}
2+{{template "repohead" .}}
3+<div class="commitmeta">
4+ <h2>{{.Commit.Subject}}</h2>
5+ <p><code>{{.Commit.Hash}}</code></p>
6+ <p>{{.Commit.Author}} · {{.Commit.When.Format "Jan 2, 2006 15:04"}} ({{ago .Commit.When}})</p>
7+</div>
8+<pre class="diff"><code>{{range .Diff}}<span class="d-{{.Class}}">{{.Text}}
9+</span>{{end}}</code></pre>
10+{{if .Truncated}}<p class="empty">Diff truncated — view the full change locally with <code>git show {{.Commit.Short}}</code>.</p>{{end}}
11+{{template "pagefoot" .}}{{end}}
templates/commits.html+180
@@ -0,0 +1,18 @@
1+{{define "commits"}}{{template "pagehead" .}}
2+{{template "repohead" .}}
3+{{$p := .}}
4+{{if .Commits}}
5+<table class="commits">
6+ {{range .Commits}}
7+ <tr>
8+ <td class="hash"><a href="/{{$p.Repo.Name}}/commit/{{.Hash}}">{{.Short}}</a></td>
9+ <td class="subject"><a href="/{{$p.Repo.Name}}/commit/{{.Hash}}">{{.Subject}}</a></td>
10+ <td class="author">{{.Author}}</td>
11+ <td class="when">{{ago .When}}</td>
12+ </tr>
13+ {{end}}
14+</table>
15+{{else}}
16+<div class="empty"><p>No commits yet.</p></div>
17+{{end}}
18+{{template "pagefoot" .}}{{end}}
templates/index.html+210
@@ -0,0 +1,21 @@
1+{{define "index"}}{{template "pagehead" .}}
2+<h1 class="home">repositories</h1>
3+{{if .Repos}}
4+<table class="repos">
5+ {{range .Repos}}
6+ <tr>
7+ <td class="name"><a href="/{{.Name}}">{{.Name}}</a></td>
8+ <td class="desc">{{.Description}}</td>
9+ <td class="when">{{if .Empty}}empty{{else}}{{ago .LastCommit}}{{end}}</td>
10+ </tr>
11+ {{end}}
12+</table>
13+{{else}}
14+<div class="empty">
15+ <p>No repositories yet. The owner creates one by pushing to it:</p>
16+ <pre>git remote add frieren {{.Base}}/myrepo.git
17+git push frieren master</pre>
18+ <p>git will ask for credentials — any username, the owner token as the password.</p>
19+</div>
20+{{end}}
21+{{template "pagefoot" .}}{{end}}
templates/notfound.html+60
@@ -0,0 +1,6 @@
1+{{define "notfound"}}{{template "pagehead" .}}
2+<div class="empty">
3+ <h1>404</h1>
4+ <p>Nothing here. <a href="/">Back to the repositories.</a></p>
5+</div>
6+{{template "pagefoot" .}}{{end}}
templates/parts.html+350
@@ -0,0 +1,35 @@
1+{{define "pagehead"}}<!doctype html>
2+<html lang="en">
3+<head>
4+<meta charset="utf-8">
5+<meta name="viewport" content="width=device-width, initial-scale=1">
6+<title>{{.Title}}</title>
7+<link rel="stylesheet" href="/static/style.css">
8+<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>">
9+</head>
10+<body>
11+<header class="site">
12+ <a class="wordmark" href="/">frieren</a>
13+</header>
14+<main>
15+{{end}}
16+
17+{{define "pagefoot"}}
18+</main>
19+<footer class="site">served by <a href="https://github.com/justin06lee/frieren">frieren</a> — clone freely; only the owner writes.</footer>
20+</body>
21+</html>{{end}}
22+
23+{{define "repohead"}}
24+<div class="repohead">
25+ <h1><a href="/">~</a> / <a href="/{{.Repo.Name}}">{{.Repo.Name}}</a></h1>
26+ {{if .Repo.Description}}<p class="desc">{{.Repo.Description}}</p>{{end}}
27+ <nav class="tabs">
28+ <a {{if eq .Tab "files"}}class="on"{{end}} href="/{{.Repo.Name}}">files</a>
29+ <a {{if eq .Tab "commits"}}class="on"{{end}} href="/{{.Repo.Name}}/commits">commits</a>
30+ <a {{if eq .Tab "refs"}}class="on"{{end}} href="/{{.Repo.Name}}/refs">refs</a>
31+ {{if .Ref}}<span class="ref" title="ref">{{.Ref}}</span>{{end}}
32+ <code class="clone">git clone {{.CloneURL}}</code>
33+ </nav>
34+</div>
35+{{end}}
templates/refs.html+300
@@ -0,0 +1,30 @@
1+{{define "refs"}}{{template "pagehead" .}}
2+{{template "repohead" .}}
3+{{$p := .}}
4+<h2>branches</h2>
5+{{if .Branches}}
6+<table class="commits">
7+ {{range .Branches}}
8+ <tr>
9+ <td class="name"><a href="/{{$p.Repo.Name}}/tree/{{pesc .Name}}">{{.Name}}</a></td>
10+ <td class="hash"><a href="/{{$p.Repo.Name}}/commits/{{pesc .Name}}">{{.Short}}</a></td>
11+ <td class="subject">{{.Subject}}</td>
12+ <td class="when">{{ago .When}}</td>
13+ </tr>
14+ {{end}}
15+</table>
16+{{else}}<p class="empty">none</p>{{end}}
17+<h2>tags</h2>
18+{{if .Tags}}
19+<table class="commits">
20+ {{range .Tags}}
21+ <tr>
22+ <td class="name">{{.Name}}</td>
23+ <td class="hash">{{.Short}}</td>
24+ <td class="subject">{{.Subject}}</td>
25+ <td class="when">{{ago .When}}</td>
26+ </tr>
27+ {{end}}
28+</table>
29+{{else}}<p class="empty">none</p>{{end}}
30+{{template "pagefoot" .}}{{end}}
templates/repo.html+320
@@ -0,0 +1,32 @@
1+{{define "repo"}}{{template "pagehead" .}}
2+{{template "repohead" .}}
3+{{if .Repo.Empty}}
4+<div class="empty">
5+ <p>This repository is empty. The owner fills it with:</p>
6+ <pre>git remote add frieren {{.CloneURL}}
7+git push frieren master</pre>
8+</div>
9+{{else}}
10+<table class="tree">
11+ {{$p := .}}
12+ {{range .Entries}}
13+ <tr>
14+ <td class="mode">{{.Mode}}</td>
15+ {{if eq .Type "tree"}}
16+ <td class="name dir"><a href="/{{$p.Repo.Name}}/tree/{{pesc $p.Ref}}/{{.Name}}">{{.Name}}/</a></td>
17+ <td class="size"></td>
18+ {{else}}
19+ <td class="name"><a href="/{{$p.Repo.Name}}/blob/{{pesc $p.Ref}}/{{.Name}}">{{.Name}}</a></td>
20+ <td class="size">{{size .Size}}</td>
21+ {{end}}
22+ </tr>
23+ {{end}}
24+</table>
25+{{if .ReadmeName}}
26+<section class="readme">
27+ <h2>{{.ReadmeName}}</h2>
28+ <pre>{{.Readme}}</pre>
29+</section>
30+{{end}}
31+{{end}}
32+{{template "pagefoot" .}}{{end}}
templates/tree.html+200
@@ -0,0 +1,20 @@
1+{{define "tree"}}{{template "pagehead" .}}
2+{{template "repohead" .}}
3+{{$p := .}}
4+<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>
5+<table class="tree">
6+ {{$base := ""}}{{if .Dir}}{{$base = printf "%s/" .Dir}}{{end}}
7+ {{range .Entries}}
8+ <tr>
9+ <td class="mode">{{.Mode}}</td>
10+ {{if eq .Type "tree"}}
11+ <td class="name dir"><a href="/{{$p.Repo.Name}}/tree/{{pesc $p.Ref}}/{{$base}}{{.Name}}">{{.Name}}/</a></td>
12+ <td class="size"></td>
13+ {{else}}
14+ <td class="name"><a href="/{{$p.Repo.Name}}/blob/{{pesc $p.Ref}}/{{$base}}{{.Name}}">{{.Name}}</a></td>
15+ <td class="size">{{size .Size}}</td>
16+ {{end}}
17+ </tr>
18+ {{end}}
19+</table>
20+{{template "pagefoot" .}}{{end}}
web.go+3720
@@ -0,0 +1,372 @@
1+package main
2+
3+import (
4+ "bytes"
5+ "embed"
6+ "fmt"
7+ "html/template"
8+ "log"
9+ "net/http"
10+ "net/url"
11+ "strings"
12+ "time"
13+)
14+
15+//go:embed templates/*.html
16+var templateFS embed.FS
17+
18+//go:embed static/style.css
19+var styleSheet []byte
20+
21+var funcs = template.FuncMap{
22+ "pesc": url.PathEscape,
23+ "ago": timeAgo,
24+ "size": byteSize,
25+}
26+
27+var pages = template.Must(template.New("").Funcs(funcs).ParseFS(templateFS, "templates/*.html"))
28+
29+func timeAgo(t time.Time) string {
30+ if t.IsZero() {
31+ return ""
32+ }
33+ d := time.Since(t)
34+ switch {
35+ case d < time.Minute:
36+ return "just now"
37+ case d < time.Hour:
38+ return fmt.Sprintf("%dm ago", int(d.Minutes()))
39+ case d < 24*time.Hour:
40+ return fmt.Sprintf("%dh ago", int(d.Hours()))
41+ case d < 30*24*time.Hour:
42+ return fmt.Sprintf("%dd ago", int(d.Hours()/24))
43+ default:
44+ return t.Format("Jan 2, 2006")
45+ }
46+}
47+
48+func byteSize(n int64) string {
49+ switch {
50+ case n < 0:
51+ return ""
52+ case n < 1024:
53+ return fmt.Sprintf("%d B", n)
54+ case n < 1<<20:
55+ return fmt.Sprintf("%.1f KB", float64(n)/1024)
56+ default:
57+ return fmt.Sprintf("%.1f MB", float64(n)/(1<<20))
58+ }
59+}
60+
61+func (srv *Server) render(w http.ResponseWriter, name string, data any) {
62+ var buf bytes.Buffer
63+ if err := pages.ExecuteTemplate(&buf, name, data); err != nil {
64+ log.Printf("render %s: %v", name, err)
65+ http.Error(w, "template error", http.StatusInternalServerError)
66+ return
67+ }
68+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
69+ buf.WriteTo(w)
70+}
71+
72+func (srv *Server) styleCSS(w http.ResponseWriter, r *http.Request) {
73+ w.Header().Set("Content-Type", "text/css; charset=utf-8")
74+ w.Header().Set("Cache-Control", "max-age=3600")
75+ w.Write(styleSheet)
76+}
77+
78+func baseURL(r *http.Request) string {
79+ scheme := "http"
80+ if r.TLS != nil {
81+ scheme = "https"
82+ }
83+ if p := r.Header.Get("X-Forwarded-Proto"); p != "" {
84+ scheme = p
85+ }
86+ return scheme + "://" + r.Host
87+}
88+
89+// page is the data every template receives.
90+type page struct {
91+ Title string
92+ Repo *RepoInfo
93+ Ref string
94+ Path string
95+ CloneURL string
96+ Tab string
97+ Base string
98+}
99+
100+func (srv *Server) newPage(r *http.Request, repo *RepoInfo, ref, tab string) page {
101+ p := page{Title: "frieren", Tab: tab, Base: baseURL(r)}
102+ if repo != nil {
103+ p.Repo = repo
104+ p.Ref = ref
105+ p.Title = repo.Name + " · frieren"
106+ p.CloneURL = baseURL(r) + "/" + repo.Name + ".git"
107+ }
108+ return p
109+}
110+
111+// openRepo loads the repo named in the route or writes a 404.
112+func (srv *Server) openRepo(w http.ResponseWriter, r *http.Request) *RepoInfo {
113+ info, err := srv.Store.open(repoParam(r))
114+ if err != nil {
115+ srv.notFound(w, r)
116+ return nil
117+ }
118+ return info
119+}
120+
121+func (srv *Server) notFound(w http.ResponseWriter, r *http.Request) {
122+ w.WriteHeader(http.StatusNotFound)
123+ srv.render(w, "notfound", page{Title: "not found · frieren"})
124+}
125+
126+func (srv *Server) indexPage(w http.ResponseWriter, r *http.Request) {
127+ srv.render(w, "index", struct {
128+ page
129+ Repos []*RepoInfo
130+ }{srv.newPage(r, nil, "", ""), srv.Store.list()})
131+}
132+
133+func (srv *Server) repoPage(w http.ResponseWriter, r *http.Request) {
134+ repo := srv.openRepo(w, r)
135+ if repo == nil {
136+ return
137+ }
138+ data := struct {
139+ page
140+ Entries []TreeEntry
141+ ReadmeName string
142+ Readme string
143+ }{page: srv.newPage(r, repo, repo.Default, "files")}
144+ if !repo.Empty {
145+ entries, err := srv.Store.lsTree(r.Context(), repo.Name, repo.Default, "")
146+ if err == nil {
147+ data.Entries = entries
148+ }
149+ if name, body := srv.Store.readme(r.Context(), repo.Name, repo.Default); name != "" && !isBinary(body) {
150+ data.ReadmeName = name
151+ data.Readme = string(body)
152+ }
153+ }
154+ srv.render(w, "repo", data)
155+}
156+
157+type crumb struct {
158+ Name string
159+ Path string
160+}
161+
162+func crumbs(path string) []crumb {
163+ if path == "" {
164+ return nil
165+ }
166+ parts := strings.Split(path, "/")
167+ out := make([]crumb, len(parts))
168+ for i, part := range parts {
169+ out[i] = crumb{Name: part, Path: strings.Join(parts[:i+1], "/")}
170+ }
171+ return out
172+}
173+
174+func (srv *Server) treePage(w http.ResponseWriter, r *http.Request) {
175+ repo := srv.openRepo(w, r)
176+ if repo == nil {
177+ return
178+ }
179+ ref, path := r.PathValue("ref"), r.PathValue("path")
180+ if !validRef(ref) || !validPath(path) {
181+ srv.notFound(w, r)
182+ return
183+ }
184+ entries, err := srv.Store.lsTree(r.Context(), repo.Name, ref, path)
185+ if err != nil {
186+ srv.notFound(w, r)
187+ return
188+ }
189+ srv.render(w, "tree", struct {
190+ page
191+ Entries []TreeEntry
192+ Crumbs []crumb
193+ Dir string
194+ }{srv.newPage(r, repo, ref, "files"), entries, crumbs(path), path})
195+}
196+
197+func isBinary(b []byte) bool {
198+ if len(b) > 8000 {
199+ b = b[:8000]
200+ }
201+ return bytes.IndexByte(b, 0) >= 0
202+}
203+
204+func (srv *Server) blobPage(w http.ResponseWriter, r *http.Request) {
205+ repo := srv.openRepo(w, r)
206+ if repo == nil {
207+ return
208+ }
209+ ref, path := r.PathValue("ref"), r.PathValue("path")
210+ if !validRef(ref) || !validPath(path) || path == "" {
211+ srv.notFound(w, r)
212+ return
213+ }
214+ blob, err := srv.Store.catBlob(r.Context(), repo.Name, ref, path)
215+ if err != nil {
216+ srv.notFound(w, r)
217+ return
218+ }
219+ all := crumbs(path)
220+ data := struct {
221+ page
222+ Parents []crumb
223+ FileName string
224+ RawPath string
225+ Lines []string
226+ Bytes int64
227+ Binary bool
228+ Truncated bool
229+ }{
230+ page: srv.newPage(r, repo, ref, "files"),
231+ Parents: all[:len(all)-1],
232+ FileName: all[len(all)-1].Name,
233+ RawPath: path,
234+ Bytes: int64(len(blob)),
235+ }
236+ switch {
237+ case isBinary(blob):
238+ data.Binary = true
239+ case len(blob) > maxBlobBytes:
240+ data.Truncated = true
241+ default:
242+ data.Lines = strings.Split(strings.TrimSuffix(string(blob), "\n"), "\n")
243+ }
244+ srv.render(w, "blob", data)
245+}
246+
247+func (srv *Server) rawFile(w http.ResponseWriter, r *http.Request) {
248+ repo := srv.openRepo(w, r)
249+ if repo == nil {
250+ return
251+ }
252+ ref, path := r.PathValue("ref"), r.PathValue("path")
253+ if !validRef(ref) || !validPath(path) || path == "" {
254+ http.NotFound(w, r)
255+ return
256+ }
257+ blob, err := srv.Store.catBlob(r.Context(), repo.Name, ref, path)
258+ if err != nil {
259+ http.NotFound(w, r)
260+ return
261+ }
262+ // Never let the browser interpret repository content as HTML.
263+ w.Header().Set("X-Content-Type-Options", "nosniff")
264+ if isBinary(blob) {
265+ w.Header().Set("Content-Type", "application/octet-stream")
266+ } else {
267+ w.Header().Set("Content-Type", "text/plain; charset=utf-8")
268+ }
269+ w.Write(blob)
270+}
271+
272+func (srv *Server) commitsPage(w http.ResponseWriter, r *http.Request) {
273+ repo := srv.openRepo(w, r)
274+ if repo == nil {
275+ return
276+ }
277+ ref := r.PathValue("ref")
278+ if ref == "" {
279+ ref = repo.Default
280+ }
281+ if !validRef(ref) {
282+ srv.notFound(w, r)
283+ return
284+ }
285+ var commits []Commit
286+ if !repo.Empty {
287+ var err error
288+ commits, err = srv.Store.log(r.Context(), repo.Name, ref, 100)
289+ if err != nil {
290+ srv.notFound(w, r)
291+ return
292+ }
293+ }
294+ srv.render(w, "commits", struct {
295+ page
296+ Commits []Commit
297+ }{srv.newPage(r, repo, ref, "commits"), commits})
298+}
299+
300+type diffLine struct {
301+ Class string
302+ Text string
303+}
304+
305+func classifyPatch(patch string) []diffLine {
306+ if patch == "" {
307+ return nil
308+ }
309+ lines := strings.Split(strings.TrimSuffix(patch, "\n"), "\n")
310+ out := make([]diffLine, len(lines))
311+ for i, l := range lines {
312+ class := "ctx"
313+ switch {
314+ case strings.HasPrefix(l, "diff --git") || strings.HasPrefix(l, "index ") ||
315+ strings.HasPrefix(l, "new file") || strings.HasPrefix(l, "deleted file") ||
316+ strings.HasPrefix(l, "similarity ") || strings.HasPrefix(l, "rename "):
317+ class = "meta"
318+ case strings.HasPrefix(l, "+++") || strings.HasPrefix(l, "---"):
319+ class = "file"
320+ case strings.HasPrefix(l, "@@"):
321+ class = "hunk"
322+ case strings.HasPrefix(l, "+"):
323+ class = "add"
324+ case strings.HasPrefix(l, "-"):
325+ class = "del"
326+ }
327+ out[i] = diffLine{Class: class, Text: l}
328+ }
329+ return out
330+}
331+
332+func (srv *Server) commitPage(w http.ResponseWriter, r *http.Request) {
333+ repo := srv.openRepo(w, r)
334+ if repo == nil {
335+ return
336+ }
337+ hash := r.PathValue("hash")
338+ if !validRef(hash) {
339+ srv.notFound(w, r)
340+ return
341+ }
342+ commit, err := srv.Store.commit(r.Context(), repo.Name, hash)
343+ if err != nil {
344+ srv.notFound(w, r)
345+ return
346+ }
347+ patch, truncated, err := srv.Store.patch(r.Context(), repo.Name, commit.Hash)
348+ if err != nil {
349+ srv.notFound(w, r)
350+ return
351+ }
352+ srv.render(w, "commit", struct {
353+ page
354+ Commit *Commit
355+ Diff []diffLine
356+ Truncated bool
357+ }{srv.newPage(r, repo, "", "commits"), commit, classifyPatch(patch), truncated})
358+}
359+
360+func (srv *Server) refsPage(w http.ResponseWriter, r *http.Request) {
361+ repo := srv.openRepo(w, r)
362+ if repo == nil {
363+ return
364+ }
365+ branches, _ := srv.Store.refs(r.Context(), repo.Name, "heads")
366+ tags, _ := srv.Store.refs(r.Context(), repo.Name, "tags")
367+ srv.render(w, "refs", struct {
368+ page
369+ Branches []Ref
370+ Tags []Ref
371+ }{srv.newPage(r, repo, "", "refs"), branches, tags})
372+}
0373