Commit ab56677031
Verified · cmc
Layout: unified · split
e2e/git_test.go +3
| @@ -17,6 +17,9 @@ func (i *instance) gitEnv(key string) []string { | |||
| 17 | key, filepath.Join(i.sshDir, "known_hosts")) | 17 | key, filepath.Join(i.sshDir, "known_hosts")) |
| 18 | return append(os.Environ(), | 18 | return append(os.Environ(), |
| 19 | "GIT_SSH_COMMAND="+sshCmd, | 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 | "GIT_AUTHOR_NAME=t", "GIT_AUTHOR_EMAIL=t@example.test", | 23 | "GIT_AUTHOR_NAME=t", "GIT_AUTHOR_EMAIL=t@example.test", |
| 21 | "GIT_COMMITTER_NAME=t", "GIT_COMMITTER_EMAIL=t@example.test", | 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 | "GIT_TERMINAL_PROMPT=0", | 53 | "GIT_TERMINAL_PROMPT=0", |
| 54 | "GIT_ASKPASS=false", | 54 | "GIT_ASKPASS=false", |
| 55 | "GIT_CONFIG_NOSYSTEM=1", | 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 | "GIT_AUTHOR_NAME=t", "GIT_AUTHOR_EMAIL=t@example.test", | 57 | "GIT_AUTHOR_NAME=t", "GIT_AUTHOR_EMAIL=t@example.test", |
| 58 | "GIT_COMMITTER_NAME=t", "GIT_COMMITTER_EMAIL=t@example.test", | 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 | func buildCommits(t *testing.T, dir string, env []string, specs []commitSpec) []string { | 67 | func buildCommits(t *testing.T, dir string, env []string, specs []commitSpec) []string { |
| 68 | t.Helper() | 68 | t.Helper() |
| 69 | tree := strings.TrimSpace(mustGit(t, dir, env, "mktree")) | 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 | base := time.Now().Add(-time.Duration(len(specs)) * time.Minute).Unix() | 77 | base := time.Now().Add(-time.Duration(len(specs)) * time.Minute).Unix() |
| 72 | var shas []string | 78 | var shas []string |
| 73 | for i, spec := range specs { | 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 | require ( | 5 | require ( |
| 6 | github.com/BurntSushi/toml v1.6.0 | 6 | github.com/BurntSushi/toml v1.6.0 |
| 7 | github.com/ProtonMail/go-crypto v1.4.1 | 7 | github.com/ProtonMail/go-crypto v1.4.1 |
| 8 | github.com/alecthomas/chroma/v2 v2.27.0 | ||
| 8 | github.com/spf13/cobra v1.10.2 | 9 | github.com/spf13/cobra v1.10.2 |
| 10 | github.com/yuin/goldmark v1.8.5 | ||
| 9 | golang.org/x/crypto v0.55.0 | 11 | golang.org/x/crypto v0.55.0 |
| 10 | modernc.org/sqlite v1.57.0 | 12 | modernc.org/sqlite v1.57.0 |
| 11 | ) | 13 | ) |
| 12 | 14 | ||
| 13 | require ( | 15 | require ( |
| 14 | github.com/cloudflare/circl v1.6.2 // indirect | 16 | github.com/cloudflare/circl v1.6.2 // indirect |
| 17 | github.com/dlclark/regexp2/v2 v2.2.1 // indirect | ||
| 15 | github.com/dustin/go-humanize v1.0.1 // indirect | 18 | github.com/dustin/go-humanize v1.0.1 // indirect |
| 16 | github.com/google/uuid v1.6.0 // indirect | 19 | github.com/google/uuid v1.6.0 // indirect |
| 17 | github.com/inconshreveable/mousetrap v1.1.0 // indirect | 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 | github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= | 2 | github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= |
| 3 | github.com/ProtonMail/go-crypto v1.4.1 h1:9RfcZHqEQUvP8RzecWEUafnZVtEvrBVL9BiF67IQOfM= | 3 | github.com/ProtonMail/go-crypto v1.4.1 h1:9RfcZHqEQUvP8RzecWEUafnZVtEvrBVL9BiF67IQOfM= |
| 4 | github.com/ProtonMail/go-crypto v1.4.1/go.mod h1:e1OaTyu5SYVrO9gKOEhTc+5UcXtTUa+P3uLudwcgPqo= | 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 | github.com/cloudflare/circl v1.6.2 h1:hL7VBpHHKzrV5WTfHCaBsgx/HGbBYlgrwvNXEVDYYsQ= | 11 | github.com/cloudflare/circl v1.6.2 h1:hL7VBpHHKzrV5WTfHCaBsgx/HGbBYlgrwvNXEVDYYsQ= |
| 6 | github.com/cloudflare/circl v1.6.2/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4= | 12 | github.com/cloudflare/circl v1.6.2/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4= |
| 7 | github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= | 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 | github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= | 16 | github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= |
| 9 | github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= | 17 | github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= |
| 10 | github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3 h1:LMLX+LgTNWpfvCBdFebv6EsYotImrt/Ppc5cXIriCSo= | 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 | github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= | 21 | github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= |
| 14 | github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= | 22 | github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= |
| 15 | github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= | 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 | github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= | 26 | github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= |
| 17 | github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= | 27 | github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= |
| 18 | github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI= | 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 | github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= | 36 | github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= |
| 27 | github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= | 37 | github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= |
| 28 | github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= | 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 | go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= | 41 | go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= |
| 30 | golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M= | 42 | golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M= |
| 31 | golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis= | 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 | package hookd | 5 | package hookd |
| 6 | 6 | ||
| 7 | import ( | 7 | import ( |
| 8 | "crypto/sha256" | ||
| 8 | "encoding/json" | 9 | "encoding/json" |
| 9 | "fmt" | 10 | "fmt" |
| 10 | "net" | 11 | "net" |
| @@ -35,8 +36,19 @@ type Response struct { | |||
| 35 | Message string `json:"message,omitempty"` | 36 | Message string `json:"message,omitempty"` |
| 36 | } | 37 | } |
| 37 | 38 | ||
| 38 | // SocketPath returns the hook socket location under the server root. | 39 | // SocketPath returns the hook socket location. It prefers the server root, |
| 39 | func SocketPath(root string) string { return filepath.Join(root, "hook.sock") } | 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 | type Server struct { | 53 | type Server struct { |
| 42 | st *store.Store | 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 | return &Server{cfg: cfg, st: st} | 28 | return &Server{cfg: cfg, st: st} |
| 29 | } | 29 | } |
| 30 | 30 | ||
| 31 | func (s *Server) Handler() http.Handler { | 31 | // receivePackRefusal exists only to fail legibly if a client POSTs without |
| 32 | mux := http.NewServeMux() | 32 | // reading the advertisement first. |
| 33 | mux.HandleFunc("GET /{owner}/{repo}/info/refs", s.infoRefs) | 33 | func (s *Server) receivePackRefusal(w http.ResponseWriter, r *http.Request) { |
| 34 | mux.HandleFunc("POST /{owner}/{repo}/git-upload-pack", s.uploadPack) | 34 | http.Error(w, s.pushRefusalMessage(r.PathValue("owner"), r.PathValue("repo")), http.StatusForbidden) |
| 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 | ||
| 40 | } | 35 | } |
| 41 | 36 | ||
| 42 | // publicRepo resolves owner/name and returns it only if it exists and is | 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 | return r, nil | 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 | } | ||