A CLI-first git forge.

cli forge git self-hosted

https://gitbay.org

Commit 33f4fa3490

33f4fa3490944ada57c2a5c57e6650020c1afeab

parent: 832f93b0bb

Verified · cmc

cmc <hello@cleberg.net> · 2026-08-24T22:21:38Z

web: top nav, linked footer, privacy page, blue accent

Part of the #10 review round. The header becomes a real nav bar:
wordmark, explore, and for logged-in users new repository, their
profile, and logout — every critical place one click away on every
page. The dashboard toolbar sheds its duplicate links and stray
middot; 'logged in as' links the profile. The footer commit hash links
to the upstream commit and a new /privacy page (reserved word) explains
the software's data handling plus operator notes from
web.privacy_notice. Accent moves to #0000f0 on light (8.4:1) and
#8899ff on dark (7.2:1 on the page background), favicon matched.
e2e/accounts_test.go +1 −1
@@ -87,7 +87,7 @@ func TestWebAccounts(t *testing.T) {
8787
8888 browser := newBrowser(t)
8989 status, body := browserGet(t, browser, inst.base()+loginPath)
90 if status != 200 || !strings.Contains(body, "logged in as alice") {
90 if status != 200 || !strings.Contains(body, `logged in as <a href="/alice">alice</a>`) {
9191 t.Fatalf("login redirect landed wrong: %d\n%s", status, body)
9292 }
9393
e2e/dashboard_test.go +1 −1
@@ -89,7 +89,7 @@ func TestDashboard(t *testing.T) {
8989 t.Fatalf("login: %d", status)
9090 }
9191 status, body = browserGet(t, browser, inst.base()+"/")
92 if status != 200 || !strings.Contains(body, "logged in as alice") {
92 if status != 200 || !strings.Contains(body, `logged in as <a href="/alice">alice</a>`) {
9393 t.Fatalf("dashboard: %d", status)
9494 }
9595 for _, want := range []string{
internal/config/config.go +3
@@ -62,6 +62,9 @@ type GitDaemon struct {
6262 type Web struct {
6363 Mode string `toml:"mode"` // view_only | accounts
6464 PasswordAuth bool `toml:"password_auth"`
65 // PrivacyNotice is operator-provided text shown on /privacy under the
66 // fixed project-level statement. Plain text; blank paragraphs split.
67 PrivacyNotice string `toml:"privacy_notice"`
6568 }
6669
6770 type Registration struct {
internal/httpd/accounts.go +8 −6
@@ -63,17 +63,19 @@ func (s *Server) login(w http.ResponseWriter, r *http.Request) {
6363 token := r.URL.Query().Get("token")
6464 if token == "" {
6565 s.render(w, "login.html", struct {
66 Site string
67 Error string
68 }{s.siteName(), ""})
66 Site string
67 Viewer string
68 Error string
69 }{s.siteName(), "", ""})
6970 return
7071 }
7172 userID, err := s.st.ConsumeLoginToken(store.HashToken(token))
7273 if err != nil {
7374 s.render(w, "login.html", struct {
74 Site string
75 Error string
76 }{s.siteName(), "that login link is invalid, expired, or already used — mint a new one"})
75 Site string
76 Viewer string
77 Error string
78 }{s.siteName(), "", "that login link is invalid, expired, or already used — mint a new one"})
7779 return
7880 }
7981 sessTok, sessHash, err := store.NewToken()
internal/httpd/routes.go +1
@@ -33,6 +33,7 @@ func (s *Server) Routes() []Route {
3333 routes = append(routes,
3434 Route{Method: "GET", Pattern: "/{$}", Handler: s.index},
3535 Route{Method: "GET", Pattern: "/explore", Handler: s.explore},
36 Route{Method: "GET", Pattern: "/privacy", Handler: s.privacy},
3637 Route{Method: "GET", Pattern: "/static/style.css", Handler: s.stylesheet},
3738 Route{Method: "GET", Pattern: "/favicon.svg", Handler: s.favicon},
3839 Route{Method: "GET", Pattern: "/{owner}", Handler: s.ownerPage},
internal/httpd/web.go +25 −2
@@ -63,7 +63,10 @@ func (s *Server) favicon(w http.ResponseWriter, r *http.Request) {
6363 // the stock plain-text response if the template fails.
6464 func (s *Server) notFound(w http.ResponseWriter, r *http.Request) {
6565 var buf bytes.Buffer
66 if err := web.Render(&buf, "404.html", struct{ Site string }{s.siteName()}); err != nil {
66 if err := web.Render(&buf, "404.html", struct {
67 Site string
68 Viewer string
69 }{s.siteName(), s.viewerName(r)}); err != nil {
6770 http.NotFound(w, r)
6871 return
6972 }
@@ -99,10 +102,11 @@ func (s *Server) index(w http.ResponseWriter, r *http.Request) {
99102 s.cfg.Server.SiteURL, "https://"), "http://"), "/")
100103 s.render(w, "landing.html", struct {
101104 Site string
105 Viewer string
102106 Host string
103107 Accounts bool
104108 Signup bool
105 }{s.siteName(), host, s.cfg.Web.Mode == "accounts",
109 }{s.siteName(), "", host, s.cfg.Web.Mode == "accounts",
106110 s.cfg.Web.Mode == "accounts" && s.cfg.Registration.Mode != "closed"})
107111 }
108112
@@ -145,6 +149,25 @@ func (s *Server) explore(w http.ResponseWriter, r *http.Request) {
145149 }{s.siteName(), viewer.Username, q, s.filterRepos(q, s.describeAll(repos))})
146150 }
147151
152// viewerName returns the logged-in username for header rendering, or "".
153func (s *Server) viewerName(r *http.Request) string {
154 if s.cfg.Web.Mode != "accounts" {
155 return ""
156 }
157 return s.viewer(r).Username
158}
159
160// privacy renders the privacy page: what the gitbay software does with
161// data, plus this instance's operator-provided notes.
162func (s *Server) privacy(w http.ResponseWriter, r *http.Request) {
163 s.render(w, "privacy.html", struct {
164 Site string
165 Viewer string
166 Host string
167 Notice string
168 }{s.siteName(), s.viewerName(r), s.cfg.SiteHost(), s.cfg.Web.PrivacyNotice})
169}
170
148171 // filterRepos keeps repos whose path, description, or topics contain the
149172 // query, case-insensitively. An empty query keeps everything.
150173 func (s *Server) filterRepos(q string, repos []describedRepo) []describedRepo {
internal/policy/names.go +1
@@ -20,6 +20,7 @@ var reservedNames = map[string]bool{
2020 "login": true,
2121 "logout": true,
2222 "new": true,
23 "privacy": true,
2324 "raw": true,
2425 "register": true,
2526 "settings": true,
internal/web/static/favicon.svg +1 −1
@@ -1 +1 @@
1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><rect x="1" y="1" width="22" height="22" rx="6" fill="#0b6c80"/><path d="M5 9.5c2-2.5 4-2.5 6 0s4 2.5 6 0" stroke="#ffffff" stroke-width="2.2" fill="none" stroke-linecap="round"/><path d="M5 15c2-2.5 4-2.5 6 0s4 2.5 6 0" stroke="#ffffff" stroke-width="2.2" fill="none" stroke-linecap="round"/></svg>
1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><rect x="1" y="1" width="22" height="22" rx="6" fill="#0000f0"/><path d="M5 9.5c2-2.5 4-2.5 6 0s4 2.5 6 0" stroke="#ffffff" stroke-width="2.2" fill="none" stroke-linecap="round"/><path d="M5 15c2-2.5 4-2.5 6 0s4 2.5 6 0" stroke="#ffffff" stroke-width="2.2" fill="none" stroke-linecap="round"/></svg>
internal/web/static/style.css +14 −3
@@ -13,7 +13,7 @@
1313 --faint: #eceff1; /* hairlines, row separators */
1414 --line: #d7dde2; /* control borders, card edges */
1515 --surface: #f6f8fa; /* cards, code, table hover */
16 --accent: #0b6c80; /* sea blue: links, active tab, primary */
16 --accent: #0000f0; /* blue: links, active tab, primary */
1717 --accent-fg: #ffffff; /* text on accent */
1818 --ok: #1a7f37;
1919 --warn: #9a6700;
@@ -56,8 +56,8 @@
5656 --faint: #1d242c;
5757 --line: #30363d;
5858 --surface: #161b22;
59 --accent: #58b7c9;
60 --accent-fg: #06272e;
59 --accent: #8899ff;
60 --accent-fg: #0a1030;
6161 --ok: #3fb950;
6262 --warn: #d29922;
6363 --bad: #f85149;
@@ -114,6 +114,17 @@ a.site {
114114 }
115115 a.site:hover { text-decoration: none; color: var(--accent); }
116116 a.site svg.mark { display: block; }
117nav.topnav {
118 display: flex;
119 align-items: center;
120 gap: var(--sp-4);
121}
122nav.topnav .spacer { flex: 1; }
123nav.topnav > a:not(.site) { color: var(--fg); font-size: var(--fs-2); }
124nav.topnav > a:not(.site):hover { color: var(--accent); text-decoration: none; }
125nav.topnav a.navuser { font-weight: 600; }
126nav.topnav button.linklike { color: var(--muted); }
127nav.topnav button.linklike:hover { color: var(--accent); text-decoration: none; }
117128 main.container {
118129 width: 100%;
119130 padding-top: var(--sp-3);
internal/web/templates/dashboard.html +1 −2
@@ -3,8 +3,7 @@
33 <div class="headrow">
44 <h1>dashboard</h1>
55 <span class="spacer"></span>
6<p class="toolbar">logged in as {{.Viewer}} · <a href="/{{.Viewer}}">your repositories</a> · <a href="/new">new repository</a> · <a href="/explore">explore</a> ·
7<form method="post" action="/logout" class="inline"><button type="submit" class="linklike">logout</button></form></p>
6<p class="toolbar">logged in as <a href="/{{.Viewer}}">{{.Viewer}}</a></p>
87 </div>
98 {{if .Pinned}}<h2>pinned</h2>
109 <div class="repogrid">
internal/web/templates/layout.html +9 −2
@@ -9,13 +9,20 @@
99 </head>
1010 <body>
1111 <header>
12 <nav class="container"><a class="site" href="/"><svg class="mark" width="20" height="20" viewBox="0 0 24 24" aria-hidden="true"><rect x="1" y="1" width="22" height="22" rx="6" fill="var(--accent)"/><path d="M5 9.5c2-2.5 4-2.5 6 0s4 2.5 6 0" stroke="var(--accent-fg)" stroke-width="2.2" fill="none" stroke-linecap="round"/><path d="M5 15c2-2.5 4-2.5 6 0s4 2.5 6 0" stroke="var(--accent-fg)" stroke-width="2.2" fill="none" stroke-linecap="round"/></svg>{{.Site}}</a></nav>
12 <nav class="container topnav">
13 <a class="site" href="/"><svg class="mark" width="20" height="20" viewBox="0 0 24 24" aria-hidden="true"><rect x="1" y="1" width="22" height="22" rx="6" fill="var(--accent)"/><path d="M5 9.5c2-2.5 4-2.5 6 0s4 2.5 6 0" stroke="var(--accent-fg)" stroke-width="2.2" fill="none" stroke-linecap="round"/><path d="M5 15c2-2.5 4-2.5 6 0s4 2.5 6 0" stroke="var(--accent-fg)" stroke-width="2.2" fill="none" stroke-linecap="round"/></svg>{{.Site}}</a>
14 <span class="spacer"></span>
15 <a href="/explore">explore</a>
16 {{if .Viewer}}<a href="/new">new repository</a>
17 <a class="navuser" href="/{{.Viewer}}">{{.Viewer}}</a>
18 <form method="post" action="/logout" class="inline"><button type="submit" class="linklike">logout</button></form>{{end}}
19 </nav>
1320 </header>
1421 <main class="container">
1522 {{template "content" .}}
1623 </main>
1724 <footer>
18 <div class="container"><p>powered by <a href="https://gitbay.org/krz/gitbay">gitbay</a>{{with gitbayVersion}} · <code>{{.}}</code>{{end}}</p></div>
25 <div class="container"><p>powered by <a href="https://gitbay.org/krz/gitbay">gitbay</a>{{with gitbayVersion}} · <code><a href="https://gitbay.org/krz/gitbay/commit/{{gitbayCommit}}">{{.}}</a></code>{{end}} · <a href="/privacy">privacy</a></p></div>
1926 </footer>
2027 </body>
2128 </html>{{end}}
internal/web/templates/privacy.html added +29
@@ -0,0 +1,29 @@
1{{define "title"}}privacy · {{.Site}}{{end}}
2{{define "content"}}
3<div class="landing">
4<h1>privacy</h1>
5
6<h2>the software</h2>
7<p>This site runs <a href="https://gitbay.org/krz/gitbay">gitbay</a>, a
8self-hosted, CLI-first git forge. The software makes no external
9requests from your browser: no analytics, no CDNs, no webfonts, no
10tracking of any kind. Everything you see is served from this host.</p>
11<p>What the server stores is what a forge needs to function: your
12account (username, email addresses, public SSH/PGP keys), the
13repositories and their contents, issues, merge requests, comments, and
14a security audit log of account and repository actions (recording the
15acting user, the action, and the public key fingerprint used). Web
16sessions use a single cookie, only after you log in. Private
17repositories are visible only to accounts you grant; to everyone else
18they are indistinguishable from nonexistent.</p>
19<p>Your data is portable by design: <code>gitbay auth export</code>
20downloads your account bundle, git data is yours by clone, and
21<code>gitbay migrate</code> moves everything to another instance.</p>
22
23<h2>this instance ({{.Host}})</h2>
24{{if .Notice}}{{range paragraphs .Notice}}<p>{{.}}</p>{{end}}
25{{else}}<p class="desc">The operator of this instance has not added
26instance-specific notes. Questions about backups, retention, or
27jurisdiction go to the operator.</p>{{end}}
28</div>
29{{end}}
internal/web/web.go +27
@@ -7,6 +7,7 @@ import (
77 "html/template"
88 "io"
99 "runtime/debug"
10 "strings"
1011 "sync"
1112 "time"
1213 )
@@ -35,8 +36,34 @@ var version = sync.OnceValue(func() string {
3536 return ""
3637 })
3738
39// fullVersion is the complete VCS revision, for linking the footer hash
40// to the upstream commit page.
41var fullVersion = sync.OnceValue(func() string {
42 info, ok := debug.ReadBuildInfo()
43 if !ok {
44 return ""
45 }
46 for _, s := range info.Settings {
47 if s.Key == "vcs.revision" {
48 return s.Value
49 }
50 }
51 return ""
52})
53
3854 var funcs = template.FuncMap{
3955 "gitbayVersion": func() string { return version() },
56 "gitbayCommit": func() string { return fullVersion() },
57 // paragraphs splits plain text on blank lines for safe rich display.
58 "paragraphs": func(s string) []string {
59 var out []string
60 for _, p := range strings.Split(s, "\n\n") {
61 if p = strings.TrimSpace(p); p != "" {
62 out = append(out, p)
63 }
64 }
65 return out
66 },
4067 // short abbreviates a commit SHA for display.
4168 "short": func(s string) string {
4269 if len(s) > 10 {