Commit ab56677031
Verified · cmc
Layout: unified · split
e2e/git_test.go +3
| @@ -17,6 +17,9 @@ func (i *instance) gitEnv(key string) []string { | ||
| 17 | 17 | key, filepath.Join(i.sshDir, "known_hosts")) |
| 18 | 18 | return append(os.Environ(), |
| 19 | 19 | "GIT_SSH_COMMAND="+sshCmd, |
| 20 | // Isolate from the developer's own git config (signing, helpers). | |
| 21 | "GIT_CONFIG_NOSYSTEM=1", | |
| 22 | "GIT_CONFIG_GLOBAL=/dev/null", | |
| 20 | 23 | "GIT_AUTHOR_NAME=t", "GIT_AUTHOR_EMAIL=t@example.test", |
| 21 | 24 | "GIT_COMMITTER_NAME=t", "GIT_COMMITTER_EMAIL=t@example.test", |
| 22 | 25 | ) |
e2e/http_test.go +1 −1
| @@ -53,7 +53,7 @@ func anonEnv() []string { | ||
| 53 | 53 | "GIT_TERMINAL_PROMPT=0", |
| 54 | 54 | "GIT_ASKPASS=false", |
| 55 | 55 | "GIT_CONFIG_NOSYSTEM=1", |
| 56 | "HOME=/nonexistent-forge-e2e", // no ~/.gitconfig credential helpers | |
| 56 | "GIT_CONFIG_GLOBAL=/dev/null", // no ~/.gitconfig credential helpers or signing | |
| 57 | 57 | "GIT_AUTHOR_NAME=t", "GIT_AUTHOR_EMAIL=t@example.test", |
| 58 | 58 | "GIT_COMMITTER_NAME=t", "GIT_COMMITTER_EMAIL=t@example.test", |
| 59 | 59 | ) |
e2e/sig_test.go +7 −1
| @@ -67,7 +67,13 @@ type commitSpec struct { | ||
| 67 | 67 | func buildCommits(t *testing.T, dir string, env []string, specs []commitSpec) []string { |
| 68 | 68 | t.Helper() |
| 69 | 69 | tree := strings.TrimSpace(mustGit(t, dir, env, "mktree")) |
| 70 | parent := "" | |
| 70 | return buildChain(t, dir, env, tree, "", specs) | |
| 71 | } | |
| 72 | ||
| 73 | // buildChain constructs signed commit objects on top of parent ("" for a | |
| 74 | // root commit) using the given tree, and points refs/heads/main at the tip. | |
| 75 | func buildChain(t *testing.T, dir string, env []string, tree, parent string, specs []commitSpec) []string { | |
| 76 | t.Helper() | |
| 71 | 77 | base := time.Now().Add(-time.Duration(len(specs)) * time.Minute).Unix() |
| 72 | 78 | var shas []string |
| 73 | 79 | for i, spec := range specs { |
e2e/web_test.go added +176
| @@ -0,0 +1,176 @@ | ||
| 1 | package e2e | |
| 2 | ||
| 3 | import ( | |
| 4 | "compress/gzip" | |
| 5 | "fmt" | |
| 6 | "io" | |
| 7 | "net/http" | |
| 8 | "os" | |
| 9 | "path/filepath" | |
| 10 | "strings" | |
| 11 | "testing" | |
| 12 | ||
| 13 | "golang.org/x/crypto/ssh" | |
| 14 | ||
| 15 | "github.com/krazywarez/forge/internal/sig" | |
| 16 | ) | |
| 17 | ||
| 18 | func (i *instance) get(t *testing.T, path string) (int, string) { | |
| 19 | t.Helper() | |
| 20 | resp, err := http.Get(fmt.Sprintf("http://127.0.0.1:%d%s", i.httpPort, path)) | |
| 21 | if err != nil { | |
| 22 | t.Fatal(err) | |
| 23 | } | |
| 24 | defer resp.Body.Close() | |
| 25 | body, _ := io.ReadAll(resp.Body) | |
| 26 | return resp.StatusCode, string(body) | |
| 27 | } | |
| 28 | ||
| 29 | func TestWebUI(t *testing.T) { | |
| 30 | inst := startInstance(t) | |
| 31 | ||
| 32 | aliceKey := inst.newKey(t, "alice") | |
| 33 | inst.admin(t, "admin", "user", "create", "alice", | |
| 34 | "--key", aliceKey+".pub", "--email", "alice@example.test", "--verified") | |
| 35 | ||
| 36 | // Public repo with real content: a README, a source file, a tag, and | |
| 37 | // one SSHSIG-signed commit for the badge check. | |
| 38 | if _, errOut, code := inst.ssh(t, aliceKey, "", "repo", "create", "alice/site"); code != 0 { | |
| 39 | t.Fatalf("repo create: %s", errOut) | |
| 40 | } | |
| 41 | work := t.TempDir() | |
| 42 | env := inst.gitEnv(aliceKey) | |
| 43 | mustGit(t, work, env, "clone", inst.sshURL("alice/site"), "w") | |
| 44 | dir := filepath.Join(work, "w") | |
| 45 | os.WriteFile(filepath.Join(dir, "README.md"), []byte("# hello site\n\nsome *markdown*\n"), 0o644) | |
| 46 | os.MkdirAll(filepath.Join(dir, "src"), 0o755) | |
| 47 | os.WriteFile(filepath.Join(dir, "src", "main.go"), []byte("package main\n\nfunc main() {}\n"), 0o644) | |
| 48 | mustGit(t, dir, env, "checkout", "-q", "-b", "main") | |
| 49 | mustGit(t, dir, env, "add", ".") | |
| 50 | mustGit(t, dir, env, "commit", "-q", "-m", "first commit") | |
| 51 | mustGit(t, dir, env, "tag", "v1.0") | |
| 52 | mustGit(t, dir, env, "push", "-q", "origin", "main", "v1.0") | |
| 53 | ||
| 54 | // A signed commit on top, built with the M4 fixture helpers. | |
| 55 | sshRaw, _ := os.ReadFile(aliceKey) | |
| 56 | signer, err := ssh.ParsePrivateKey(sshRaw) | |
| 57 | if err != nil { | |
| 58 | t.Fatal(err) | |
| 59 | } | |
| 60 | head := strings.TrimSpace(mustGit(t, dir, env, "rev-parse", "HEAD")) | |
| 61 | buildSignedCommitOn(t, dir, env, head, "signed tip", "alice@example.test", signer) | |
| 62 | mustGit(t, dir, env, "push", "-q", "origin", "main") | |
| 63 | ||
| 64 | // Private repo must be invisible everywhere. | |
| 65 | if _, _, code := inst.ssh(t, aliceKey, "", "repo", "create", "alice/secret", "--private"); code != 0 { | |
| 66 | t.Fatal("create private failed") | |
| 67 | } | |
| 68 | ||
| 69 | // Index lists the public repo, not the private one. | |
| 70 | status, body := inst.get(t, "/") | |
| 71 | if status != 200 || !strings.Contains(body, "alice/site") { | |
| 72 | t.Fatalf("index: %d\n%s", status, body) | |
| 73 | } | |
| 74 | if strings.Contains(body, "secret") { | |
| 75 | t.Fatal("index leaks private repo") | |
| 76 | } | |
| 77 | ||
| 78 | // Repo home: tree entries plus rendered README. | |
| 79 | status, body = inst.get(t, "/alice/site") | |
| 80 | if status != 200 || !strings.Contains(body, "src/") || !strings.Contains(body, "README.md") { | |
| 81 | t.Fatalf("repo home: %d\n%s", status, body) | |
| 82 | } | |
| 83 | if !strings.Contains(body, "<h1>hello site</h1>") || !strings.Contains(body, "<em>markdown</em>") { | |
| 84 | t.Fatalf("README not rendered:\n%s", body) | |
| 85 | } | |
| 86 | ||
| 87 | // Subdirectory tree and blob with highlighting. | |
| 88 | status, body = inst.get(t, "/alice/site/tree/main/src") | |
| 89 | if status != 200 || !strings.Contains(body, "main.go") { | |
| 90 | t.Fatalf("tree src: %d", status) | |
| 91 | } | |
| 92 | status, body = inst.get(t, "/alice/site/blob/main/src/main.go") | |
| 93 | if status != 200 || !strings.Contains(body, "package") { | |
| 94 | t.Fatalf("blob: %d", status) | |
| 95 | } | |
| 96 | ||
| 97 | // Raw serves exact bytes with nosniff. | |
| 98 | resp, err := http.Get(fmt.Sprintf("http://127.0.0.1:%d/alice/site/raw/main/src/main.go", inst.httpPort)) | |
| 99 | if err != nil { | |
| 100 | t.Fatal(err) | |
| 101 | } | |
| 102 | raw, _ := io.ReadAll(resp.Body) | |
| 103 | resp.Body.Close() | |
| 104 | if string(raw) != "package main\n\nfunc main() {}\n" { | |
| 105 | t.Fatalf("raw bytes: %q", raw) | |
| 106 | } | |
| 107 | if resp.Header.Get("X-Content-Type-Options") != "nosniff" { | |
| 108 | t.Fatal("raw missing nosniff") | |
| 109 | } | |
| 110 | ||
| 111 | // Log: both commits, with badges matching the M4 states exactly. | |
| 112 | status, body = inst.get(t, "/alice/site/log") | |
| 113 | if status != 200 { | |
| 114 | t.Fatalf("log: %d", status) | |
| 115 | } | |
| 116 | if !strings.Contains(body, "badge-verified") || !strings.Contains(body, "signed tip") { | |
| 117 | t.Fatalf("log missing verified badge:\n%s", body) | |
| 118 | } | |
| 119 | if !strings.Contains(body, "badge-unsigned") || !strings.Contains(body, "first commit") { | |
| 120 | t.Fatalf("log missing unsigned badge:\n%s", body) | |
| 121 | } | |
| 122 | ||
| 123 | // Commit page for the signed tip. | |
| 124 | tip := strings.TrimSpace(mustGit(t, dir, env, "rev-parse", "HEAD")) | |
| 125 | status, body = inst.get(t, "/alice/site/commit/"+tip) | |
| 126 | if status != 200 || !strings.Contains(body, "badge-verified") || !strings.Contains(body, "alice") { | |
| 127 | t.Fatalf("commit page: %d\n%s", status, body) | |
| 128 | } | |
| 129 | ||
| 130 | // Refs page shows branch and tag. | |
| 131 | status, body = inst.get(t, "/alice/site/refs") | |
| 132 | if status != 200 || !strings.Contains(body, "main") || !strings.Contains(body, "v1.0") { | |
| 133 | t.Fatalf("refs: %d", status) | |
| 134 | } | |
| 135 | ||
| 136 | // Archive downloads a valid gzip. | |
| 137 | resp, err = http.Get(fmt.Sprintf("http://127.0.0.1:%d/alice/site/archive/main.tar.gz", inst.httpPort)) | |
| 138 | if err != nil { | |
| 139 | t.Fatal(err) | |
| 140 | } | |
| 141 | gz, err := gzip.NewReader(resp.Body) | |
| 142 | if err != nil { | |
| 143 | t.Fatalf("archive not gzip: %v", err) | |
| 144 | } | |
| 145 | tarBytes, _ := io.ReadAll(gz) | |
| 146 | resp.Body.Close() | |
| 147 | if !strings.Contains(string(tarBytes), "README.md") { | |
| 148 | t.Fatal("archive missing content") | |
| 149 | } | |
| 150 | ||
| 151 | // Private repo pages: 404, indistinguishable from nonexistent. | |
| 152 | for _, p := range []string{"/alice/secret", "/alice/secret/log", "/alice/nothere"} { | |
| 153 | if status, _ := inst.get(t, p); status != 404 { | |
| 154 | t.Errorf("GET %s = %d, want 404", p, status) | |
| 155 | } | |
| 156 | } | |
| 157 | } | |
| 158 | ||
| 159 | // buildSignedCommitOn adds one SSHSIG-signed commit on top of parent, | |
| 160 | // reusing the M4 fixture machinery. | |
| 161 | func buildSignedCommitOn(t *testing.T, dir string, env []string, parent, subject, email string, signer ssh.Signer) { | |
| 162 | t.Helper() | |
| 163 | tree := strings.TrimSpace(mustGit(t, dir, env, "rev-parse", parent+"^{tree}")) | |
| 164 | specs := []commitSpec{{ | |
| 165 | authorEmail: email, | |
| 166 | subject: subject, | |
| 167 | sign: func(p []byte) string { | |
| 168 | s, err := sig.MarshalSSHSig(signer, p) | |
| 169 | if err != nil { | |
| 170 | t.Fatal(err) | |
| 171 | } | |
| 172 | return string(s) | |
| 173 | }, | |
| 174 | }} | |
| 175 | buildChain(t, dir, env, tree, parent, specs) | |
| 176 | } | |
go.mod +3
| @@ -5,13 +5,16 @@ go 1.27.0 | ||
| 5 | 5 | require ( |
| 6 | 6 | github.com/BurntSushi/toml v1.6.0 |
| 7 | 7 | github.com/ProtonMail/go-crypto v1.4.1 |
| 8 | github.com/alecthomas/chroma/v2 v2.27.0 | |
| 8 | 9 | github.com/spf13/cobra v1.10.2 |
| 10 | github.com/yuin/goldmark v1.8.5 | |
| 9 | 11 | golang.org/x/crypto v0.55.0 |
| 10 | 12 | modernc.org/sqlite v1.57.0 |
| 11 | 13 | ) |
| 12 | 14 | |
| 13 | 15 | require ( |
| 14 | 16 | github.com/cloudflare/circl v1.6.2 // indirect |
| 17 | github.com/dlclark/regexp2/v2 v2.2.1 // indirect | |
| 15 | 18 | github.com/dustin/go-humanize v1.0.1 // indirect |
| 16 | 19 | github.com/google/uuid v1.6.0 // indirect |
| 17 | 20 | github.com/inconshreveable/mousetrap v1.1.0 // indirect |
go.sum +12
| @@ -2,9 +2,17 @@ github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk | ||
| 2 | 2 | github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= |
| 3 | 3 | github.com/ProtonMail/go-crypto v1.4.1 h1:9RfcZHqEQUvP8RzecWEUafnZVtEvrBVL9BiF67IQOfM= |
| 4 | 4 | github.com/ProtonMail/go-crypto v1.4.1/go.mod h1:e1OaTyu5SYVrO9gKOEhTc+5UcXtTUa+P3uLudwcgPqo= |
| 5 | github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0= | |
| 6 | github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k= | |
| 7 | github.com/alecthomas/chroma/v2 v2.27.0 h1:FodwmyOBgJULFYmDqibcp9pvfDLWdtPRh9v/r5BXYZs= | |
| 8 | github.com/alecthomas/chroma/v2 v2.27.0/go.mod h1:NjJ3ciIgrqBNeIkWZ4e46nseoLDslxU1LmfCoL+wcY8= | |
| 9 | github.com/alecthomas/repr v0.5.2 h1:SU73FTI9D1P5UNtvseffFSGmdNci/O6RsqzeXJtP0Qs= | |
| 10 | github.com/alecthomas/repr v0.5.2/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= | |
| 5 | 11 | github.com/cloudflare/circl v1.6.2 h1:hL7VBpHHKzrV5WTfHCaBsgx/HGbBYlgrwvNXEVDYYsQ= |
| 6 | 12 | github.com/cloudflare/circl v1.6.2/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4= |
| 7 | 13 | github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= |
| 14 | github.com/dlclark/regexp2/v2 v2.2.1 h1:mf4KkFUj0gJuarK8P+LgiS+Lit7m9N1yAwEfPbee7R0= | |
| 15 | github.com/dlclark/regexp2/v2 v2.2.1/go.mod h1:avUrQvPaLz2DrFNHJF0taWAFFX2C1GMSSoeiqFjcBmU= | |
| 8 | 16 | github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= |
| 9 | 17 | github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= |
| 10 | 18 | github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3 h1:LMLX+LgTNWpfvCBdFebv6EsYotImrt/Ppc5cXIriCSo= |
| @@ -13,6 +21,8 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= | ||
| 13 | 21 | github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= |
| 14 | 22 | github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= |
| 15 | 23 | github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= |
| 24 | github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= | |
| 25 | github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= | |
| 16 | 26 | github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= |
| 17 | 27 | github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= |
| 18 | 28 | github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI= |
| @@ -26,6 +36,8 @@ github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= | ||
| 26 | 36 | github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= |
| 27 | 37 | github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= |
| 28 | 38 | github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= |
| 39 | github.com/yuin/goldmark v1.8.5 h1:r6N5afV5qj/5S4UTch8agZHJ8UxNCMwX7WjkkJam2NA= | |
| 40 | github.com/yuin/goldmark v1.8.5/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= | |
| 29 | 41 | go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= |
| 30 | 42 | golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M= |
| 31 | 43 | golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis= |
internal/gitutil/read.go added +132
| @@ -0,0 +1,132 @@ | ||
| 1 | package gitutil | |
| 2 | ||
| 3 | import ( | |
| 4 | "bytes" | |
| 5 | "fmt" | |
| 6 | "io" | |
| 7 | "os/exec" | |
| 8 | "strconv" | |
| 9 | "strings" | |
| 10 | ) | |
| 11 | ||
| 12 | type TreeEntry struct { | |
| 13 | Mode string | |
| 14 | Type string // blob | tree | |
| 15 | SHA string | |
| 16 | Size int64 // -1 for trees | |
| 17 | Name string | |
| 18 | } | |
| 19 | ||
| 20 | // ListTree lists one level of the tree at ref:path. | |
| 21 | func ListTree(dir, ref, path string) ([]TreeEntry, error) { | |
| 22 | spec := ref | |
| 23 | if path != "" { | |
| 24 | spec = ref + ":" + path | |
| 25 | } | |
| 26 | cmd := exec.Command("git", "-C", dir, "ls-tree", "-l", spec) | |
| 27 | out, err := cmd.Output() | |
| 28 | if err != nil { | |
| 29 | return nil, fmt.Errorf("ls-tree %s: %w", spec, err) | |
| 30 | } | |
| 31 | var entries []TreeEntry | |
| 32 | for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") { | |
| 33 | if line == "" { | |
| 34 | continue | |
| 35 | } | |
| 36 | // <mode> <type> <sha> <size>\t<name> | |
| 37 | meta, name, ok := strings.Cut(line, "\t") | |
| 38 | if !ok { | |
| 39 | continue | |
| 40 | } | |
| 41 | f := strings.Fields(meta) | |
| 42 | if len(f) != 4 { | |
| 43 | continue | |
| 44 | } | |
| 45 | size := int64(-1) | |
| 46 | if f[3] != "-" { | |
| 47 | size, _ = strconv.ParseInt(f[3], 10, 64) | |
| 48 | } | |
| 49 | entries = append(entries, TreeEntry{Mode: f[0], Type: f[1], SHA: f[2], Size: size, Name: name}) | |
| 50 | } | |
| 51 | return entries, nil | |
| 52 | } | |
| 53 | ||
| 54 | // ReadBlob returns the contents of ref:path, capped at limit bytes. | |
| 55 | func ReadBlob(dir, ref, path string, limit int64) ([]byte, error) { | |
| 56 | cmd := exec.Command("git", "-C", dir, "cat-file", "blob", ref+":"+path) | |
| 57 | stdout, err := cmd.StdoutPipe() | |
| 58 | if err != nil { | |
| 59 | return nil, err | |
| 60 | } | |
| 61 | if err := cmd.Start(); err != nil { | |
| 62 | return nil, err | |
| 63 | } | |
| 64 | data, err := io.ReadAll(io.LimitReader(stdout, limit)) | |
| 65 | io.Copy(io.Discard, stdout) // drain so git exits cleanly | |
| 66 | if werr := cmd.Wait(); werr != nil { | |
| 67 | return nil, fmt.Errorf("cat-file blob %s:%s: %w", ref, path, werr) | |
| 68 | } | |
| 69 | return data, err | |
| 70 | } | |
| 71 | ||
| 72 | // ResolveRef resolves a ref or sha to a full commit sha; errors if absent. | |
| 73 | func ResolveRef(dir, ref string) (string, error) { | |
| 74 | cmd := exec.Command("git", "-C", dir, "rev-parse", "--verify", "--quiet", ref+"^{commit}") | |
| 75 | out, err := cmd.Output() | |
| 76 | if err != nil { | |
| 77 | return "", fmt.Errorf("unknown ref %q", ref) | |
| 78 | } | |
| 79 | return strings.TrimSpace(string(out)), nil | |
| 80 | } | |
| 81 | ||
| 82 | type Ref struct { | |
| 83 | Name string | |
| 84 | SHA string | |
| 85 | } | |
| 86 | ||
| 87 | // Refs lists branches or tags; kind is "heads" or "tags". | |
| 88 | func Refs(dir, kind string) ([]Ref, error) { | |
| 89 | cmd := exec.Command("git", "-C", dir, "for-each-ref", | |
| 90 | "--format=%(refname:short) %(objectname)", "refs/"+kind) | |
| 91 | out, err := cmd.Output() | |
| 92 | if err != nil { | |
| 93 | return nil, err | |
| 94 | } | |
| 95 | var refs []Ref | |
| 96 | for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") { | |
| 97 | if name, sha, ok := strings.Cut(line, " "); ok { | |
| 98 | refs = append(refs, Ref{Name: name, SHA: sha}) | |
| 99 | } | |
| 100 | } | |
| 101 | return refs, nil | |
| 102 | } | |
| 103 | ||
| 104 | // Archive streams a tar.gz of ref to w. | |
| 105 | func Archive(dir, ref, prefix string, w io.Writer) error { | |
| 106 | cmd := exec.Command("git", "-C", dir, "archive", "--format=tar.gz", "--prefix="+prefix+"/", ref) | |
| 107 | cmd.Stdout = w | |
| 108 | return cmd.Run() | |
| 109 | } | |
| 110 | ||
| 111 | // ShowPatch returns the stat+patch text for one commit. | |
| 112 | func ShowPatch(dir, sha string, limit int64) (string, error) { | |
| 113 | cmd := exec.Command("git", "-C", dir, "show", "--stat", "--patch", "--format=", sha) | |
| 114 | stdout, err := cmd.StdoutPipe() | |
| 115 | if err != nil { | |
| 116 | return "", err | |
| 117 | } | |
| 118 | if err := cmd.Start(); err != nil { | |
| 119 | return "", err | |
| 120 | } | |
| 121 | data, _ := io.ReadAll(io.LimitReader(stdout, limit)) | |
| 122 | io.Copy(io.Discard, stdout) | |
| 123 | if err := cmd.Wait(); err != nil { | |
| 124 | return "", fmt.Errorf("show %s: %w", sha, err) | |
| 125 | } | |
| 126 | return string(data), nil | |
| 127 | } | |
| 128 | ||
| 129 | // IsBinary reports whether data looks like binary content. | |
| 130 | func IsBinary(data []byte) bool { | |
| 131 | return bytes.IndexByte(data, 0) >= 0 | |
| 132 | } | |
internal/hookd/hookd.go +14 −2
| @@ -5,6 +5,7 @@ | ||
| 5 | 5 | package hookd |
| 6 | 6 | |
| 7 | 7 | import ( |
| 8 | "crypto/sha256" | |
| 8 | 9 | "encoding/json" |
| 9 | 10 | "fmt" |
| 10 | 11 | "net" |
| @@ -35,8 +36,19 @@ type Response struct { | ||
| 35 | 36 | Message string `json:"message,omitempty"` |
| 36 | 37 | } |
| 37 | 38 | |
| 38 | // SocketPath returns the hook socket location under the server root. | |
| 39 | func SocketPath(root string) string { return filepath.Join(root, "hook.sock") } | |
| 39 | // SocketPath returns the hook socket location. It prefers the server root, | |
| 40 | // but unix socket paths are capped (~104 bytes on macOS, 108 on Linux), so | |
| 41 | // deep roots fall back to a hashed name under the system temp directory. | |
| 42 | // Hooks receive the chosen path via FORGE_HOOK_SOCKET, so both sides always | |
| 43 | // agree. | |
| 44 | func SocketPath(root string) string { | |
| 45 | p := filepath.Join(root, "hook.sock") | |
| 46 | if len(p) <= 100 { | |
| 47 | return p | |
| 48 | } | |
| 49 | sum := sha256.Sum256([]byte(root)) | |
| 50 | return filepath.Join(os.TempDir(), fmt.Sprintf("forge-%x.sock", sum[:8])) | |
| 51 | } | |
| 40 | 52 | |
| 41 | 53 | type Server struct { |
| 42 | 54 | st *store.Store |
internal/httpd/routes.go added +53
| @@ -0,0 +1,53 @@ | ||
| 1 | package httpd | |
| 2 | ||
| 3 | import "net/http" | |
| 4 | ||
| 5 | // Route is one entry in the explicit route table. The view-only guarantee is | |
| 6 | // structural: Handler() consults web.mode when building the table, and the | |
| 7 | // route test asserts no mutating route exists in view_only mode. | |
| 8 | type Route struct { | |
| 9 | Method string | |
| 10 | Pattern string // without method prefix | |
| 11 | // Mutating marks routes that can change server state. The git transport | |
| 12 | // POSTs are not mutating: upload-pack is a pure read, and the | |
| 13 | // receive-pack endpoint is a static refusal that writes nothing. | |
| 14 | Mutating bool | |
| 15 | Handler http.HandlerFunc | |
| 16 | } | |
| 17 | ||
| 18 | // Routes returns the route table for the configured web.mode. | |
| 19 | func (s *Server) Routes() []Route { | |
| 20 | // Git smart transport (anonymous, public repos only). | |
| 21 | routes := []Route{ | |
| 22 | {Method: "GET", Pattern: "/{owner}/{repo}/info/refs", Handler: s.infoRefs}, | |
| 23 | {Method: "POST", Pattern: "/{owner}/{repo}/git-upload-pack", Handler: s.uploadPack}, | |
| 24 | {Method: "POST", Pattern: "/{owner}/{repo}/git-receive-pack", Handler: s.receivePackRefusal}, | |
| 25 | } | |
| 26 | ||
| 27 | // Web UI, read-only. These exist in every mode. | |
| 28 | routes = append(routes, | |
| 29 | Route{Method: "GET", Pattern: "/{$}", Handler: s.index}, | |
| 30 | Route{Method: "GET", Pattern: "/static/style.css", Handler: s.stylesheet}, | |
| 31 | Route{Method: "GET", Pattern: "/{owner}/{repo}", Handler: s.repoHome}, | |
| 32 | Route{Method: "GET", Pattern: "/{owner}/{repo}/tree/{ref}/{path...}", Handler: s.tree}, | |
| 33 | Route{Method: "GET", Pattern: "/{owner}/{repo}/blob/{ref}/{path...}", Handler: s.blob}, | |
| 34 | Route{Method: "GET", Pattern: "/{owner}/{repo}/raw/{ref}/{path...}", Handler: s.raw}, | |
| 35 | Route{Method: "GET", Pattern: "/{owner}/{repo}/log", Handler: s.log}, | |
| 36 | Route{Method: "GET", Pattern: "/{owner}/{repo}/log/{ref}", Handler: s.log}, | |
| 37 | Route{Method: "GET", Pattern: "/{owner}/{repo}/commit/{sha}", Handler: s.commit}, | |
| 38 | Route{Method: "GET", Pattern: "/{owner}/{repo}/refs", Handler: s.refs}, | |
| 39 | Route{Method: "GET", Pattern: "/{owner}/{repo}/archive/{file}", Handler: s.archive}, | |
| 40 | ) | |
| 41 | ||
| 42 | // Account-mode routes (login, web edits) are appended here in M8 — | |
| 43 | // and only when s.cfg.Web.Mode == "accounts". | |
| 44 | return routes | |
| 45 | } | |
| 46 | ||
| 47 | func (s *Server) Handler() http.Handler { | |
| 48 | mux := http.NewServeMux() | |
| 49 | for _, r := range s.Routes() { | |
| 50 | mux.HandleFunc(r.Method+" "+r.Pattern, r.Handler) | |
| 51 | } | |
| 52 | return mux | |
| 53 | } | |
internal/httpd/routes_test.go added +51
| @@ -0,0 +1,51 @@ | ||
| 1 | package httpd | |
| 2 | ||
| 3 | import ( | |
| 4 | "strings" | |
| 5 | "testing" | |
| 6 | ||
| 7 | "github.com/krazywarez/forge/internal/config" | |
| 8 | "github.com/krazywarez/forge/internal/policy" | |
| 9 | ) | |
| 10 | ||
| 11 | // TestViewOnlyHasNoMutatingRoutes is the structural guarantee from the plan: | |
| 12 | // under web.mode = "view_only" the route table must contain no mutating | |
| 13 | // route — not hidden ones, none at all. | |
| 14 | func TestViewOnlyHasNoMutatingRoutes(t *testing.T) { | |
| 15 | cfg := config.Default() | |
| 16 | cfg.Web.Mode = "view_only" | |
| 17 | s := New(cfg, nil) | |
| 18 | ||
| 19 | for _, r := range s.Routes() { | |
| 20 | if r.Mutating { | |
| 21 | t.Errorf("view_only route table contains mutating route %s %s", r.Method, r.Pattern) | |
| 22 | } | |
| 23 | // The only POSTs allowed are the git transport endpoints: a pure | |
| 24 | // read (upload-pack) and a static refusal (receive-pack). | |
| 25 | if r.Method != "GET" && !strings.Contains(r.Pattern, "git-upload-pack") && !strings.Contains(r.Pattern, "git-receive-pack") { | |
| 26 | t.Errorf("view_only route table contains non-GET route %s %s", r.Method, r.Pattern) | |
| 27 | } | |
| 28 | for _, word := range []string{"login", "logout", "register", "edit", "new", "settings"} { | |
| 29 | if strings.Contains(r.Pattern, "/"+word) { | |
| 30 | t.Errorf("view_only route table contains account-mode pattern %s %s", r.Method, r.Pattern) | |
| 31 | } | |
| 32 | } | |
| 33 | } | |
| 34 | } | |
| 35 | ||
| 36 | // TestTopLevelRouteWordsAreReserved keeps the route table and the reserved | |
| 37 | // username list in agreement: every literal first path segment must be an | |
| 38 | // unclaimable username. | |
| 39 | func TestTopLevelRouteWordsAreReserved(t *testing.T) { | |
| 40 | s := New(config.Default(), nil) | |
| 41 | for _, r := range s.Routes() { | |
| 42 | seg := strings.TrimPrefix(r.Pattern, "/") | |
| 43 | seg, _, _ = strings.Cut(seg, "/") | |
| 44 | if seg == "" || strings.HasPrefix(seg, "{") { | |
| 45 | continue // wildcard or root | |
| 46 | } | |
| 47 | if !policy.Reserved(seg) { | |
| 48 | t.Errorf("top-level route word %q is not in the reserved username list", seg) | |
| 49 | } | |
| 50 | } | |
| 51 | } | |
internal/httpd/smart.go +4 −9
| @@ -28,15 +28,10 @@ func New(cfg config.Config, st *store.Store) *Server { | ||
| 28 | 28 | return &Server{cfg: cfg, st: st} |
| 29 | 29 | } |
| 30 | 30 | |
| 31 | func (s *Server) Handler() http.Handler { | |
| 32 | mux := http.NewServeMux() | |
| 33 | mux.HandleFunc("GET /{owner}/{repo}/info/refs", s.infoRefs) | |
| 34 | mux.HandleFunc("POST /{owner}/{repo}/git-upload-pack", s.uploadPack) | |
| 35 | // Push endpoints exist only to fail legibly. | |
| 36 | mux.HandleFunc("POST /{owner}/{repo}/git-receive-pack", func(w http.ResponseWriter, r *http.Request) { | |
| 37 | http.Error(w, s.pushRefusalMessage(r.PathValue("owner"), r.PathValue("repo")), http.StatusForbidden) | |
| 38 | }) | |
| 39 | return mux | |
| 31 | // receivePackRefusal exists only to fail legibly if a client POSTs without | |
| 32 | // reading the advertisement first. | |
| 33 | func (s *Server) receivePackRefusal(w http.ResponseWriter, r *http.Request) { | |
| 34 | http.Error(w, s.pushRefusalMessage(r.PathValue("owner"), r.PathValue("repo")), http.StatusForbidden) | |
| 40 | 35 | } |
| 41 | 36 | |
| 42 | 37 | // publicRepo resolves owner/name and returns it only if it exists and is |
internal/httpd/web.go added +399
| @@ -0,0 +1,399 @@ | ||
| 1 | package httpd | |
| 2 | ||
| 3 | import ( | |
| 4 | "bytes" | |
| 5 | "fmt" | |
| 6 | "html/template" | |
| 7 | "net/http" | |
| 8 | "path" | |
| 9 | "strings" | |
| 10 | "time" | |
| 11 | ||
| 12 | "github.com/alecthomas/chroma/v2/formatters/html" | |
| 13 | "github.com/alecthomas/chroma/v2/lexers" | |
| 14 | "github.com/alecthomas/chroma/v2/styles" | |
| 15 | "github.com/yuin/goldmark" | |
| 16 | ||
| 17 | "github.com/krazywarez/forge/internal/control" | |
| 18 | "github.com/krazywarez/forge/internal/gitutil" | |
| 19 | "github.com/krazywarez/forge/internal/sig" | |
| 20 | "github.com/krazywarez/forge/internal/store" | |
| 21 | "github.com/krazywarez/forge/internal/web" | |
| 22 | ) | |
| 23 | ||
| 24 | const maxRenderBytes = 1 << 20 // largest blob rendered inline | |
| 25 | ||
| 26 | func (s *Server) render(w http.ResponseWriter, page string, data any) { | |
| 27 | var buf bytes.Buffer | |
| 28 | if err := web.Render(&buf, page, data); err != nil { | |
| 29 | http.Error(w, "template error: "+err.Error(), http.StatusInternalServerError) | |
| 30 | return | |
| 31 | } | |
| 32 | w.Header().Set("Content-Type", "text/html; charset=utf-8") | |
| 33 | buf.WriteTo(w) | |
| 34 | } | |
| 35 | ||
| 36 | func (s *Server) siteName() string { | |
| 37 | h := strings.TrimPrefix(strings.TrimPrefix(s.cfg.Server.SiteURL, "https://"), "http://") | |
| 38 | return strings.TrimSuffix(h, "/") | |
| 39 | } | |
| 40 | ||
| 41 | func (s *Server) stylesheet(w http.ResponseWriter, r *http.Request) { | |
| 42 | w.Header().Set("Content-Type", "text/css; charset=utf-8") | |
| 43 | w.Write(web.StyleCSS) | |
| 44 | } | |
| 45 | ||
| 46 | func (s *Server) index(w http.ResponseWriter, r *http.Request) { | |
| 47 | repos, err := s.st.ListPublicRepos() | |
| 48 | if err != nil { | |
| 49 | http.Error(w, "internal error", http.StatusInternalServerError) | |
| 50 | return | |
| 51 | } | |
| 52 | s.render(w, "index.html", struct { | |
| 53 | Site string | |
| 54 | Repos []store.Repo | |
| 55 | }{s.siteName(), repos}) | |
| 56 | } | |
| 57 | ||
| 58 | // repoPage is the shared context for repo-scoped pages. | |
| 59 | type repoPage struct { | |
| 60 | Site string | |
| 61 | Repo store.Repo | |
| 62 | Ref string | |
| 63 | CloneURL string | |
| 64 | Dir string | |
| 65 | } | |
| 66 | ||
| 67 | // repoFor resolves the repo for a web request; false means 404 was sent. | |
| 68 | // The anonymous web sees public repos only — private and missing repos are | |
| 69 | // indistinguishable. | |
| 70 | func (s *Server) repoFor(w http.ResponseWriter, r *http.Request, ref string) (repoPage, bool) { | |
| 71 | repo, ok := s.publicRepo(r.PathValue("owner"), r.PathValue("repo")) | |
| 72 | if !ok { | |
| 73 | http.NotFound(w, r) | |
| 74 | return repoPage{}, false | |
| 75 | } | |
| 76 | if ref == "" { | |
| 77 | ref = repo.DefaultBranch | |
| 78 | } | |
| 79 | return repoPage{ | |
| 80 | Site: s.siteName(), | |
| 81 | Repo: repo, | |
| 82 | Ref: ref, | |
| 83 | CloneURL: s.cfg.Server.SiteURL + "/" + repo.Path() + ".git", | |
| 84 | Dir: control.RepoDir(s.cfg.Server.Root, repo.OwnerName, repo.Name), | |
| 85 | }, true | |
| 86 | } | |
| 87 | ||
| 88 | type crumb struct { | |
| 89 | Name string | |
| 90 | URL string | |
| 91 | } | |
| 92 | ||
| 93 | func crumbs(p repoPage, kind, filePath string) []crumb { | |
| 94 | var cs []crumb | |
| 95 | base := "/" + p.Repo.Path() + "/" + kind + "/" + p.Ref + "/" | |
| 96 | acc := "" | |
| 97 | for _, part := range strings.Split(filePath, "/") { | |
| 98 | if part == "" { | |
| 99 | continue | |
| 100 | } | |
| 101 | acc = path.Join(acc, part) | |
| 102 | cs = append(cs, crumb{Name: part, URL: base + acc}) | |
| 103 | } | |
| 104 | return cs | |
| 105 | } | |
| 106 | ||
| 107 | func (s *Server) repoHome(w http.ResponseWriter, r *http.Request) { | |
| 108 | p, ok := s.repoFor(w, r, "") | |
| 109 | if !ok { | |
| 110 | return | |
| 111 | } | |
| 112 | s.renderTree(w, r, p, "") | |
| 113 | } | |
| 114 | ||
| 115 | func (s *Server) tree(w http.ResponseWriter, r *http.Request) { | |
| 116 | p, ok := s.repoFor(w, r, r.PathValue("ref")) | |
| 117 | if !ok { | |
| 118 | return | |
| 119 | } | |
| 120 | s.renderTree(w, r, p, strings.Trim(r.PathValue("path"), "/")) | |
| 121 | } | |
| 122 | ||
| 123 | func (s *Server) renderTree(w http.ResponseWriter, r *http.Request, p repoPage, dirPath string) { | |
| 124 | if _, err := gitutil.ResolveRef(p.Dir, p.Ref); err != nil { | |
| 125 | // Empty repo: render the page with no entries rather than 404. | |
| 126 | s.render(w, "tree.html", struct { | |
| 127 | repoPage | |
| 128 | Crumbs []crumb | |
| 129 | Prefix string | |
| 130 | Entries []gitutil.TreeEntry | |
| 131 | ReadmeHTML template.HTML | |
| 132 | }{repoPage: p}) | |
| 133 | return | |
| 134 | } | |
| 135 | entries, err := gitutil.ListTree(p.Dir, p.Ref, dirPath) | |
| 136 | if err != nil { | |
| 137 | http.NotFound(w, r) | |
| 138 | return | |
| 139 | } | |
| 140 | prefix := "" | |
| 141 | if dirPath != "" { | |
| 142 | prefix = dirPath + "/" | |
| 143 | } | |
| 144 | ||
| 145 | var readmeHTML template.HTML | |
| 146 | for _, e := range entries { | |
| 147 | if e.Type != "blob" { | |
| 148 | continue | |
| 149 | } | |
| 150 | lower := strings.ToLower(e.Name) | |
| 151 | if lower == "readme" || lower == "readme.md" || lower == "readme.markdown" { | |
| 152 | raw, err := gitutil.ReadBlob(p.Dir, p.Ref, prefix+e.Name, maxRenderBytes) | |
| 153 | if err == nil { | |
| 154 | var buf bytes.Buffer | |
| 155 | if strings.HasSuffix(lower, ".md") || strings.HasSuffix(lower, ".markdown") { | |
| 156 | // goldmark's default renderer drops raw HTML: safe. | |
| 157 | if goldmark.Convert(raw, &buf) == nil { | |
| 158 | readmeHTML = template.HTML(buf.String()) | |
| 159 | } | |
| 160 | } else { | |
| 161 | readmeHTML = template.HTML("<pre>" + template.HTMLEscapeString(string(raw)) + "</pre>") | |
| 162 | } | |
| 163 | } | |
| 164 | break | |
| 165 | } | |
| 166 | } | |
| 167 | ||
| 168 | s.render(w, "tree.html", struct { | |
| 169 | repoPage | |
| 170 | Crumbs []crumb | |
| 171 | Prefix string | |
| 172 | Entries []gitutil.TreeEntry | |
| 173 | ReadmeHTML template.HTML | |
| 174 | }{p, crumbs(p, "tree", dirPath), prefix, entries, readmeHTML}) | |
| 175 | } | |
| 176 | ||
| 177 | func (s *Server) blob(w http.ResponseWriter, r *http.Request) { | |
| 178 | p, ok := s.repoFor(w, r, r.PathValue("ref")) | |
| 179 | if !ok { | |
| 180 | return | |
| 181 | } | |
| 182 | filePath := strings.Trim(r.PathValue("path"), "/") | |
| 183 | data, err := gitutil.ReadBlob(p.Dir, p.Ref, filePath, maxRenderBytes+1) | |
| 184 | if err != nil { | |
| 185 | http.NotFound(w, r) | |
| 186 | return | |
| 187 | } | |
| 188 | binary := gitutil.IsBinary(data) || len(data) > maxRenderBytes | |
| 189 | ||
| 190 | var codeHTML template.HTML | |
| 191 | if !binary { | |
| 192 | codeHTML = highlight(filePath, data) | |
| 193 | } | |
| 194 | cs := crumbs(p, "blob", filePath) | |
| 195 | base := "" | |
| 196 | if len(cs) > 0 { | |
| 197 | base = cs[len(cs)-1].Name | |
| 198 | cs = cs[:len(cs)-1] | |
| 199 | } | |
| 200 | s.render(w, "blob.html", struct { | |
| 201 | repoPage | |
| 202 | Crumbs []crumb | |
| 203 | Base string | |
| 204 | Path string | |
| 205 | Binary bool | |
| 206 | Size int | |
| 207 | CodeHTML template.HTML | |
| 208 | }{p, cs, base, filePath, binary, len(data), codeHTML}) | |
| 209 | } | |
| 210 | ||
| 211 | func highlight(filePath string, data []byte) template.HTML { | |
| 212 | lexer := lexers.Match(filePath) | |
| 213 | if lexer == nil { | |
| 214 | lexer = lexers.Fallback | |
| 215 | } | |
| 216 | style := styles.Get("friendly") | |
| 217 | formatter := html.New(html.WithLineNumbers(true), html.LineNumbersInTable(false)) | |
| 218 | iterator, err := lexer.Tokenise(nil, string(data)) | |
| 219 | if err != nil { | |
| 220 | return template.HTML("<pre>" + template.HTMLEscapeString(string(data)) + "</pre>") | |
| 221 | } | |
| 222 | var buf bytes.Buffer | |
| 223 | if err := formatter.Format(&buf, style, iterator); err != nil { | |
| 224 | return template.HTML("<pre>" + template.HTMLEscapeString(string(data)) + "</pre>") | |
| 225 | } | |
| 226 | return template.HTML(buf.String()) | |
| 227 | } | |
| 228 | ||
| 229 | func (s *Server) raw(w http.ResponseWriter, r *http.Request) { | |
| 230 | p, ok := s.repoFor(w, r, r.PathValue("ref")) | |
| 231 | if !ok { | |
| 232 | return | |
| 233 | } | |
| 234 | filePath := strings.Trim(r.PathValue("path"), "/") | |
| 235 | data, err := gitutil.ReadBlob(p.Dir, p.Ref, filePath, s.cfg.Limits.MaxBlobBytes) | |
| 236 | if err != nil { | |
| 237 | http.NotFound(w, r) | |
| 238 | return | |
| 239 | } | |
| 240 | // Serve inert: never let repo content execute in the forge's origin. | |
| 241 | w.Header().Set("Content-Type", "text/plain; charset=utf-8") | |
| 242 | w.Header().Set("X-Content-Type-Options", "nosniff") | |
| 243 | w.Write(data) | |
| 244 | } | |
| 245 | ||
| 246 | type sigView struct { | |
| 247 | State string | |
| 248 | Signer string | |
| 249 | Fingerprint string | |
| 250 | } | |
| 251 | ||
| 252 | func (s *Server) sigFor(repo store.Repo, dir, sha string) (sigView, *sig.Commit) { | |
| 253 | raw, err := gitutil.ReadCommit(dir, sha) | |
| 254 | if err != nil { | |
| 255 | return sigView{State: "unsigned"}, nil | |
| 256 | } | |
| 257 | parsed, err := sig.ParseCommit(raw) | |
| 258 | if err != nil { | |
| 259 | return sigView{State: "unsigned"}, nil | |
| 260 | } | |
| 261 | res, err := control.VerifyCommitCached(s.st, repo, parsed, sha) | |
| 262 | if err != nil { | |
| 263 | return sigView{State: "unsigned"}, parsed | |
| 264 | } | |
| 265 | v := sigView{State: string(res.State), Fingerprint: res.KeyFingerprint} | |
| 266 | if res.SignerUserID != 0 { | |
| 267 | if u, err := s.st.UserByID(res.SignerUserID); err == nil { | |
| 268 | v.Signer = u.Username | |
| 269 | } | |
| 270 | } | |
| 271 | return v, parsed | |
| 272 | } | |
| 273 | ||
| 274 | func (s *Server) log(w http.ResponseWriter, r *http.Request) { | |
| 275 | ref := r.PathValue("ref") | |
| 276 | p, ok := s.repoFor(w, r, ref) | |
| 277 | if !ok { | |
| 278 | return | |
| 279 | } | |
| 280 | const pageSize = 50 | |
| 281 | shas, err := gitutil.RevList(p.Dir, p.Ref, pageSize+1) | |
| 282 | if err != nil { | |
| 283 | http.NotFound(w, r) | |
| 284 | return | |
| 285 | } | |
| 286 | next := "" | |
| 287 | if len(shas) > pageSize { | |
| 288 | next = shas[pageSize] | |
| 289 | shas = shas[:pageSize] | |
| 290 | } | |
| 291 | type row struct { | |
| 292 | SHA, ShortSHA, Subject, AuthorName, AuthorEmail, Date string | |
| 293 | Sig sigView | |
| 294 | } | |
| 295 | var rows []row | |
| 296 | for _, sha := range shas { | |
| 297 | v, parsed := s.sigFor(p.Repo, p.Dir, sha) | |
| 298 | rw := row{SHA: sha, ShortSHA: sha[:10], Sig: v} | |
| 299 | if parsed != nil { | |
| 300 | rw.Subject = parsed.Subject | |
| 301 | rw.AuthorName = parsed.AuthorName | |
| 302 | rw.AuthorEmail = parsed.AuthorEmail | |
| 303 | rw.Date = time.Unix(parsed.AuthorUnix, 0).UTC().Format("2006-01-02") | |
| 304 | } | |
| 305 | rows = append(rows, rw) | |
| 306 | } | |
| 307 | s.render(w, "log.html", struct { | |
| 308 | repoPage | |
| 309 | Commits []row | |
| 310 | NextSHA string | |
| 311 | }{p, rows, next}) | |
| 312 | } | |
| 313 | ||
| 314 | func (s *Server) commit(w http.ResponseWriter, r *http.Request) { | |
| 315 | p, ok := s.repoFor(w, r, "") | |
| 316 | if !ok { | |
| 317 | return | |
| 318 | } | |
| 319 | sha := r.PathValue("sha") | |
| 320 | full, err := gitutil.ResolveRef(p.Dir, sha) | |
| 321 | if err != nil { | |
| 322 | http.NotFound(w, r) | |
| 323 | return | |
| 324 | } | |
| 325 | v, parsed := s.sigFor(p.Repo, p.Dir, full) | |
| 326 | if parsed == nil { | |
| 327 | http.NotFound(w, r) | |
| 328 | return | |
| 329 | } | |
| 330 | patch, _ := gitutil.ShowPatch(p.Dir, full, 4<<20) | |
| 331 | type diffLine struct { | |
| 332 | Class string | |
| 333 | Text string | |
| 334 | } | |
| 335 | var lines []diffLine | |
| 336 | for _, l := range strings.Split(patch, "\n") { | |
| 337 | class := "" | |
| 338 | switch { | |
| 339 | case strings.HasPrefix(l, "+++"), strings.HasPrefix(l, "---"), strings.HasPrefix(l, "diff "), strings.HasPrefix(l, "index "): | |
| 340 | class = "meta" | |
| 341 | case strings.HasPrefix(l, "@@"): | |
| 342 | class = "hunk" | |
| 343 | case strings.HasPrefix(l, "+"): | |
| 344 | class = "add" | |
| 345 | case strings.HasPrefix(l, "-"): | |
| 346 | class = "del" | |
| 347 | } | |
| 348 | lines = append(lines, diffLine{class, l}) | |
| 349 | } | |
| 350 | committerEmail := "" | |
| 351 | if parsed.CommitterEmail != parsed.AuthorEmail { | |
| 352 | committerEmail = parsed.CommitterEmail | |
| 353 | } | |
| 354 | msg := "" | |
| 355 | if i := bytes.Index(parsed.Payload, []byte("\n\n")); i >= 0 { | |
| 356 | msg = string(parsed.Payload[i+2:]) | |
| 357 | } | |
| 358 | s.render(w, "commit.html", struct { | |
| 359 | repoPage | |
| 360 | SHA, ShortSHA, AuthorName, AuthorEmail, CommitterEmail, Date, Message string | |
| 361 | Sig sigView | |
| 362 | DiffLines []diffLine | |
| 363 | }{p, full, full[:10], parsed.AuthorName, parsed.AuthorEmail, committerEmail, | |
| 364 | time.Unix(parsed.AuthorUnix, 0).UTC().Format(time.RFC3339), msg, v, lines}) | |
| 365 | } | |
| 366 | ||
| 367 | func (s *Server) refs(w http.ResponseWriter, r *http.Request) { | |
| 368 | p, ok := s.repoFor(w, r, "") | |
| 369 | if !ok { | |
| 370 | return | |
| 371 | } | |
| 372 | branches, _ := gitutil.Refs(p.Dir, "heads") | |
| 373 | tags, _ := gitutil.Refs(p.Dir, "tags") | |
| 374 | s.render(w, "refs.html", struct { | |
| 375 | repoPage | |
| 376 | Branches, Tags []gitutil.Ref | |
| 377 | }{p, branches, tags}) | |
| 378 | } | |
| 379 | ||
| 380 | func (s *Server) archive(w http.ResponseWriter, r *http.Request) { | |
| 381 | p, ok := s.repoFor(w, r, "") | |
| 382 | if !ok { | |
| 383 | return | |
| 384 | } | |
| 385 | file := r.PathValue("file") | |
| 386 | ref, ok := strings.CutSuffix(file, ".tar.gz") | |
| 387 | if !ok { | |
| 388 | http.NotFound(w, r) | |
| 389 | return | |
| 390 | } | |
| 391 | if _, err := gitutil.ResolveRef(p.Dir, ref); err != nil { | |
| 392 | http.NotFound(w, r) | |
| 393 | return | |
| 394 | } | |
| 395 | prefix := fmt.Sprintf("%s-%s", p.Repo.Name, ref) | |
| 396 | w.Header().Set("Content-Type", "application/gzip") | |
| 397 | w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", prefix+".tar.gz")) | |
| 398 | gitutil.Archive(p.Dir, ref, prefix, w) | |
| 399 | } | |
internal/store/repos.go +22
| @@ -191,3 +191,25 @@ func (s *Store) RepoByID(id int64) (Repo, error) { | ||
| 191 | 191 | } |
| 192 | 192 | return r, nil |
| 193 | 193 | } |
| 194 | ||
| 195 | // ListPublicRepos returns all public repositories, for the anonymous index. | |
| 196 | func (s *Store) ListPublicRepos() ([]Repo, error) { | |
| 197 | rows, err := s.DB.Query(` | |
| 198 | SELECT r.id, r.owner_kind, r.owner_id, u.username, r.name, r.visibility, r.default_branch, r.settings_json | |
| 199 | FROM repos r JOIN users u ON r.owner_kind = 'user' AND u.id = r.owner_id | |
| 200 | WHERE r.visibility = 'public' ORDER BY u.username, r.name`) | |
| 201 | if err != nil { | |
| 202 | return nil, err | |
| 203 | } | |
| 204 | defer rows.Close() | |
| 205 | var out []Repo | |
| 206 | for rows.Next() { | |
| 207 | var r Repo | |
| 208 | var settingsJSON string | |
| 209 | if err := rows.Scan(&r.ID, &r.OwnerKind, &r.OwnerID, &r.OwnerName, &r.Name, &r.Visibility, &r.DefaultBranch, &settingsJSON); err != nil { | |
| 210 | return nil, err | |
| 211 | } | |
| 212 | out = append(out, r) | |
| 213 | } | |
| 214 | return out, rows.Err() | |
| 215 | } | |
internal/web/static/style.css added +53
| @@ -0,0 +1,53 @@ | ||
| 1 | :root { | |
| 2 | --bg: #ffffff; --fg: #1a1a1a; --muted: #666; --line: #ddd; | |
| 3 | --link: #0550ae; --code-bg: #f6f8fa; | |
| 4 | --ok: #1a7f37; --warn: #9a6700; --bad: #cf222e; --neutral: #666; | |
| 5 | } | |
| 6 | @media (prefers-color-scheme: dark) { | |
| 7 | :root { | |
| 8 | --bg: #0d1117; --fg: #e6edf3; --muted: #8b949e; --line: #30363d; | |
| 9 | --link: #58a6ff; --code-bg: #161b22; | |
| 10 | --ok: #3fb950; --warn: #d29922; --bad: #f85149; --neutral: #8b949e; | |
| 11 | } | |
| 12 | } | |
| 13 | * { box-sizing: border-box; } | |
| 14 | body { | |
| 15 | margin: 0; background: var(--bg); color: var(--fg); | |
| 16 | font: 15px/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; | |
| 17 | } | |
| 18 | header { border-bottom: 1px solid var(--line); padding: 0.6rem 1rem; } | |
| 19 | a.site { font-weight: 700; } | |
| 20 | main { max-width: 60rem; margin: 0 auto; padding: 1rem; } | |
| 21 | a { color: var(--link); text-decoration: none; } | |
| 22 | a:hover { text-decoration: underline; } | |
| 23 | h1 { font-size: 1.3rem; } | |
| 24 | h2 { font-size: 1.1rem; } | |
| 25 | code, pre, .code, td.mode, td.size { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 13px; } | |
| 26 | table { border-collapse: collapse; width: 100%; } | |
| 27 | td { padding: 0.25rem 0.6rem 0.25rem 0; border-bottom: 1px solid var(--line); vertical-align: top; } | |
| 28 | td.size, td.mode { color: var(--muted); white-space: nowrap; } | |
| 29 | nav.tabs a { margin-right: 1rem; } | |
| 30 | p.clone code { background: var(--code-bg); padding: 0.15rem 0.4rem; border-radius: 4px; } | |
| 31 | .crumbs { color: var(--muted); } | |
| 32 | .readme, .code { border: 1px solid var(--line); border-radius: 6px; padding: 1rem; margin-top: 1rem; overflow-x: auto; } | |
| 33 | /* chroma emits inline styles for a light background; pin the block to light | |
| 34 | colors in both schemes so unstyled tokens stay legible. */ | |
| 35 | .code { background: #f8f8f8; color: #1a1a1a; } | |
| 36 | .code pre { margin: 0; background: transparent !important; } | |
| 37 | pre.message { background: var(--code-bg); padding: 0.8rem; border-radius: 6px; } | |
| 38 | pre.diff { background: var(--code-bg); padding: 0.8rem; border-radius: 6px; overflow-x: auto; } | |
| 39 | pre.diff .add { color: var(--ok); } | |
| 40 | pre.diff .del { color: var(--bad); } | |
| 41 | pre.diff .hunk { color: var(--link); } | |
| 42 | pre.diff .meta { color: var(--muted); } | |
| 43 | .badge { | |
| 44 | display: inline-block; padding: 0.05rem 0.5rem; border-radius: 10px; | |
| 45 | font-size: 12px; border: 1px solid; | |
| 46 | } | |
| 47 | .badge-verified { color: var(--ok); border-color: var(--ok); } | |
| 48 | .badge-unsigned { color: var(--neutral); border-color: var(--line); } | |
| 49 | .badge-signed_unknown_key { color: var(--warn); border-color: var(--warn); } | |
| 50 | .badge-signed_email_mismatch { color: var(--bad); border-color: var(--bad); } | |
| 51 | .badge-signed_key_expired { color: var(--warn); border-color: var(--warn); } | |
| 52 | .badge-signed_key_revoked { color: var(--bad); border-color: var(--bad); } | |
| 53 | .badge-bad_signature { color: var(--bad); border-color: var(--bad); } | |
internal/web/templates/blob.html added +8
| @@ -0,0 +1,8 @@ | ||
| 1 | {{define "title"}}{{.Path}} · {{.Repo.OwnerName}}/{{.Repo.Name}}{{end}} | |
| 2 | {{define "content"}} | |
| 3 | {{template "repoheader" .}} | |
| 4 | <p class="crumbs">{{.Ref}}: {{range .Crumbs}}<a href="{{.URL}}">{{.Name}}</a>/{{end}}{{.Base}} | |
| 5 | · <a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/raw/{{.Ref}}/{{.Path}}">raw</a></p> | |
| 6 | {{if .Binary}}<p>binary file, {{.Size}} bytes — <a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/raw/{{.Ref}}/{{.Path}}">download</a></p> | |
| 7 | {{else}}<div class="code">{{.CodeHTML}}</div>{{end}} | |
| 8 | {{end}} | |
internal/web/templates/commit.html added +11
| @@ -0,0 +1,11 @@ | ||
| 1 | {{define "title"}}{{.ShortSHA}} · {{.Repo.OwnerName}}/{{.Repo.Name}}{{end}} | |
| 2 | {{define "content"}} | |
| 3 | {{template "repoheader" .}} | |
| 4 | <h2><code>{{.SHA}}</code></h2> | |
| 5 | <p>{{template "sigbadge" .Sig}}</p> | |
| 6 | <p>author: {{.AuthorName}} <{{.AuthorEmail}}> · {{.Date}} | |
| 7 | {{if .CommitterEmail}}<br>committer: <{{.CommitterEmail}}>{{end}}</p> | |
| 8 | <pre class="message">{{.Message}}</pre> | |
| 9 | <pre class="diff">{{range .DiffLines}}<span class="{{.Class}}">{{.Text}}</span> | |
| 10 | {{end}}</pre> | |
| 11 | {{end}} | |
internal/web/templates/index.html added +8
| @@ -0,0 +1,8 @@ | ||
| 1 | {{define "title"}}{{.Site}}{{end}} | |
| 2 | {{define "content"}} | |
| 3 | <h1>repositories</h1> | |
| 4 | <table> | |
| 5 | {{range .Repos}}<tr><td><a href="/{{.OwnerName}}/{{.Name}}">{{.OwnerName}}/{{.Name}}</a></td><td>{{.DefaultBranch}}</td></tr> | |
| 6 | {{else}}<tr><td>no public repositories</td></tr>{{end}} | |
| 7 | </table> | |
| 8 | {{end}} | |
internal/web/templates/layout.html added +30
| @@ -0,0 +1,30 @@ | ||
| 1 | {{define "layout"}}<!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>{{template "title" .}}</title> | |
| 7 | <link rel="stylesheet" href="/static/style.css"> | |
| 8 | </head> | |
| 9 | <body> | |
| 10 | <header> | |
| 11 | <nav><a class="site" href="/">{{.Site}}</a></nav> | |
| 12 | </header> | |
| 13 | <main> | |
| 14 | {{template "content" .}} | |
| 15 | </main> | |
| 16 | </body> | |
| 17 | </html>{{end}} | |
| 18 | ||
| 19 | {{define "repoheader"}} | |
| 20 | <h1><a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}">{{.Repo.OwnerName}}/{{.Repo.Name}}</a></h1> | |
| 21 | <nav class="tabs"> | |
| 22 | <a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}">files</a> | |
| 23 | <a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/log">log</a> | |
| 24 | <a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/refs">refs</a> | |
| 25 | <a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/archive/{{.Ref}}.tar.gz">archive</a> | |
| 26 | </nav> | |
| 27 | <p class="clone">clone: <code>git clone {{.CloneURL}}</code></p> | |
| 28 | {{end}} | |
| 29 | ||
| 30 | {{define "sigbadge"}}<span class="badge badge-{{.State}}" title="{{.Fingerprint}}">{{.State}}{{if .Signer}} · {{.Signer}}{{end}}</span>{{end}} | |
internal/web/templates/log.html added +15
| @@ -0,0 +1,15 @@ | ||
| 1 | {{define "title"}}log · {{.Repo.OwnerName}}/{{.Repo.Name}}{{end}} | |
| 2 | {{define "content"}} | |
| 3 | {{template "repoheader" .}} | |
| 4 | <table class="log"> | |
| 5 | {{range .Commits}}<tr> | |
| 6 | <td><code><a href="/{{$.Repo.OwnerName}}/{{$.Repo.Name}}/commit/{{.SHA}}">{{.ShortSHA}}</a></code></td> | |
| 7 | <td>{{.Subject}}</td> | |
| 8 | <td>{{.AuthorName}} <{{.AuthorEmail}}></td> | |
| 9 | <td>{{.Date}}</td> | |
| 10 | <td>{{template "sigbadge" .Sig}}</td> | |
| 11 | </tr> | |
| 12 | {{end}} | |
| 13 | </table> | |
| 14 | {{if .NextSHA}}<p><a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/log/{{.NextSHA}}">older →</a></p>{{end}} | |
| 15 | {{end}} | |
internal/web/templates/refs.html added +8
| @@ -0,0 +1,8 @@ | ||
| 1 | {{define "title"}}refs · {{.Repo.OwnerName}}/{{.Repo.Name}}{{end}} | |
| 2 | {{define "content"}} | |
| 3 | {{template "repoheader" .}} | |
| 4 | <h2>branches</h2> | |
| 5 | <table>{{range .Branches}}<tr><td><a href="/{{$.Repo.OwnerName}}/{{$.Repo.Name}}/tree/{{.Name}}/">{{.Name}}</a></td><td><code>{{.SHA}}</code></td></tr>{{end}}</table> | |
| 6 | <h2>tags</h2> | |
| 7 | <table>{{range .Tags}}<tr><td><a href="/{{$.Repo.OwnerName}}/{{$.Repo.Name}}/tree/{{.Name}}/">{{.Name}}</a></td><td><code>{{.SHA}}</code></td></tr>{{else}}<tr><td>none</td></tr>{{end}}</table> | |
| 8 | {{end}} | |
internal/web/templates/tree.html added +14
| @@ -0,0 +1,14 @@ | ||
| 1 | {{define "title"}}{{.Repo.OwnerName}}/{{.Repo.Name}}{{end}} | |
| 2 | {{define "content"}} | |
| 3 | {{template "repoheader" .}} | |
| 4 | <p class="crumbs">{{.Ref}}: {{range .Crumbs}}<a href="{{.URL}}">{{.Name}}</a>/{{end}}</p> | |
| 5 | <table class="tree"> | |
| 6 | {{range .Entries}}<tr> | |
| 7 | <td class="mode">{{.Mode}}</td> | |
| 8 | {{if eq .Type "tree"}}<td><a href="/{{$.Repo.OwnerName}}/{{$.Repo.Name}}/tree/{{$.Ref}}/{{$.Prefix}}{{.Name}}">{{.Name}}/</a></td><td></td> | |
| 9 | {{else}}<td><a href="/{{$.Repo.OwnerName}}/{{$.Repo.Name}}/blob/{{$.Ref}}/{{$.Prefix}}{{.Name}}">{{.Name}}</a></td><td class="size">{{.Size}}</td>{{end}} | |
| 10 | </tr> | |
| 11 | {{else}}<tr><td>empty</td></tr>{{end}} | |
| 12 | </table> | |
| 13 | {{if .ReadmeHTML}}<section class="readme">{{.ReadmeHTML}}</section>{{end}} | |
| 14 | {{end}} | |
internal/web/web.go added +26
| @@ -0,0 +1,26 @@ | ||
| 1 | // Package web holds the server-rendered templates and static assets for the | |
| 2 | // read-only UI. No JavaScript, no build step. | |
| 3 | package web | |
| 4 | ||
| 5 | import ( | |
| 6 | "embed" | |
| 7 | "html/template" | |
| 8 | "io" | |
| 9 | ) | |
| 10 | ||
| 11 | //go:embed templates/*.html | |
| 12 | var templateFS embed.FS | |
| 13 | ||
| 14 | //go:embed static/style.css | |
| 15 | var StyleCSS []byte | |
| 16 | ||
| 17 | // Render executes the named page template with the shared layout. | |
| 18 | func Render(w io.Writer, page string, data any) error { | |
| 19 | t, err := template.Must( | |
| 20 | template.ParseFS(templateFS, "templates/layout.html"), | |
| 21 | ).ParseFS(templateFS, "templates/"+page) | |
| 22 | if err != nil { | |
| 23 | return err | |
| 24 | } | |
| 25 | return t.ExecuteTemplate(w, "layout", data) | |
| 26 | } | |