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) { |
| 87 | 87 | |
| 88 | 88 | browser := newBrowser(t) |
| 89 | 89 | 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>`) { |
| 91 | 91 | t.Fatalf("login redirect landed wrong: %d\n%s", status, body) |
| 92 | 92 | } |
| 93 | 93 | |
e2e/dashboard_test.go
+1 −1
| @@ -89,7 +89,7 @@ func TestDashboard(t *testing.T) { |
| 89 | 89 | t.Fatalf("login: %d", status) |
| 90 | 90 | } |
| 91 | 91 | 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>`) { |
| 93 | 93 | t.Fatalf("dashboard: %d", status) |
| 94 | 94 | } |
| 95 | 95 | for _, want := range []string{ |
internal/config/config.go
+3
| @@ -62,6 +62,9 @@ type GitDaemon struct { |
| 62 | 62 | type Web struct { |
| 63 | 63 | Mode string `toml:"mode"` // view_only | accounts |
| 64 | 64 | 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"` |
| 65 | 68 | } |
| 66 | 69 | |
| 67 | 70 | type Registration struct { |
internal/httpd/accounts.go
+8 −6
| @@ -63,17 +63,19 @@ func (s *Server) login(w http.ResponseWriter, r *http.Request) { |
| 63 | 63 | token := r.URL.Query().Get("token") |
| 64 | 64 | if token == "" { |
| 65 | 65 | 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(), "", ""}) |
| 69 | 70 | return |
| 70 | 71 | } |
| 71 | 72 | userID, err := s.st.ConsumeLoginToken(store.HashToken(token)) |
| 72 | 73 | if err != nil { |
| 73 | 74 | 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"}) |
| 77 | 79 | return |
| 78 | 80 | } |
| 79 | 81 | sessTok, sessHash, err := store.NewToken() |
internal/httpd/routes.go
+1
| @@ -33,6 +33,7 @@ func (s *Server) Routes() []Route { |
| 33 | 33 | routes = append(routes, |
| 34 | 34 | Route{Method: "GET", Pattern: "/{$}", Handler: s.index}, |
| 35 | 35 | Route{Method: "GET", Pattern: "/explore", Handler: s.explore}, |
| 36 | Route{Method: "GET", Pattern: "/privacy", Handler: s.privacy}, |
| 36 | 37 | Route{Method: "GET", Pattern: "/static/style.css", Handler: s.stylesheet}, |
| 37 | 38 | Route{Method: "GET", Pattern: "/favicon.svg", Handler: s.favicon}, |
| 38 | 39 | 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) { |
| 63 | 63 | // the stock plain-text response if the template fails. |
| 64 | 64 | func (s *Server) notFound(w http.ResponseWriter, r *http.Request) { |
| 65 | 65 | 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 { |
| 67 | 70 | http.NotFound(w, r) |
| 68 | 71 | return |
| 69 | 72 | } |
| @@ -99,10 +102,11 @@ func (s *Server) index(w http.ResponseWriter, r *http.Request) { |
| 99 | 102 | s.cfg.Server.SiteURL, "https://"), "http://"), "/") |
| 100 | 103 | s.render(w, "landing.html", struct { |
| 101 | 104 | Site string |
| 105 | Viewer string |
| 102 | 106 | Host string |
| 103 | 107 | Accounts bool |
| 104 | 108 | Signup bool |
| 105 | | }{s.siteName(), host, s.cfg.Web.Mode == "accounts", |
| 109 | }{s.siteName(), "", host, s.cfg.Web.Mode == "accounts", |
| 106 | 110 | s.cfg.Web.Mode == "accounts" && s.cfg.Registration.Mode != "closed"}) |
| 107 | 111 | } |
| 108 | 112 | |
| @@ -145,6 +149,25 @@ func (s *Server) explore(w http.ResponseWriter, r *http.Request) { |
| 145 | 149 | }{s.siteName(), viewer.Username, q, s.filterRepos(q, s.describeAll(repos))}) |
| 146 | 150 | } |
| 147 | 151 | |
| 152 | // viewerName returns the logged-in username for header rendering, or "". |
| 153 | func (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. |
| 162 | func (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 | |
| 148 | 171 | // filterRepos keeps repos whose path, description, or topics contain the |
| 149 | 172 | // query, case-insensitively. An empty query keeps everything. |
| 150 | 173 | func (s *Server) filterRepos(q string, repos []describedRepo) []describedRepo { |
internal/policy/names.go
+1
| @@ -20,6 +20,7 @@ var reservedNames = map[string]bool{ |
| 20 | 20 | "login": true, |
| 21 | 21 | "logout": true, |
| 22 | 22 | "new": true, |
| 23 | "privacy": true, |
| 23 | 24 | "raw": true, |
| 24 | 25 | "register": true, |
| 25 | 26 | "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 @@ |
| 13 | 13 | --faint: #eceff1; /* hairlines, row separators */ |
| 14 | 14 | --line: #d7dde2; /* control borders, card edges */ |
| 15 | 15 | --surface: #f6f8fa; /* cards, code, table hover */ |
| 16 | | --accent: #0b6c80; /* sea blue: links, active tab, primary */ |
| 16 | --accent: #0000f0; /* blue: links, active tab, primary */ |
| 17 | 17 | --accent-fg: #ffffff; /* text on accent */ |
| 18 | 18 | --ok: #1a7f37; |
| 19 | 19 | --warn: #9a6700; |
| @@ -56,8 +56,8 @@ |
| 56 | 56 | --faint: #1d242c; |
| 57 | 57 | --line: #30363d; |
| 58 | 58 | --surface: #161b22; |
| 59 | | --accent: #58b7c9; |
| 60 | | --accent-fg: #06272e; |
| 59 | --accent: #8899ff; |
| 60 | --accent-fg: #0a1030; |
| 61 | 61 | --ok: #3fb950; |
| 62 | 62 | --warn: #d29922; |
| 63 | 63 | --bad: #f85149; |
| @@ -114,6 +114,17 @@ a.site { |
| 114 | 114 | } |
| 115 | 115 | a.site:hover { text-decoration: none; color: var(--accent); } |
| 116 | 116 | a.site svg.mark { display: block; } |
| 117 | nav.topnav { |
| 118 | display: flex; |
| 119 | align-items: center; |
| 120 | gap: var(--sp-4); |
| 121 | } |
| 122 | nav.topnav .spacer { flex: 1; } |
| 123 | nav.topnav > a:not(.site) { color: var(--fg); font-size: var(--fs-2); } |
| 124 | nav.topnav > a:not(.site):hover { color: var(--accent); text-decoration: none; } |
| 125 | nav.topnav a.navuser { font-weight: 600; } |
| 126 | nav.topnav button.linklike { color: var(--muted); } |
| 127 | nav.topnav button.linklike:hover { color: var(--accent); text-decoration: none; } |
| 117 | 128 | main.container { |
| 118 | 129 | width: 100%; |
| 119 | 130 | padding-top: var(--sp-3); |
internal/web/templates/dashboard.html
+1 −2
| @@ -3,8 +3,7 @@ |
| 3 | 3 | <div class="headrow"> |
| 4 | 4 | <h1>dashboard</h1> |
| 5 | 5 | <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> |
| 8 | 7 | </div> |
| 9 | 8 | {{if .Pinned}}<h2>pinned</h2> |
| 10 | 9 | <div class="repogrid"> |
internal/web/templates/layout.html
+9 −2
| @@ -9,13 +9,20 @@ |
| 9 | 9 | </head> |
| 10 | 10 | <body> |
| 11 | 11 | <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> |
| 13 | 20 | </header> |
| 14 | 21 | <main class="container"> |
| 15 | 22 | {{template "content" .}} |
| 16 | 23 | </main> |
| 17 | 24 | <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> |
| 19 | 26 | </footer> |
| 20 | 27 | </body> |
| 21 | 28 | </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 |
| 8 | self-hosted, CLI-first git forge. The software makes no external |
| 9 | requests from your browser: no analytics, no CDNs, no webfonts, no |
| 10 | tracking 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 |
| 12 | account (username, email addresses, public SSH/PGP keys), the |
| 13 | repositories and their contents, issues, merge requests, comments, and |
| 14 | a security audit log of account and repository actions (recording the |
| 15 | acting user, the action, and the public key fingerprint used). Web |
| 16 | sessions use a single cookie, only after you log in. Private |
| 17 | repositories are visible only to accounts you grant; to everyone else |
| 18 | they are indistinguishable from nonexistent.</p> |
| 19 | <p>Your data is portable by design: <code>gitbay auth export</code> |
| 20 | downloads 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 |
| 26 | instance-specific notes. Questions about backups, retention, or |
| 27 | jurisdiction go to the operator.</p>{{end}} |
| 28 | </div> |
| 29 | {{end}} |
internal/web/web.go
+27
| @@ -7,6 +7,7 @@ import ( |
| 7 | 7 | "html/template" |
| 8 | 8 | "io" |
| 9 | 9 | "runtime/debug" |
| 10 | "strings" |
| 10 | 11 | "sync" |
| 11 | 12 | "time" |
| 12 | 13 | ) |
| @@ -35,8 +36,34 @@ var version = sync.OnceValue(func() string { |
| 35 | 36 | return "" |
| 36 | 37 | }) |
| 37 | 38 | |
| 39 | // fullVersion is the complete VCS revision, for linking the footer hash |
| 40 | // to the upstream commit page. |
| 41 | var 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 | |
| 38 | 54 | var funcs = template.FuncMap{ |
| 39 | 55 | "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 | }, |
| 40 | 67 | // short abbreviates a commit SHA for display. |
| 41 | 68 | "short": func(s string) string { |
| 42 | 69 | if len(s) > 10 { |