A CLI-first git forge.

cli forge git self-hosted

https://gitbay.org

Commit 617b05d09b

617b05d09be747ad310179ce5432dec98ebe2c17

parent: 9e1f1e9283

Verified · cmc ci/build: success

cmc <hello@cleberg.net> · 2026-08-25T16:57:32Z

Pages: serve public repos' pages branches as static sites

[pages] domain = "example.site" serves <owner>.<domain> from the owner's
repos: the repo named "pages" at the root, every other public repo with
a pages branch under /<repo>/. Correct content types, directory index
redirects, 60s cache. Pages responses bypass the forge CSP — sites need
their own scripts — and run on a separate origin with no forge cookies.
Private repos, missing branches, unknown owners, and the apex 404.

ACME issues per-subdomain certs on demand (host policy: owner must
exist); the port-80 redirect sends pages hosts to themselves.
check-config refuses a pages domain that is, or is a parent of, the
site host.

Closes #15
cmd/gitbayd/main.go +29 −2
@@ -170,10 +170,31 @@ func serveCmd() *cobra.Command {
170170 errCh <- hs.ListenAndServeTLS(cfg.HTTP.CertFile, cfg.HTTP.KeyFile)
171171 case "acme":
172172 host := cfg.SiteHost()
173 stripPort := func(hp string) string {
174 if h, _, err := net.SplitHostPort(hp); err == nil {
175 return h
176 }
177 return hp
178 }
179 // Beyond the site host, allow <owner>.<pages domain>
180 // for owners that exist — certs come on demand per
181 // subdomain, no wildcard needed.
182 hostPolicy := func(ctx context.Context, h string) error {
183 if h == host {
184 return nil
185 }
186 if pd := cfg.Pages.Domain; pd != "" {
187 if owner, ok := strings.CutSuffix(h, "."+pd); ok &&
188 !strings.Contains(owner, ".") && st.OwnerExists(owner) {
189 return nil
190 }
191 }
192 return fmt.Errorf("host %q not served here", h)
193 }
173194 m := &autocert.Manager{
174195 Prompt: autocert.AcceptTOS,
175196 Cache: autocert.DirCache(filepath.Join(cfg.Server.Root, "acme")),
176 HostPolicy: autocert.HostWhitelist(host),
197 HostPolicy: hostPolicy,
177198 Email: cfg.HTTP.ACMEEmail,
178199 }
179200 // TLS-ALPN-01 rides the HTTPS port itself. The optional
@@ -181,7 +202,13 @@ func serveCmd() *cobra.Command {
181202 // it (port 80 taken, no privileges) is not fatal.
182203 if addr := cfg.HTTP.ACMEHTTPAddr; addr != "" && addr != "off" {
183204 redirect := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
184 http.Redirect(w, r, "https://"+host+r.URL.RequestURI(), http.StatusMovedPermanently)
205 // Pages hosts redirect to themselves, not the
206 // forge host.
207 target := host
208 if hostPolicy(r.Context(), stripPort(r.Host)) == nil {
209 target = stripPort(r.Host)
210 }
211 http.Redirect(w, r, "https://"+target+r.URL.RequestURI(), http.StatusMovedPermanently)
185212 })
186213 go func() {
187214 slog.Info("acme http listening", "addr", addr)
e2e/pages_test.go added +121
@@ -0,0 +1,121 @@
1package e2e
2
3import (
4 "fmt"
5 "io"
6 "net/http"
7 "os"
8 "path/filepath"
9 "strings"
10 "testing"
11)
12
13// pagesGet fetches a path with a pages Host header against the instance.
14func (i *instance) pagesGet(t *testing.T, host, path string) (*http.Response, string) {
15 t.Helper()
16 req, err := http.NewRequest("GET", fmt.Sprintf("http://127.0.0.1:%d%s", i.httpPort, path), nil)
17 if err != nil {
18 t.Fatal(err)
19 }
20 req.Host = host
21 resp, err := (&http.Client{CheckRedirect: func(*http.Request, []*http.Request) error {
22 return http.ErrUseLastResponse
23 }}).Do(req)
24 if err != nil {
25 t.Fatal(err)
26 }
27 defer resp.Body.Close()
28 body, _ := io.ReadAll(resp.Body)
29 return resp, string(body)
30}
31
32func TestPages(t *testing.T) {
33 inst := startInstanceWith(t, "[pages]\ndomain = \"p.test\"\n")
34 aliceKey := inst.newKey(t, "alice")
35 inst.admin(t, "admin", "user", "create", "alice", "--key", aliceKey+".pub")
36
37 env := inst.gitEnv(aliceKey)
38 pushPages := func(repo string, files map[string]string) {
39 t.Helper()
40 if _, errOut, code := inst.ssh(t, aliceKey, "", "repo", "create", repo); code != 0 {
41 t.Fatalf("create %s: %s", repo, errOut)
42 }
43 work := t.TempDir()
44 mustGit(t, work, env, "clone", inst.sshURL(repo), "w")
45 dir := filepath.Join(work, "w")
46 for name, content := range files {
47 os.MkdirAll(filepath.Dir(filepath.Join(dir, name)), 0o755)
48 os.WriteFile(filepath.Join(dir, name), []byte(content), 0o644)
49 }
50 mustGit(t, dir, env, "checkout", "-q", "-b", "pages")
51 mustGit(t, dir, env, "add", ".")
52 mustGit(t, dir, env, "commit", "-q", "-m", "site")
53 mustGit(t, dir, env, "push", "-q", "origin", "pages")
54 }
55
56 pushPages("alice/pages", map[string]string{
57 "index.html": "<h1>alice root</h1><script>x=1</script>",
58 })
59 pushPages("alice/site", map[string]string{
60 "index.html": "<h1>project site</h1>",
61 "style.css": "body{color:red}",
62 "guide/index.html": "<h1>guide</h1>",
63 })
64
65 // Root site from the "pages" repo, scripts intact, no forge CSP.
66 resp, body := inst.pagesGet(t, "alice.p.test", "/")
67 if resp.StatusCode != 200 || !strings.Contains(body, "alice root") || !strings.Contains(body, "<script>") {
68 t.Fatalf("root site: %d\n%s", resp.StatusCode, body)
69 }
70 if ct := resp.Header.Get("Content-Type"); !strings.HasPrefix(ct, "text/html") {
71 t.Fatalf("root content-type: %s", ct)
72 }
73 if resp.Header.Get("Content-Security-Policy") != "" {
74 t.Fatal("forge CSP leaked onto a pages response")
75 }
76
77 // Project site under /<repo>/, with a redirect adding the slash.
78 if resp, _ = inst.pagesGet(t, "alice.p.test", "/site"); resp.StatusCode != 301 {
79 t.Fatalf("bare project path: %d", resp.StatusCode)
80 }
81 if resp, body = inst.pagesGet(t, "alice.p.test", "/site/"); !strings.Contains(body, "project site") {
82 t.Fatalf("project index: %d\n%s", resp.StatusCode, body)
83 }
84 if resp, _ = inst.pagesGet(t, "alice.p.test", "/site/style.css"); !strings.HasPrefix(resp.Header.Get("Content-Type"), "text/css") {
85 t.Fatalf("css content-type: %s", resp.Header.Get("Content-Type"))
86 }
87 // Directory paths inside a site serve their index and gain a slash.
88 if resp, _ = inst.pagesGet(t, "alice.p.test", "/site/guide"); resp.StatusCode != 301 {
89 t.Fatalf("dir redirect: %d", resp.StatusCode)
90 }
91 if _, body = inst.pagesGet(t, "alice.p.test", "/site/guide/"); !strings.Contains(body, "guide") {
92 t.Fatalf("dir index:\n%s", body)
93 }
94
95 // Private repos never serve pages; unknown owners and the apex 404.
96 if _, errOut, code := inst.ssh(t, aliceKey, "", "repo", "create", "alice/secret", "--private"); code != 0 {
97 t.Fatalf("create secret: %s", errOut)
98 }
99 work := t.TempDir()
100 mustGit(t, work, env, "clone", inst.sshURL("alice/secret"), "w")
101 sdir := filepath.Join(work, "w")
102 os.WriteFile(filepath.Join(sdir, "index.html"), []byte("hidden"), 0o644)
103 mustGit(t, sdir, env, "checkout", "-q", "-b", "pages")
104 mustGit(t, sdir, env, "add", ".")
105 mustGit(t, sdir, env, "commit", "-q", "-m", "s")
106 mustGit(t, sdir, env, "push", "-q", "origin", "pages")
107 for _, tc := range []struct{ host, path string }{
108 {"alice.p.test", "/secret/"},
109 {"bob.p.test", "/"},
110 {"p.test", "/"},
111 } {
112 if resp, _ = inst.pagesGet(t, tc.host, tc.path); resp.StatusCode != 404 {
113 t.Fatalf("%s%s: %d, want 404", tc.host, tc.path, resp.StatusCode)
114 }
115 }
116
117 // The forge itself still answers on its own host.
118 if status, _ := inst.get(t, "/explore"); status != 200 {
119 t.Fatalf("forge routes broken: %d", status)
120 }
121}
internal/config/config.go +16
@@ -21,6 +21,7 @@ type Config struct {
2121 Registration Registration `toml:"registration"`
2222 API API `toml:"api"`
2323 Webhooks Webhooks `toml:"webhooks"`
24 Pages Pages `toml:"pages"`
2425 Limits Limits `toml:"limits"`
2526 Mail Mail `toml:"mail"`
2627 Mirrors Mirrors `toml:"mirrors"`
@@ -71,6 +72,13 @@ type Registration struct {
7172 Mode string `toml:"mode"` // closed | invite | open
7273 }
7374
75// Pages serves each public repo's `pages` branch as a static site on
76// <owner>.<domain> — a separate origin, so page-authored scripts never run
77// on the forge's own host. Empty domain disables the feature.
78type Pages struct {
79 Domain string `toml:"domain"`
80}
81
7482 // API controls the HTTPS/JSON control-plane API (bearer tokens minted over
7583 // SSH). Off by default: an instance that never enables it has no
7684 // credential-bearing HTTP surface at all.
@@ -155,6 +163,14 @@ func (c Config) Validate() error {
155163 if c.Server.Root == "" {
156164 errs = append(errs, errors.New("server.root is required"))
157165 }
166 if d := c.Pages.Domain; d != "" {
167 if d == c.SiteHost() {
168 errs = append(errs, errors.New("pages.domain must differ from the site host: pages serve repo-authored scripts, which must not run on the forge's origin"))
169 }
170 if strings.HasSuffix(c.SiteHost(), "."+d) {
171 errs = append(errs, errors.New("pages.domain must not be a parent of the site host"))
172 }
173 }
158174 if c.Server.SiteURL == "" {
159175 errs = append(errs, errors.New("server.site_url is required"))
160176 }
internal/httpd/pages.go added +110
@@ -0,0 +1,110 @@
1package httpd
2
3import (
4 "mime"
5 "net"
6 "net/http"
7 "path"
8 "strings"
9
10 "gitbay.org/gitbay/internal/control"
11 "gitbay.org/gitbay/internal/gitutil"
12 "gitbay.org/gitbay/internal/store"
13)
14
15// PagesBranch is the branch a repo publishes as its static site.
16const PagesBranch = "refs/heads/pages"
17
18// pagesRouter sends <owner>.<domain> requests to the pages server and
19// everything else to the forge. Pages responses deliberately bypass the
20// forge's security headers: sites need their own scripts, and they run on
21// a separate origin where the forge has no cookies to protect.
22func (s *Server) pagesRouter(forge http.Handler) http.Handler {
23 domain := s.cfg.Pages.Domain
24 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
25 host := hostOnly(r.Host)
26 if host == domain || strings.HasSuffix(host, "."+domain) {
27 s.servePage(w, r, host)
28 return
29 }
30 forge.ServeHTTP(w, r)
31 })
32}
33
34func hostOnly(hostport string) string {
35 if h, _, err := net.SplitHostPort(hostport); err == nil {
36 return h
37 }
38 return hostport
39}
40
41// servePage maps <owner>.<domain>/<repo>/<path> to the repo's pages
42// branch, and <owner>.<domain>/<path> to the owner's repo named "pages".
43// Private repos and missing branches are plain 404s.
44func (s *Server) servePage(w http.ResponseWriter, r *http.Request, host string) {
45 if r.Method != http.MethodGet && r.Method != http.MethodHead {
46 http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
47 return
48 }
49 owner, ok := strings.CutSuffix(host, "."+s.cfg.Pages.Domain)
50 if !ok || owner == "" || strings.Contains(owner, ".") {
51 http.NotFound(w, r)
52 return
53 }
54 reqPath := strings.TrimPrefix(path.Clean("/"+r.URL.Path), "/")
55
56 // A first segment naming a public repo with a pages branch wins;
57 // everything else falls through to the owner's "pages" repo.
58 if seg, rest, _ := strings.Cut(reqPath, "/"); seg != "" && seg != "pages" {
59 if repo, err := s.st.RepoByPath(owner + "/" + seg); err == nil && repo.Visibility == "public" {
60 dir := control.RepoDir(s.cfg.Server.Root, repo.OwnerName, repo.Name)
61 if _, err := gitutil.ResolveRef(dir, PagesBranch); err == nil {
62 if rest == "" && !strings.HasSuffix(r.URL.Path, "/") {
63 http.Redirect(w, r, r.URL.Path+"/", http.StatusMovedPermanently)
64 return
65 }
66 s.servePageFile(w, r, repo, rest)
67 return
68 }
69 }
70 }
71 repo, err := s.st.RepoByPath(owner + "/pages")
72 if err != nil || repo.Visibility != "public" {
73 http.NotFound(w, r)
74 return
75 }
76 s.servePageFile(w, r, repo, reqPath)
77}
78
79func (s *Server) servePageFile(w http.ResponseWriter, r *http.Request, repo store.Repo, filePath string) {
80 dir := control.RepoDir(s.cfg.Server.Root, repo.OwnerName, repo.Name)
81 if filePath == "" {
82 filePath = "index.html"
83 }
84 data, err := gitutil.ReadBlob(dir, PagesBranch, filePath, s.cfg.Limits.MaxBlobBytes)
85 if err != nil {
86 // A directory path serves its index.html; /guide -> /guide/ keeps
87 // relative links working.
88 if idx, ierr := gitutil.ReadBlob(dir, PagesBranch, filePath+"/index.html", s.cfg.Limits.MaxBlobBytes); ierr == nil {
89 if !strings.HasSuffix(r.URL.Path, "/") {
90 http.Redirect(w, r, r.URL.Path+"/", http.StatusMovedPermanently)
91 return
92 }
93 data, filePath = idx, filePath+"/index.html"
94 } else {
95 http.NotFound(w, r)
96 return
97 }
98 }
99 ct := mime.TypeByExtension(path.Ext(filePath))
100 if ct == "" {
101 ct = http.DetectContentType(data)
102 }
103 w.Header().Set("Content-Type", ct)
104 w.Header().Set("X-Content-Type-Options", "nosniff")
105 w.Header().Set("Cache-Control", "public, max-age=60")
106 if r.Method == http.MethodHead {
107 return
108 }
109 w.Write(data)
110}
internal/httpd/routes.go +5 −1
@@ -122,7 +122,11 @@ func (s *Server) Handler() http.Handler {
122122 if len(s.cfg.GoImport) > 0 {
123123 h = s.goImportHandler(mux)
124124 }
125 return s.securityHeaders(h)
125 h = s.securityHeaders(h)
126 if s.cfg.Pages.Domain != "" {
127 h = s.pagesRouter(h)
128 }
129 return h
126130 }
127131
128132 // securityHeaders sets defensive response headers on every reply. The CSP
internal/store/users.go +9
@@ -46,6 +46,15 @@ func (s *Store) CreateUser(username string, isAdmin bool) (int64, error) {
4646 return res.LastInsertId()
4747 }
4848
49// OwnerExists reports whether a user or org owns the name — the ACME host
50// policy check for pages subdomains.
51func (s *Store) OwnerExists(name string) bool {
52 var n int
53 s.DB.QueryRow(`SELECT (SELECT COUNT(*) FROM users WHERE username = ?1)
54 + (SELECT COUNT(*) FROM orgs WHERE name = ?1)`, name).Scan(&n)
55 return n > 0
56}
57
4958 func (s *Store) UserByUsername(name string) (User, error) {
5059 var u User
5160 var admin, pending, disabled int