krz/gitbay
A CLI-first git forge.
clone: git clone https://gitbay.org/krz/gitbay.git
ab56677031290fee18323c467841a25216d45768
verified · cmc
author: Christian Cleberg <hello@cleberg.net> · 2026-08-23T22:57:41Z
e2e/git_test.go | 3 + e2e/http_test.go | 2 +- e2e/sig_test.go | 8 +- e2e/web_test.go | 176 ++++++++++++++++ go.mod | 3 + go.sum | 12 ++ internal/gitutil/read.go | 132 ++++++++++++ internal/hookd/hookd.go | 16 +- internal/httpd/routes.go | 53 +++++ internal/httpd/routes_test.go | 51 +++++ internal/httpd/smart.go | 13 +- internal/httpd/web.go | 399 +++++++++++++++++++++++++++++++++++++ internal/store/repos.go | 22 ++ internal/web/static/style.css | 53 +++++ internal/web/templates/blob.html | 8 + internal/web/templates/commit.html | 11 + internal/web/templates/index.html | 8 + internal/web/templates/layout.html | 30 +++ internal/web/templates/log.html | 15 ++ internal/web/templates/refs.html | 8 + internal/web/templates/tree.html | 14 ++ internal/web/web.go | 26 +++ 22 files changed, 1050 insertions(+), 13 deletions(-) @@ -17,6 +17,9 @@ func (i *instance) gitEnv(key string) []string { key, filepath.Join(i.sshDir, "known_hosts")) return append(os.Environ(), "GIT_SSH_COMMAND="+sshCmd, + // Isolate from the developer's own git config (signing, helpers). + "GIT_CONFIG_NOSYSTEM=1", + "GIT_CONFIG_GLOBAL=/dev/null", "GIT_AUTHOR_NAME=t", "GIT_AUTHOR_EMAIL=t@example.test", "GIT_COMMITTER_NAME=t", "GIT_COMMITTER_EMAIL=t@example.test", ) @@ -53,7 +53,7 @@ func anonEnv() []string { "GIT_TERMINAL_PROMPT=0", "GIT_ASKPASS=false", "GIT_CONFIG_NOSYSTEM=1", - "HOME=/nonexistent-forge-e2e", // no ~/.gitconfig credential helpers + "GIT_CONFIG_GLOBAL=/dev/null", // no ~/.gitconfig credential helpers or signing "GIT_AUTHOR_NAME=t", "GIT_AUTHOR_EMAIL=t@example.test", "GIT_COMMITTER_NAME=t", "GIT_COMMITTER_EMAIL=t@example.test", ) @@ -67,7 +67,13 @@ type commitSpec struct { func buildCommits(t *testing.T, dir string, env []string, specs []commitSpec) []string { t.Helper() tree := strings.TrimSpace(mustGit(t, dir, env, "mktree")) - parent := "" + return buildChain(t, dir, env, tree, "", specs) +} + +// buildChain constructs signed commit objects on top of parent ("" for a +// root commit) using the given tree, and points refs/heads/main at the tip. +func buildChain(t *testing.T, dir string, env []string, tree, parent string, specs []commitSpec) []string { + t.Helper() base := time.Now().Add(-time.Duration(len(specs)) * time.Minute).Unix() var shas []string for i, spec := range specs { new file mode 100644 @@ -0,0 +1,176 @@ +package e2e + +import ( + "compress/gzip" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "strings" + "testing" + + "golang.org/x/crypto/ssh" + + "github.com/krazywarez/forge/internal/sig" +) + +func (i *instance) get(t *testing.T, path string) (int, string) { + t.Helper() + resp, err := http.Get(fmt.Sprintf("http://127.0.0.1:%d%s", i.httpPort, path)) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + body, _ := io.ReadAll(resp.Body) + return resp.StatusCode, string(body) +} + +func TestWebUI(t *testing.T) { + inst := startInstance(t) + + aliceKey := inst.newKey(t, "alice") + inst.admin(t, "admin", "user", "create", "alice", + "--key", aliceKey+".pub", "--email", "alice@example.test", "--verified") + + // Public repo with real content: a README, a source file, a tag, and + // one SSHSIG-signed commit for the badge check. + if _, errOut, code := inst.ssh(t, aliceKey, "", "repo", "create", "alice/site"); code != 0 { + t.Fatalf("repo create: %s", errOut) + } + work := t.TempDir() + env := inst.gitEnv(aliceKey) + mustGit(t, work, env, "clone", inst.sshURL("alice/site"), "w") + dir := filepath.Join(work, "w") + os.WriteFile(filepath.Join(dir, "README.md"), []byte("# hello site\n\nsome *markdown*\n"), 0o644) + os.MkdirAll(filepath.Join(dir, "src"), 0o755) + os.WriteFile(filepath.Join(dir, "src", "main.go"), []byte("package main\n\nfunc main() {}\n"), 0o644) + mustGit(t, dir, env, "checkout", "-q", "-b", "main") + mustGit(t, dir, env, "add", ".") + mustGit(t, dir, env, "commit", "-q", "-m", "first commit") + mustGit(t, dir, env, "tag", "v1.0") + mustGit(t, dir, env, "push", "-q", "origin", "main", "v1.0") + + // A signed commit on top, built with the M4 fixture helpers. + sshRaw, _ := os.ReadFile(aliceKey) + signer, err := ssh.ParsePrivateKey(sshRaw) + if err != nil { + t.Fatal(err) + } + head := strings.TrimSpace(mustGit(t, dir, env, "rev-parse", "HEAD")) + buildSignedCommitOn(t, dir, env, head, "signed tip", "alice@example.test", signer) + mustGit(t, dir, env, "push", "-q", "origin", "main") + + // Private repo must be invisible everywhere. + if _, _, code := inst.ssh(t, aliceKey, "", "repo", "create", "alice/secret", "--private"); code != 0 { + t.Fatal("create private failed") + } + + // Index lists the public repo, not the private one. + status, body := inst.get(t, "/") + if status != 200 || !strings.Contains(body, "alice/site") { + t.Fatalf("index: %d\n%s", status, body) + } + if strings.Contains(body, "secret") { + t.Fatal("index leaks private repo") + } + + // Repo home: tree entries plus rendered README. + status, body = inst.get(t, "/alice/site") + if status != 200 || !strings.Contains(body, "src/") || !strings.Contains(body, "README.md") { + t.Fatalf("repo home: %d\n%s", status, body) + } + if !strings.Contains(body, "<h1>hello site</h1>") || !strings.Contains(body, "<em>markdown</em>") { + t.Fatalf("README not rendered:\n%s", body) + } + + // Subdirectory tree and blob with highlighting. + status, body = inst.get(t, "/alice/site/tree/main/src") + if status != 200 || !strings.Contains(body, "main.go") { + t.Fatalf("tree src: %d", status) + } + status, body = inst.get(t, "/alice/site/blob/main/src/main.go") + if status != 200 || !strings.Contains(body, "package") { + t.Fatalf("blob: %d", status) + } + + // Raw serves exact bytes with nosniff. + resp, err := http.Get(fmt.Sprintf("http://127.0.0.1:%d/alice/site/raw/main/src/main.go", inst.httpPort)) + if err != nil { + t.Fatal(err) + } + raw, _ := io.ReadAll(resp.Body) + resp.Body.Close() + if string(raw) != "package main\n\nfunc main() {}\n" { + t.Fatalf("raw bytes: %q", raw) + } + if resp.Header.Get("X-Content-Type-Options") != "nosniff" { + t.Fatal("raw missing nosniff") + } + + // Log: both commits, with badges matching the M4 states exactly. + status, body = inst.get(t, "/alice/site/log") + if status != 200 { + t.Fatalf("log: %d", status) + } + if !strings.Contains(body, "badge-verified") || !strings.Contains(body, "signed tip") { + t.Fatalf("log missing verified badge:\n%s", body) + } + if !strings.Contains(body, "badge-unsigned") || !strings.Contains(body, "first commit") { + t.Fatalf("log missing unsigned badge:\n%s", body) + } + + // Commit page for the signed tip. + tip := strings.TrimSpace(mustGit(t, dir, env, "rev-parse", "HEAD")) + status, body = inst.get(t, "/alice/site/commit/"+tip) + if status != 200 || !strings.Contains(body, "badge-verified") || !strings.Contains(body, "alice") { + t.Fatalf("commit page: %d\n%s", status, body) + } + + // Refs page shows branch and tag. + status, body = inst.get(t, "/alice/site/refs") + if status != 200 || !strings.Contains(body, "main") || !strings.Contains(body, "v1.0") { + t.Fatalf("refs: %d", status) + } + + // Archive downloads a valid gzip. + resp, err = http.Get(fmt.Sprintf("http://127.0.0.1:%d/alice/site/archive/main.tar.gz", inst.httpPort)) + if err != nil { + t.Fatal(err) + } + gz, err := gzip.NewReader(resp.Body) + if err != nil { + t.Fatalf("archive not gzip: %v", err) + } + tarBytes, _ := io.ReadAll(gz) + resp.Body.Close() + if !strings.Contains(string(tarBytes), "README.md") { + t.Fatal("archive missing content") + } + + // Private repo pages: 404, indistinguishable from nonexistent. + for _, p := range []string{"/alice/secret", "/alice/secret/log", "/alice/nothere"} { + if status, _ := inst.get(t, p); status != 404 { + t.Errorf("GET %s = %d, want 404", p, status) + } + } +} + +// buildSignedCommitOn adds one SSHSIG-signed commit on top of parent, +// reusing the M4 fixture machinery. +func buildSignedCommitOn(t *testing.T, dir string, env []string, parent, subject, email string, signer ssh.Signer) { + t.Helper() + tree := strings.TrimSpace(mustGit(t, dir, env, "rev-parse", parent+"^{tree}")) + specs := []commitSpec{{ + authorEmail: email, + subject: subject, + sign: func(p []byte) string { + s, err := sig.MarshalSSHSig(signer, p) + if err != nil { + t.Fatal(err) + } + return string(s) + }, + }} + buildChain(t, dir, env, tree, parent, specs) +} @@ -5,13 +5,16 @@ go 1.27.0 require ( github.com/BurntSushi/toml v1.6.0 github.com/ProtonMail/go-crypto v1.4.1 + github.com/alecthomas/chroma/v2 v2.27.0 github.com/spf13/cobra v1.10.2 + github.com/yuin/goldmark v1.8.5 golang.org/x/crypto v0.55.0 modernc.org/sqlite v1.57.0 ) require ( github.com/cloudflare/circl v1.6.2 // indirect + github.com/dlclark/regexp2/v2 v2.2.1 // indirect github.com/dustin/go-humanize v1.0.1 // indirect github.com/google/uuid v1.6.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect @@ -2,9 +2,17 @@ github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= github.com/ProtonMail/go-crypto v1.4.1 h1:9RfcZHqEQUvP8RzecWEUafnZVtEvrBVL9BiF67IQOfM= github.com/ProtonMail/go-crypto v1.4.1/go.mod h1:e1OaTyu5SYVrO9gKOEhTc+5UcXtTUa+P3uLudwcgPqo= +github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0= +github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k= +github.com/alecthomas/chroma/v2 v2.27.0 h1:FodwmyOBgJULFYmDqibcp9pvfDLWdtPRh9v/r5BXYZs= +github.com/alecthomas/chroma/v2 v2.27.0/go.mod h1:NjJ3ciIgrqBNeIkWZ4e46nseoLDslxU1LmfCoL+wcY8= +github.com/alecthomas/repr v0.5.2 h1:SU73FTI9D1P5UNtvseffFSGmdNci/O6RsqzeXJtP0Qs= +github.com/alecthomas/repr v0.5.2/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= github.com/cloudflare/circl v1.6.2 h1:hL7VBpHHKzrV5WTfHCaBsgx/HGbBYlgrwvNXEVDYYsQ= github.com/cloudflare/circl v1.6.2/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/dlclark/regexp2/v2 v2.2.1 h1:mf4KkFUj0gJuarK8P+LgiS+Lit7m9N1yAwEfPbee7R0= +github.com/dlclark/regexp2/v2 v2.2.1/go.mod h1:avUrQvPaLz2DrFNHJF0taWAFFX2C1GMSSoeiqFjcBmU= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= 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= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= +github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= +github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI= @@ -26,6 +36,8 @@ github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/yuin/goldmark v1.8.5 h1:r6N5afV5qj/5S4UTch8agZHJ8UxNCMwX7WjkkJam2NA= +github.com/yuin/goldmark v1.8.5/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M= golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis= new file mode 100644 @@ -0,0 +1,132 @@ +package gitutil + +import ( + "bytes" + "fmt" + "io" + "os/exec" + "strconv" + "strings" +) + +type TreeEntry struct { + Mode string + Type string // blob | tree + SHA string + Size int64 // -1 for trees + Name string +} + +// ListTree lists one level of the tree at ref:path. +func ListTree(dir, ref, path string) ([]TreeEntry, error) { + spec := ref + if path != "" { + spec = ref + ":" + path + } + cmd := exec.Command("git", "-C", dir, "ls-tree", "-l", spec) + out, err := cmd.Output() + if err != nil { + return nil, fmt.Errorf("ls-tree %s: %w", spec, err) + } + var entries []TreeEntry + for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") { + if line == "" { + continue + } + // <mode> <type> <sha> <size>\t<name> + meta, name, ok := strings.Cut(line, "\t") + if !ok { + continue + } + f := strings.Fields(meta) + if len(f) != 4 { + continue + } + size := int64(-1) + if f[3] != "-" { + size, _ = strconv.ParseInt(f[3], 10, 64) + } + entries = append(entries, TreeEntry{Mode: f[0], Type: f[1], SHA: f[2], Size: size, Name: name}) + } + return entries, nil +} + +// ReadBlob returns the contents of ref:path, capped at limit bytes. +func ReadBlob(dir, ref, path string, limit int64) ([]byte, error) { + cmd := exec.Command("git", "-C", dir, "cat-file", "blob", ref+":"+path) + stdout, err := cmd.StdoutPipe() + if err != nil { + return nil, err + } + if err := cmd.Start(); err != nil { + return nil, err + } + data, err := io.ReadAll(io.LimitReader(stdout, limit)) + io.Copy(io.Discard, stdout) // drain so git exits cleanly + if werr := cmd.Wait(); werr != nil { + return nil, fmt.Errorf("cat-file blob %s:%s: %w", ref, path, werr) + } + return data, err +} + +// ResolveRef resolves a ref or sha to a full commit sha; errors if absent. +func ResolveRef(dir, ref string) (string, error) { + cmd := exec.Command("git", "-C", dir, "rev-parse", "--verify", "--quiet", ref+"^{commit}") + out, err := cmd.Output() + if err != nil { + return "", fmt.Errorf("unknown ref %q", ref) + } + return strings.TrimSpace(string(out)), nil +} + +type Ref struct { + Name string + SHA string +} + +// Refs lists branches or tags; kind is "heads" or "tags". +func Refs(dir, kind string) ([]Ref, error) { + cmd := exec.Command("git", "-C", dir, "for-each-ref", + "--format=%(refname:short) %(objectname)", "refs/"+kind) + out, err := cmd.Output() + if err != nil { + return nil, err + } + var refs []Ref + for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") { + if name, sha, ok := strings.Cut(line, " "); ok { + refs = append(refs, Ref{Name: name, SHA: sha}) + } + } + return refs, nil +} + +// Archive streams a tar.gz of ref to w. +func Archive(dir, ref, prefix string, w io.Writer) error { + cmd := exec.Command("git", "-C", dir, "archive", "--format=tar.gz", "--prefix="+prefix+"/", ref) + cmd.Stdout = w + return cmd.Run() +} + +// ShowPatch returns the stat+patch text for one commit. +func ShowPatch(dir, sha string, limit int64) (string, error) { + cmd := exec.Command("git", "-C", dir, "show", "--stat", "--patch", "--format=", sha) + stdout, err := cmd.StdoutPipe() + if err != nil { + return "", err + } + if err := cmd.Start(); err != nil { + return "", err + } + data, _ := io.ReadAll(io.LimitReader(stdout, limit)) + io.Copy(io.Discard, stdout) + if err := cmd.Wait(); err != nil { + return "", fmt.Errorf("show %s: %w", sha, err) + } + return string(data), nil +} + +// IsBinary reports whether data looks like binary content. +func IsBinary(data []byte) bool { + return bytes.IndexByte(data, 0) >= 0 +} @@ -5,6 +5,7 @@ package hookd import ( + "crypto/sha256" "encoding/json" "fmt" "net" @@ -35,8 +36,19 @@ type Response struct { Message string `json:"message,omitempty"` } -// SocketPath returns the hook socket location under the server root. -func SocketPath(root string) string { return filepath.Join(root, "hook.sock") } +// SocketPath returns the hook socket location. It prefers the server root, +// but unix socket paths are capped (~104 bytes on macOS, 108 on Linux), so +// deep roots fall back to a hashed name under the system temp directory. +// Hooks receive the chosen path via FORGE_HOOK_SOCKET, so both sides always +// agree. +func SocketPath(root string) string { + p := filepath.Join(root, "hook.sock") + if len(p) <= 100 { + return p + } + sum := sha256.Sum256([]byte(root)) + return filepath.Join(os.TempDir(), fmt.Sprintf("forge-%x.sock", sum[:8])) +} type Server struct { st *store.Store new file mode 100644 @@ -0,0 +1,53 @@ +package httpd + +import "net/http" + +// Route is one entry in the explicit route table. The view-only guarantee is +// structural: Handler() consults web.mode when building the table, and the +// route test asserts no mutating route exists in view_only mode. +type Route struct { + Method string + Pattern string // without method prefix + // Mutating marks routes that can change server state. The git transport + // POSTs are not mutating: upload-pack is a pure read, and the + // receive-pack endpoint is a static refusal that writes nothing. + Mutating bool + Handler http.HandlerFunc +} + +// Routes returns the route table for the configured web.mode. +func (s *Server) Routes() []Route { + // Git smart transport (anonymous, public repos only). + routes := []Route{ + {Method: "GET", Pattern: "/{owner}/{repo}/info/refs", Handler: s.infoRefs}, + {Method: "POST", Pattern: "/{owner}/{repo}/git-upload-pack", Handler: s.uploadPack}, + {Method: "POST", Pattern: "/{owner}/{repo}/git-receive-pack", Handler: s.receivePackRefusal}, + } + + // Web UI, read-only. These exist in every mode. + routes = append(routes, + Route{Method: "GET", Pattern: "/{$}", Handler: s.index}, + Route{Method: "GET", Pattern: "/static/style.css", Handler: s.stylesheet}, + Route{Method: "GET", Pattern: "/{owner}/{repo}", Handler: s.repoHome}, + Route{Method: "GET", Pattern: "/{owner}/{repo}/tree/{ref}/{path...}", Handler: s.tree}, + Route{Method: "GET", Pattern: "/{owner}/{repo}/blob/{ref}/{path...}", Handler: s.blob}, + Route{Method: "GET", Pattern: "/{owner}/{repo}/raw/{ref}/{path...}", Handler: s.raw}, + Route{Method: "GET", Pattern: "/{owner}/{repo}/log", Handler: s.log}, + Route{Method: "GET", Pattern: "/{owner}/{repo}/log/{ref}", Handler: s.log}, + Route{Method: "GET", Pattern: "/{owner}/{repo}/commit/{sha}", Handler: s.commit}, + Route{Method: "GET", Pattern: "/{owner}/{repo}/refs", Handler: s.refs}, + Route{Method: "GET", Pattern: "/{owner}/{repo}/archive/{file}", Handler: s.archive}, + ) + + // Account-mode routes (login, web edits) are appended here in M8 — + // and only when s.cfg.Web.Mode == "accounts". + return routes +} + +func (s *Server) Handler() http.Handler { + mux := http.NewServeMux() + for _, r := range s.Routes() { + mux.HandleFunc(r.Method+" "+r.Pattern, r.Handler) + } + return mux +} new file mode 100644 @@ -0,0 +1,51 @@ +package httpd + +import ( + "strings" + "testing" + + "github.com/krazywarez/forge/internal/config" + "github.com/krazywarez/forge/internal/policy" +) + +// TestViewOnlyHasNoMutatingRoutes is the structural guarantee from the plan: +// under web.mode = "view_only" the route table must contain no mutating +// route — not hidden ones, none at all. +func TestViewOnlyHasNoMutatingRoutes(t *testing.T) { + cfg := config.Default() + cfg.Web.Mode = "view_only" + s := New(cfg, nil) + + for _, r := range s.Routes() { + if r.Mutating { + t.Errorf("view_only route table contains mutating route %s %s", r.Method, r.Pattern) + } + // The only POSTs allowed are the git transport endpoints: a pure + // read (upload-pack) and a static refusal (receive-pack). + if r.Method != "GET" && !strings.Contains(r.Pattern, "git-upload-pack") && !strings.Contains(r.Pattern, "git-receive-pack") { + t.Errorf("view_only route table contains non-GET route %s %s", r.Method, r.Pattern) + } + for _, word := range []string{"login", "logout", "register", "edit", "new", "settings"} { + if strings.Contains(r.Pattern, "/"+word) { + t.Errorf("view_only route table contains account-mode pattern %s %s", r.Method, r.Pattern) + } + } + } +} + +// TestTopLevelRouteWordsAreReserved keeps the route table and the reserved +// username list in agreement: every literal first path segment must be an +// unclaimable username. +func TestTopLevelRouteWordsAreReserved(t *testing.T) { + s := New(config.Default(), nil) + for _, r := range s.Routes() { + seg := strings.TrimPrefix(r.Pattern, "/") + seg, _, _ = strings.Cut(seg, "/") + if seg == "" || strings.HasPrefix(seg, "{") { + continue // wildcard or root + } + if !policy.Reserved(seg) { + t.Errorf("top-level route word %q is not in the reserved username list", seg) + } + } +} @@ -28,15 +28,10 @@ func New(cfg config.Config, st *store.Store) *Server { return &Server{cfg: cfg, st: st} } -func (s *Server) Handler() http.Handler { - mux := http.NewServeMux() - mux.HandleFunc("GET /{owner}/{repo}/info/refs", s.infoRefs) - mux.HandleFunc("POST /{owner}/{repo}/git-upload-pack", s.uploadPack) - // Push endpoints exist only to fail legibly. - mux.HandleFunc("POST /{owner}/{repo}/git-receive-pack", func(w http.ResponseWriter, r *http.Request) { - http.Error(w, s.pushRefusalMessage(r.PathValue("owner"), r.PathValue("repo")), http.StatusForbidden) - }) - return mux +// receivePackRefusal exists only to fail legibly if a client POSTs without +// reading the advertisement first. +func (s *Server) receivePackRefusal(w http.ResponseWriter, r *http.Request) { + http.Error(w, s.pushRefusalMessage(r.PathValue("owner"), r.PathValue("repo")), http.StatusForbidden) } // publicRepo resolves owner/name and returns it only if it exists and is new file mode 100644 @@ -0,0 +1,399 @@ +package httpd + +import ( + "bytes" + "fmt" + "html/template" + "net/http" + "path" + "strings" + "time" + + "github.com/alecthomas/chroma/v2/formatters/html" + "github.com/alecthomas/chroma/v2/lexers" + "github.com/alecthomas/chroma/v2/styles" + "github.com/yuin/goldmark" + + "github.com/krazywarez/forge/internal/control" + "github.com/krazywarez/forge/internal/gitutil" + "github.com/krazywarez/forge/internal/sig" + "github.com/krazywarez/forge/internal/store" + "github.com/krazywarez/forge/internal/web" +) + +const maxRenderBytes = 1 << 20 // largest blob rendered inline + +func (s *Server) render(w http.ResponseWriter, page string, data any) { + var buf bytes.Buffer + if err := web.Render(&buf, page, data); err != nil { + http.Error(w, "template error: "+err.Error(), http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "text/html; charset=utf-8") + buf.WriteTo(w) +} + +func (s *Server) siteName() string { + h := strings.TrimPrefix(strings.TrimPrefix(s.cfg.Server.SiteURL, "https://"), "http://") + return strings.TrimSuffix(h, "/") +} + +func (s *Server) stylesheet(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/css; charset=utf-8") + w.Write(web.StyleCSS) +} + +func (s *Server) index(w http.ResponseWriter, r *http.Request) { + repos, err := s.st.ListPublicRepos() + if err != nil { + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + s.render(w, "index.html", struct { + Site string + Repos []store.Repo + }{s.siteName(), repos}) +} + +// repoPage is the shared context for repo-scoped pages. +type repoPage struct { + Site string + Repo store.Repo + Ref string + CloneURL string + Dir string +} + +// repoFor resolves the repo for a web request; false means 404 was sent. +// The anonymous web sees public repos only — private and missing repos are +// indistinguishable. +func (s *Server) repoFor(w http.ResponseWriter, r *http.Request, ref string) (repoPage, bool) { + repo, ok := s.publicRepo(r.PathValue("owner"), r.PathValue("repo")) + if !ok { + http.NotFound(w, r) + return repoPage{}, false + } + if ref == "" { + ref = repo.DefaultBranch + } + return repoPage{ + Site: s.siteName(), + Repo: repo, + Ref: ref, + CloneURL: s.cfg.Server.SiteURL + "/" + repo.Path() + ".git", + Dir: control.RepoDir(s.cfg.Server.Root, repo.OwnerName, repo.Name), + }, true +} + +type crumb struct { + Name string + URL string +} + +func crumbs(p repoPage, kind, filePath string) []crumb { + var cs []crumb + base := "/" + p.Repo.Path() + "/" + kind + "/" + p.Ref + "/" + acc := "" + for _, part := range strings.Split(filePath, "/") { + if part == "" { + continue + } + acc = path.Join(acc, part) + cs = append(cs, crumb{Name: part, URL: base + acc}) + } + return cs +} + +func (s *Server) repoHome(w http.ResponseWriter, r *http.Request) { + p, ok := s.repoFor(w, r, "") + if !ok { + return + } + s.renderTree(w, r, p, "") +} + +func (s *Server) tree(w http.ResponseWriter, r *http.Request) { + p, ok := s.repoFor(w, r, r.PathValue("ref")) + if !ok { + return + } + s.renderTree(w, r, p, strings.Trim(r.PathValue("path"), "/")) +} + +func (s *Server) renderTree(w http.ResponseWriter, r *http.Request, p repoPage, dirPath string) { + if _, err := gitutil.ResolveRef(p.Dir, p.Ref); err != nil { + // Empty repo: render the page with no entries rather than 404. + s.render(w, "tree.html", struct { + repoPage + Crumbs []crumb + Prefix string + Entries []gitutil.TreeEntry + ReadmeHTML template.HTML + }{repoPage: p}) + return + } + entries, err := gitutil.ListTree(p.Dir, p.Ref, dirPath) + if err != nil { + http.NotFound(w, r) + return + } + prefix := "" + if dirPath != "" { + prefix = dirPath + "/" + } + + var readmeHTML template.HTML + for _, e := range entries { + if e.Type != "blob" { + continue + } + lower := strings.ToLower(e.Name) + if lower == "readme" || lower == "readme.md" || lower == "readme.markdown" { + raw, err := gitutil.ReadBlob(p.Dir, p.Ref, prefix+e.Name, maxRenderBytes) + if err == nil { + var buf bytes.Buffer + if strings.HasSuffix(lower, ".md") || strings.HasSuffix(lower, ".markdown") { + // goldmark's default renderer drops raw HTML: safe. + if goldmark.Convert(raw, &buf) == nil { + readmeHTML = template.HTML(buf.String()) + } + } else { + readmeHTML = template.HTML("<pre>" + template.HTMLEscapeString(string(raw)) + "</pre>") + } + } + break + } + } + + s.render(w, "tree.html", struct { + repoPage + Crumbs []crumb + Prefix string + Entries []gitutil.TreeEntry + ReadmeHTML template.HTML + }{p, crumbs(p, "tree", dirPath), prefix, entries, readmeHTML}) +} + +func (s *Server) blob(w http.ResponseWriter, r *http.Request) { + p, ok := s.repoFor(w, r, r.PathValue("ref")) + if !ok { + return + } + filePath := strings.Trim(r.PathValue("path"), "/") + data, err := gitutil.ReadBlob(p.Dir, p.Ref, filePath, maxRenderBytes+1) + if err != nil { + http.NotFound(w, r) + return + } + binary := gitutil.IsBinary(data) || len(data) > maxRenderBytes + + var codeHTML template.HTML + if !binary { + codeHTML = highlight(filePath, data) + } + cs := crumbs(p, "blob", filePath) + base := "" + if len(cs) > 0 { + base = cs[len(cs)-1].Name + cs = cs[:len(cs)-1] + } + s.render(w, "blob.html", struct { + repoPage + Crumbs []crumb + Base string + Path string + Binary bool + Size int + CodeHTML template.HTML + }{p, cs, base, filePath, binary, len(data), codeHTML}) +} + +func highlight(filePath string, data []byte) template.HTML { + lexer := lexers.Match(filePath) + if lexer == nil { + lexer = lexers.Fallback + } + style := styles.Get("friendly") + formatter := html.New(html.WithLineNumbers(true), html.LineNumbersInTable(false)) + iterator, err := lexer.Tokenise(nil, string(data)) + if err != nil { + return template.HTML("<pre>" + template.HTMLEscapeString(string(data)) + "</pre>") + } + var buf bytes.Buffer + if err := formatter.Format(&buf, style, iterator); err != nil { + return template.HTML("<pre>" + template.HTMLEscapeString(string(data)) + "</pre>") + } + return template.HTML(buf.String()) +} + +func (s *Server) raw(w http.ResponseWriter, r *http.Request) { + p, ok := s.repoFor(w, r, r.PathValue("ref")) + if !ok { + return + } + filePath := strings.Trim(r.PathValue("path"), "/") + data, err := gitutil.ReadBlob(p.Dir, p.Ref, filePath, s.cfg.Limits.MaxBlobBytes) + if err != nil { + http.NotFound(w, r) + return + } + // Serve inert: never let repo content execute in the forge's origin. + w.Header().Set("Content-Type", "text/plain; charset=utf-8") + w.Header().Set("X-Content-Type-Options", "nosniff") + w.Write(data) +} + +type sigView struct { + State string + Signer string + Fingerprint string +} + +func (s *Server) sigFor(repo store.Repo, dir, sha string) (sigView, *sig.Commit) { + raw, err := gitutil.ReadCommit(dir, sha) + if err != nil { + return sigView{State: "unsigned"}, nil + } + parsed, err := sig.ParseCommit(raw) + if err != nil { + return sigView{State: "unsigned"}, nil + } + res, err := control.VerifyCommitCached(s.st, repo, parsed, sha) + if err != nil { + return sigView{State: "unsigned"}, parsed + } + v := sigView{State: string(res.State), Fingerprint: res.KeyFingerprint} + if res.SignerUserID != 0 { + if u, err := s.st.UserByID(res.SignerUserID); err == nil { + v.Signer = u.Username + } + } + return v, parsed +} + +func (s *Server) log(w http.ResponseWriter, r *http.Request) { + ref := r.PathValue("ref") + p, ok := s.repoFor(w, r, ref) + if !ok { + return + } + const pageSize = 50 + shas, err := gitutil.RevList(p.Dir, p.Ref, pageSize+1) + if err != nil { + http.NotFound(w, r) + return + } + next := "" + if len(shas) > pageSize { + next = shas[pageSize] + shas = shas[:pageSize] + } + type row struct { + SHA, ShortSHA, Subject, AuthorName, AuthorEmail, Date string + Sig sigView + } + var rows []row + for _, sha := range shas { + v, parsed := s.sigFor(p.Repo, p.Dir, sha) + rw := row{SHA: sha, ShortSHA: sha[:10], Sig: v} + if parsed != nil { + rw.Subject = parsed.Subject + rw.AuthorName = parsed.AuthorName + rw.AuthorEmail = parsed.AuthorEmail + rw.Date = time.Unix(parsed.AuthorUnix, 0).UTC().Format("2006-01-02") + } + rows = append(rows, rw) + } + s.render(w, "log.html", struct { + repoPage + Commits []row + NextSHA string + }{p, rows, next}) +} + +func (s *Server) commit(w http.ResponseWriter, r *http.Request) { + p, ok := s.repoFor(w, r, "") + if !ok { + return + } + sha := r.PathValue("sha") + full, err := gitutil.ResolveRef(p.Dir, sha) + if err != nil { + http.NotFound(w, r) + return + } + v, parsed := s.sigFor(p.Repo, p.Dir, full) + if parsed == nil { + http.NotFound(w, r) + return + } + patch, _ := gitutil.ShowPatch(p.Dir, full, 4<<20) + type diffLine struct { + Class string + Text string + } + var lines []diffLine + for _, l := range strings.Split(patch, "\n") { + class := "" + switch { + case strings.HasPrefix(l, "+++"), strings.HasPrefix(l, "---"), strings.HasPrefix(l, "diff "), strings.HasPrefix(l, "index "): + class = "meta" + case strings.HasPrefix(l, "@@"): + class = "hunk" + case strings.HasPrefix(l, "+"): + class = "add" + case strings.HasPrefix(l, "-"): + class = "del" + } + lines = append(lines, diffLine{class, l}) + } + committerEmail := "" + if parsed.CommitterEmail != parsed.AuthorEmail { + committerEmail = parsed.CommitterEmail + } + msg := "" + if i := bytes.Index(parsed.Payload, []byte("\n\n")); i >= 0 { + msg = string(parsed.Payload[i+2:]) + } + s.render(w, "commit.html", struct { + repoPage + SHA, ShortSHA, AuthorName, AuthorEmail, CommitterEmail, Date, Message string + Sig sigView + DiffLines []diffLine + }{p, full, full[:10], parsed.AuthorName, parsed.AuthorEmail, committerEmail, + time.Unix(parsed.AuthorUnix, 0).UTC().Format(time.RFC3339), msg, v, lines}) +} + +func (s *Server) refs(w http.ResponseWriter, r *http.Request) { + p, ok := s.repoFor(w, r, "") + if !ok { + return + } + branches, _ := gitutil.Refs(p.Dir, "heads") + tags, _ := gitutil.Refs(p.Dir, "tags") + s.render(w, "refs.html", struct { + repoPage + Branches, Tags []gitutil.Ref + }{p, branches, tags}) +} + +func (s *Server) archive(w http.ResponseWriter, r *http.Request) { + p, ok := s.repoFor(w, r, "") + if !ok { + return + } + file := r.PathValue("file") + ref, ok := strings.CutSuffix(file, ".tar.gz") + if !ok { + http.NotFound(w, r) + return + } + if _, err := gitutil.ResolveRef(p.Dir, ref); err != nil { + http.NotFound(w, r) + return + } + prefix := fmt.Sprintf("%s-%s", p.Repo.Name, ref) + w.Header().Set("Content-Type", "application/gzip") + w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", prefix+".tar.gz")) + gitutil.Archive(p.Dir, ref, prefix, w) +} @@ -191,3 +191,25 @@ func (s *Store) RepoByID(id int64) (Repo, error) { } return r, nil } + +// ListPublicRepos returns all public repositories, for the anonymous index. +func (s *Store) ListPublicRepos() ([]Repo, error) { + rows, err := s.DB.Query(` + SELECT r.id, r.owner_kind, r.owner_id, u.username, r.name, r.visibility, r.default_branch, r.settings_json + FROM repos r JOIN users u ON r.owner_kind = 'user' AND u.id = r.owner_id + WHERE r.visibility = 'public' ORDER BY u.username, r.name`) + if err != nil { + return nil, err + } + defer rows.Close() + var out []Repo + for rows.Next() { + var r Repo + var settingsJSON string + if err := rows.Scan(&r.ID, &r.OwnerKind, &r.OwnerID, &r.OwnerName, &r.Name, &r.Visibility, &r.DefaultBranch, &settingsJSON); err != nil { + return nil, err + } + out = append(out, r) + } + return out, rows.Err() +} new file mode 100644 @@ -0,0 +1,53 @@ +:root { + --bg: #ffffff; --fg: #1a1a1a; --muted: #666; --line: #ddd; + --link: #0550ae; --code-bg: #f6f8fa; + --ok: #1a7f37; --warn: #9a6700; --bad: #cf222e; --neutral: #666; +} +@media (prefers-color-scheme: dark) { + :root { + --bg: #0d1117; --fg: #e6edf3; --muted: #8b949e; --line: #30363d; + --link: #58a6ff; --code-bg: #161b22; + --ok: #3fb950; --warn: #d29922; --bad: #f85149; --neutral: #8b949e; + } +} +* { box-sizing: border-box; } +body { + margin: 0; background: var(--bg); color: var(--fg); + font: 15px/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; +} +header { border-bottom: 1px solid var(--line); padding: 0.6rem 1rem; } +a.site { font-weight: 700; } +main { max-width: 60rem; margin: 0 auto; padding: 1rem; } +a { color: var(--link); text-decoration: none; } +a:hover { text-decoration: underline; } +h1 { font-size: 1.3rem; } +h2 { font-size: 1.1rem; } +code, pre, .code, td.mode, td.size { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 13px; } +table { border-collapse: collapse; width: 100%; } +td { padding: 0.25rem 0.6rem 0.25rem 0; border-bottom: 1px solid var(--line); vertical-align: top; } +td.size, td.mode { color: var(--muted); white-space: nowrap; } +nav.tabs a { margin-right: 1rem; } +p.clone code { background: var(--code-bg); padding: 0.15rem 0.4rem; border-radius: 4px; } +.crumbs { color: var(--muted); } +.readme, .code { border: 1px solid var(--line); border-radius: 6px; padding: 1rem; margin-top: 1rem; overflow-x: auto; } +/* chroma emits inline styles for a light background; pin the block to light + colors in both schemes so unstyled tokens stay legible. */ +.code { background: #f8f8f8; color: #1a1a1a; } +.code pre { margin: 0; background: transparent !important; } +pre.message { background: var(--code-bg); padding: 0.8rem; border-radius: 6px; } +pre.diff { background: var(--code-bg); padding: 0.8rem; border-radius: 6px; overflow-x: auto; } +pre.diff .add { color: var(--ok); } +pre.diff .del { color: var(--bad); } +pre.diff .hunk { color: var(--link); } +pre.diff .meta { color: var(--muted); } +.badge { + display: inline-block; padding: 0.05rem 0.5rem; border-radius: 10px; + font-size: 12px; border: 1px solid; +} +.badge-verified { color: var(--ok); border-color: var(--ok); } +.badge-unsigned { color: var(--neutral); border-color: var(--line); } +.badge-signed_unknown_key { color: var(--warn); border-color: var(--warn); } +.badge-signed_email_mismatch { color: var(--bad); border-color: var(--bad); } +.badge-signed_key_expired { color: var(--warn); border-color: var(--warn); } +.badge-signed_key_revoked { color: var(--bad); border-color: var(--bad); } +.badge-bad_signature { color: var(--bad); border-color: var(--bad); } new file mode 100644 @@ -0,0 +1,8 @@ +{{define "title"}}{{.Path}} · {{.Repo.OwnerName}}/{{.Repo.Name}}{{end}} +{{define "content"}} +{{template "repoheader" .}} +<p class="crumbs">{{.Ref}}: {{range .Crumbs}}<a href="{{.URL}}">{{.Name}}</a>/{{end}}{{.Base}} + · <a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/raw/{{.Ref}}/{{.Path}}">raw</a></p> +{{if .Binary}}<p>binary file, {{.Size}} bytes — <a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/raw/{{.Ref}}/{{.Path}}">download</a></p> +{{else}}<div class="code">{{.CodeHTML}}</div>{{end}} +{{end}} new file mode 100644 @@ -0,0 +1,11 @@ +{{define "title"}}{{.ShortSHA}} · {{.Repo.OwnerName}}/{{.Repo.Name}}{{end}} +{{define "content"}} +{{template "repoheader" .}} +<h2><code>{{.SHA}}</code></h2> +<p>{{template "sigbadge" .Sig}}</p> +<p>author: {{.AuthorName}} <{{.AuthorEmail}}> · {{.Date}} +{{if .CommitterEmail}}<br>committer: <{{.CommitterEmail}}>{{end}}</p> +<pre class="message">{{.Message}}</pre> +<pre class="diff">{{range .DiffLines}}<span class="{{.Class}}">{{.Text}}</span> +{{end}}</pre> +{{end}} new file mode 100644 @@ -0,0 +1,8 @@ +{{define "title"}}{{.Site}}{{end}} +{{define "content"}} +<h1>repositories</h1> +<table> +{{range .Repos}}<tr><td><a href="/{{.OwnerName}}/{{.Name}}">{{.OwnerName}}/{{.Name}}</a></td><td>{{.DefaultBranch}}</td></tr> +{{else}}<tr><td>no public repositories</td></tr>{{end}} +</table> +{{end}} new file mode 100644 @@ -0,0 +1,30 @@ +{{define "layout"}}<!DOCTYPE html> +<html lang="en"> +<head> +<meta charset="utf-8"> +<meta name="viewport" content="width=device-width, initial-scale=1"> +<title>{{template "title" .}}</title> +<link rel="stylesheet" href="/static/style.css"> +</head> +<body> +<header> + <nav><a class="site" href="/">{{.Site}}</a></nav> +</header> +<main> +{{template "content" .}} +</main> +</body> +</html>{{end}} + +{{define "repoheader"}} +<h1><a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}">{{.Repo.OwnerName}}/{{.Repo.Name}}</a></h1> +<nav class="tabs"> + <a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}">files</a> + <a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/log">log</a> + <a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/refs">refs</a> + <a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/archive/{{.Ref}}.tar.gz">archive</a> +</nav> +<p class="clone">clone: <code>git clone {{.CloneURL}}</code></p> +{{end}} + +{{define "sigbadge"}}<span class="badge badge-{{.State}}" title="{{.Fingerprint}}">{{.State}}{{if .Signer}} · {{.Signer}}{{end}}</span>{{end}} new file mode 100644 @@ -0,0 +1,15 @@ +{{define "title"}}log · {{.Repo.OwnerName}}/{{.Repo.Name}}{{end}} +{{define "content"}} +{{template "repoheader" .}} +<table class="log"> +{{range .Commits}}<tr> + <td><code><a href="/{{$.Repo.OwnerName}}/{{$.Repo.Name}}/commit/{{.SHA}}">{{.ShortSHA}}</a></code></td> + <td>{{.Subject}}</td> + <td>{{.AuthorName}} <{{.AuthorEmail}}></td> + <td>{{.Date}}</td> + <td>{{template "sigbadge" .Sig}}</td> +</tr> +{{end}} +</table> +{{if .NextSHA}}<p><a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/log/{{.NextSHA}}">older →</a></p>{{end}} +{{end}} new file mode 100644 @@ -0,0 +1,8 @@ +{{define "title"}}refs · {{.Repo.OwnerName}}/{{.Repo.Name}}{{end}} +{{define "content"}} +{{template "repoheader" .}} +<h2>branches</h2> +<table>{{range .Branches}}<tr><td><a href="/{{$.Repo.OwnerName}}/{{$.Repo.Name}}/tree/{{.Name}}/">{{.Name}}</a></td><td><code>{{.SHA}}</code></td></tr>{{end}}</table> +<h2>tags</h2> +<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> +{{end}} new file mode 100644 @@ -0,0 +1,14 @@ +{{define "title"}}{{.Repo.OwnerName}}/{{.Repo.Name}}{{end}} +{{define "content"}} +{{template "repoheader" .}} +<p class="crumbs">{{.Ref}}: {{range .Crumbs}}<a href="{{.URL}}">{{.Name}}</a>/{{end}}</p> +<table class="tree"> +{{range .Entries}}<tr> + <td class="mode">{{.Mode}}</td> + {{if eq .Type "tree"}}<td><a href="/{{$.Repo.OwnerName}}/{{$.Repo.Name}}/tree/{{$.Ref}}/{{$.Prefix}}{{.Name}}">{{.Name}}/</a></td><td></td> + {{else}}<td><a href="/{{$.Repo.OwnerName}}/{{$.Repo.Name}}/blob/{{$.Ref}}/{{$.Prefix}}{{.Name}}">{{.Name}}</a></td><td class="size">{{.Size}}</td>{{end}} +</tr> +{{else}}<tr><td>empty</td></tr>{{end}} +</table> +{{if .ReadmeHTML}}<section class="readme">{{.ReadmeHTML}}</section>{{end}} +{{end}} new file mode 100644 @@ -0,0 +1,26 @@ +// Package web holds the server-rendered templates and static assets for the +// read-only UI. No JavaScript, no build step. +package web + +import ( + "embed" + "html/template" + "io" +) + +//go:embed templates/*.html +var templateFS embed.FS + +//go:embed static/style.css +var StyleCSS []byte + +// Render executes the named page template with the shared layout. +func Render(w io.Writer, page string, data any) error { + t, err := template.Must( + template.ParseFS(templateFS, "templates/layout.html"), + ).ParseFS(templateFS, "templates/"+page) + if err != nil { + return err + } + return t.ExecuteTemplate(w, "layout", data) +}