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(+)
| @@ -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 | + | } |
| @@ -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 | + | } |
| @@ -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 | + | } |
| @@ -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 | + | } |
| @@ -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 | + | } |
| @@ -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; } |
| @@ -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}} |
| @@ -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}} |
| @@ -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}} |
| @@ -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}} |
| @@ -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}} |
| @@ -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}} |
| @@ -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}} |
| @@ -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}} |
| @@ -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}} |
| @@ -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 | + | } | |
| 0 | 373 |