Commit ab56677031

ab56677031290fee18323c467841a25216d45768

parent: 35d07f32e1

Verified · cmc

cmc <hello@cleberg.net> · 2026-08-23 22:57 UTC

M5: read-only web UI

- server-rendered pages (html/template, no JS, no build step): repo
  index, tree with README rendering (goldmark, raw HTML dropped), blob
  with chroma highlighting, raw with nosniff, log and commit pages with
  signature badges, refs, tar.gz archive
- explicit route table with per-route mutating flag; Handler() builds
  the mux from it; tests assert view_only has zero mutating routes and
  that every top-level route word is a reserved username
- private and nonexistent repos both 404 on every web path
- light/dark via prefers-color-scheme; code blocks pinned light to
  match chroma's inline styles
- hook socket falls back to a hashed temp path when the server root
  exceeds the unix sun_path limit
- e2e: test git env isolated from developer gitconfig (commit.gpgsign)

Layout: unified · split

e2e/git_test.go +3
@@ -17,6 +17,9 @@ func (i *instance) gitEnv(key string) []string {
1717 key, filepath.Join(i.sshDir, "known_hosts"))
1818 return append(os.Environ(),
1919 "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",
2023 "GIT_AUTHOR_NAME=t", "GIT_AUTHOR_EMAIL=t@example.test",
2124 "GIT_COMMITTER_NAME=t", "GIT_COMMITTER_EMAIL=t@example.test",
2225 )
e2e/http_test.go +1 −1
@@ -53,7 +53,7 @@ func anonEnv() []string {
5353 "GIT_TERMINAL_PROMPT=0",
5454 "GIT_ASKPASS=false",
5555 "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
5757 "GIT_AUTHOR_NAME=t", "GIT_AUTHOR_EMAIL=t@example.test",
5858 "GIT_COMMITTER_NAME=t", "GIT_COMMITTER_EMAIL=t@example.test",
5959 )
e2e/sig_test.go +7 −1
@@ -67,7 +67,13 @@ type commitSpec struct {
6767func buildCommits(t *testing.T, dir string, env []string, specs []commitSpec) []string {
6868 t.Helper()
6969 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.
75func buildChain(t *testing.T, dir string, env []string, tree, parent string, specs []commitSpec) []string {
76 t.Helper()
7177 base := time.Now().Add(-time.Duration(len(specs)) * time.Minute).Unix()
7278 var shas []string
7379 for i, spec := range specs {
e2e/web_test.go added +176
@@ -0,0 +1,176 @@
1package e2e
2
3import (
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
18func (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
29func 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.
161func 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
55require (
66 github.com/BurntSushi/toml v1.6.0
77 github.com/ProtonMail/go-crypto v1.4.1
8 github.com/alecthomas/chroma/v2 v2.27.0
89 github.com/spf13/cobra v1.10.2
10 github.com/yuin/goldmark v1.8.5
911 golang.org/x/crypto v0.55.0
1012 modernc.org/sqlite v1.57.0
1113)
1214
1315require (
1416 github.com/cloudflare/circl v1.6.2 // indirect
17 github.com/dlclark/regexp2/v2 v2.2.1 // indirect
1518 github.com/dustin/go-humanize v1.0.1 // indirect
1619 github.com/google/uuid v1.6.0 // indirect
1720 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
22github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
33github.com/ProtonMail/go-crypto v1.4.1 h1:9RfcZHqEQUvP8RzecWEUafnZVtEvrBVL9BiF67IQOfM=
44github.com/ProtonMail/go-crypto v1.4.1/go.mod h1:e1OaTyu5SYVrO9gKOEhTc+5UcXtTUa+P3uLudwcgPqo=
5github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0=
6github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
7github.com/alecthomas/chroma/v2 v2.27.0 h1:FodwmyOBgJULFYmDqibcp9pvfDLWdtPRh9v/r5BXYZs=
8github.com/alecthomas/chroma/v2 v2.27.0/go.mod h1:NjJ3ciIgrqBNeIkWZ4e46nseoLDslxU1LmfCoL+wcY8=
9github.com/alecthomas/repr v0.5.2 h1:SU73FTI9D1P5UNtvseffFSGmdNci/O6RsqzeXJtP0Qs=
10github.com/alecthomas/repr v0.5.2/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
511github.com/cloudflare/circl v1.6.2 h1:hL7VBpHHKzrV5WTfHCaBsgx/HGbBYlgrwvNXEVDYYsQ=
612github.com/cloudflare/circl v1.6.2/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4=
713github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
14github.com/dlclark/regexp2/v2 v2.2.1 h1:mf4KkFUj0gJuarK8P+LgiS+Lit7m9N1yAwEfPbee7R0=
15github.com/dlclark/regexp2/v2 v2.2.1/go.mod h1:avUrQvPaLz2DrFNHJF0taWAFFX2C1GMSSoeiqFjcBmU=
816github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
917github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
1018github.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=
1321github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
1422github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
1523github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
24github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
25github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
1626github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
1727github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
1828github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI=
@@ -26,6 +36,8 @@ github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
2636github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
2737github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
2838github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
39github.com/yuin/goldmark v1.8.5 h1:r6N5afV5qj/5S4UTch8agZHJ8UxNCMwX7WjkkJam2NA=
40github.com/yuin/goldmark v1.8.5/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
2941go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
3042golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M=
3143golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis=
internal/gitutil/read.go added +132
@@ -0,0 +1,132 @@
1package gitutil
2
3import (
4 "bytes"
5 "fmt"
6 "io"
7 "os/exec"
8 "strconv"
9 "strings"
10)
11
12type 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.
21func 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.
55func 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.
73func 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
82type Ref struct {
83 Name string
84 SHA string
85}
86
87// Refs lists branches or tags; kind is "heads" or "tags".
88func 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.
105func 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.
112func 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.
130func IsBinary(data []byte) bool {
131 return bytes.IndexByte(data, 0) >= 0
132}
internal/hookd/hookd.go +14 −2
@@ -5,6 +5,7 @@
55package hookd
66
77import (
8 "crypto/sha256"
89 "encoding/json"
910 "fmt"
1011 "net"
@@ -35,8 +36,19 @@ type Response struct {
3536 Message string `json:"message,omitempty"`
3637}
3738
38// SocketPath returns the hook socket location under the server root.
39func 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.
44func 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}
4052
4153type Server struct {
4254 st *store.Store
internal/httpd/routes.go added +53
@@ -0,0 +1,53 @@
1package httpd
2
3import "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.
8type 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.
19func (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
47func (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 @@
1package httpd
2
3import (
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.
14func 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.
39func 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 {
2828 return &Server{cfg: cfg, st: st}
2929}
3030
31func (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.
33func (s *Server) receivePackRefusal(w http.ResponseWriter, r *http.Request) {
34 http.Error(w, s.pushRefusalMessage(r.PathValue("owner"), r.PathValue("repo")), http.StatusForbidden)
4035}
4136
4237// publicRepo resolves owner/name and returns it only if it exists and is
internal/httpd/web.go added +399
@@ -0,0 +1,399 @@
1package httpd
2
3import (
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
24const maxRenderBytes = 1 << 20 // largest blob rendered inline
25
26func (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
36func (s *Server) siteName() string {
37 h := strings.TrimPrefix(strings.TrimPrefix(s.cfg.Server.SiteURL, "https://"), "http://")
38 return strings.TrimSuffix(h, "/")
39}
40
41func (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
46func (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.
59type 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.
70func (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
88type crumb struct {
89 Name string
90 URL string
91}
92
93func 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
107func (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
115func (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
123func (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
177func (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
211func 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
229func (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
246type sigView struct {
247 State string
248 Signer string
249 Fingerprint string
250}
251
252func (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
274func (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
314func (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
367func (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
380func (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) {
191191 }
192192 return r, nil
193193}
194
195// ListPublicRepos returns all public repositories, for the anonymous index.
196func (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; }
14body {
15 margin: 0; background: var(--bg); color: var(--fg);
16 font: 15px/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
17}
18header { border-bottom: 1px solid var(--line); padding: 0.6rem 1rem; }
19a.site { font-weight: 700; }
20main { max-width: 60rem; margin: 0 auto; padding: 1rem; }
21a { color: var(--link); text-decoration: none; }
22a:hover { text-decoration: underline; }
23h1 { font-size: 1.3rem; }
24h2 { font-size: 1.1rem; }
25code, pre, .code, td.mode, td.size { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 13px; }
26table { border-collapse: collapse; width: 100%; }
27td { padding: 0.25rem 0.6rem 0.25rem 0; border-bottom: 1px solid var(--line); vertical-align: top; }
28td.size, td.mode { color: var(--muted); white-space: nowrap; }
29nav.tabs a { margin-right: 1rem; }
30p.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; }
37pre.message { background: var(--code-bg); padding: 0.8rem; border-radius: 6px; }
38pre.diff { background: var(--code-bg); padding: 0.8rem; border-radius: 6px; overflow-x: auto; }
39pre.diff .add { color: var(--ok); }
40pre.diff .del { color: var(--bad); }
41pre.diff .hunk { color: var(--link); }
42pre.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}} &lt;{{.AuthorEmail}}&gt; · {{.Date}}
7{{if .CommitterEmail}}<br>committer: &lt;{{.CommitterEmail}}&gt;{{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}} &lt;{{.AuthorEmail}}&gt;</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.
3package web
4
5import (
6 "embed"
7 "html/template"
8 "io"
9)
10
11//go:embed templates/*.html
12var templateFS embed.FS
13
14//go:embed static/style.css
15var StyleCSS []byte
16
17// Render executes the named page template with the shared layout.
18func 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}