A CLI-first git forge. cli forge git self-hosted

https://gitbay.org

Commit fd81fb5899

fd81fb5899fb651d5ad49f3d5458befcfd76622e

parent: a55ff45b55

Verified · cmc ci/build: success ci/test: failure

cmc <hello@cleberg.net> · 2026-09-19 06:42 UTC

web: the ten findings from the forge comparison

Links inside running text are underlined; titles, paths and pagers keep
position as their cue. File view line numbers are 24px inline blocks and
chroma's LineLink rule, which set outline: none, is dropped from the
served palette, so they keep the focus ring. Every rendered <pre> and the
landing quickstart block carry tabindex="0". The issue and MR aside no
longer precedes the thread on one column. Tree names wrap at 40rem so
the age column stays inside the card at 320px. The mux falls through to
the 404 template. An org image link with no description gets its file
name as alt. Release titles are h2. Text responses are gzipped for
clients that accept it.

web theme show|set system|light|dark stores the colour scheme per
account (migration 0057, users.theme); the layout stamps it as
data-theme on <html>, the account page has an Appearance section, and
the served stylesheet guards the dark token block and repeats it under
the dark stamp. The syntax palettes are scoped the same four ways.

Closes #232
.gitbay/wiki/Parity.org +1
@@ -308,6 +308,7 @@ client has no use for one (krz/gitbay#57).
308308| notification inbox | yes | yes | yes |
309309| activity mail on, off | yes | yes | yes |
310310| watch writable repos | yes | yes | yes |
311| web colour scheme | yes | yes | n/a |
311312| API token mint | yes | no | no |
312313| account export bundle | yes | yes | n/a |
313314| profile set | yes | yes | yes |
.gitbay/wiki/Users.org +5
@@ -653,6 +653,11 @@ id with its creation and expiry, and =gitbay web sessions revoke <id>=
653653or =--all= ends them from the terminal, which is where a lost laptop is
654654handled.
655655
656=web theme set light= or =dark= fixes the web UI's colour scheme for
657your account; =system=, the default, follows the browser's own
658preference. =web theme show= prints it. The account page has the same
659control under Appearance.
660
656661* Notifications
657662
658663When the instance has SMTP configured, activity mails you as well as
CHANGELOG.org +25
@@ -4,6 +4,31 @@ Versioning follows semver from v0.1.0. Database migrations run
44automatically on daemon start; upgrade notes appear per release when
55anything beyond "replace the binary and restart" is needed.
66
7* v1.30.0 — unreleased
8
9The web findings from the forge comparison (#232).
10
11- Links inside running text are underlined: the meta lines on issues,
12 merge requests and releases no longer tell a link apart by colour
13 alone.
14- File view line numbers are 24px targets and keep the focus ring;
15 chroma's LineLink rule, which set =outline: none=, is dropped from the
16 served palette.
17- Every rendered =<pre>= and the landing page's quickstart block take
18 keyboard focus, so a block that scrolls sideways can be reached.
19- On one column the issue and merge request aside follows the thread
20 instead of preceding it.
21- The file tree at 320px keeps its last column inside the card.
22- A path no route matches renders the 404 page instead of a plain-text
23 body.
24- An org image link with no description gets its file name as alt text,
25 so a README badge is a named link.
26- Release titles are h2.
27- Text responses are gzipped for clients that accept it.
28- =web theme set system|light|dark= and an Appearance section on the
29 account page fix the colour scheme per account. Migration 0057 adds
30 =users.theme=.
31
732* v1.29.0 — 2026-09-19
833
934The icon nav is a top bar at every width.
cmd/gitbay/main.go +4
@@ -647,6 +647,10 @@ func webCmd() *cobra.Command {
647647 pass("list", "list your browser sessions", passOpts{server: []string{"web", "sessions", "list"}}),
648648 pass("revoke", "end a browser session: <id>|--all", passOpts{server: []string{"web", "sessions", "revoke"}}),
649649 ),
650 group("theme", "the colour scheme the web UI uses for you",
651 pass("show", "show your colour scheme", passOpts{server: []string{"web", "theme", "show"}}),
652 pass("set", "follow the browser, or force one: system|light|dark", passOpts{server: []string{"web", "theme", "set"}}),
653 ),
650654 )
651655}
652656
e2e/theme_test.go added +44
@@ -0,0 +1,44 @@
1package e2e
2
3import (
4 "net/url"
5 "strings"
6 "testing"
7)
8
9// web theme set fixes the colour scheme the layout stamps on <html>; the
10// account page shows the same setting and changes it (#232).
11func TestWebTheme(t *testing.T) {
12 inst := startInstanceWith(t, "[web]\nmode = \"accounts\"\n")
13 key := inst.newKey(t, "alice")
14 inst.admin(t, "admin", "user", "create", "alice", "--key", key+".pub")
15
16 if out, _, code := inst.ssh(t, key, "", "web", "theme", "show", "--json"); code != 0 || !strings.Contains(out, `"theme":"system"`) {
17 t.Fatalf("theme show: %d %s", code, out)
18 }
19 if _, _, code := inst.ssh(t, key, "", "web", "theme", "set", "blue"); code != 2 {
20 t.Fatalf("bad value accepted: %d", code)
21 }
22 if out, _, code := inst.ssh(t, key, "", "web", "theme", "set", "dark", "--json"); code != 0 || !strings.Contains(out, `"theme":"dark"`) {
23 t.Fatalf("theme set: %d %s", code, out)
24 }
25
26 alice := inst.login(t, key)
27 set := inst.base() + "/settings"
28 _, body := browserGet(t, alice, set)
29 if !strings.Contains(body, `<html lang="en" data-theme="dark">`) {
30 t.Fatalf("page is not stamped dark:\n%s", body[:200])
31 }
32 if !strings.Contains(body, `<option value="dark" selected>`) {
33 t.Fatalf("account page does not show dark selected")
34 }
35 if status, _ := browserPost(t, alice, set, url.Values{"field": {"theme"}, "theme": {"system"}}); status != 200 {
36 t.Fatalf("settings post: %d", status)
37 }
38 if out, _, _ := inst.ssh(t, key, "", "web", "theme", "show", "--json"); !strings.Contains(out, `"theme":"system"`) {
39 t.Fatalf("web form did not reset the theme: %s", out)
40 }
41 if _, body := browserGet(t, alice, set); strings.Contains(body, "data-theme") {
42 t.Fatalf("system stamps the page")
43 }
44}
internal/control/theme.go added +49
@@ -0,0 +1,49 @@
1package control
2
3import (
4 "fmt"
5 "io"
6
7 "gitbay.org/gitbay/internal/protocol"
8)
9
10func init() {
11 register(Command{Path: []string{"web", "theme", "show"},
12 Summary: "the colour scheme the web UI uses for you",
13 Usage: "web theme show",
14 ReadOnly: true, Run: runWebThemeShow})
15 register(Command{Path: []string{"web", "theme", "set"},
16 Summary: "follow the browser's scheme, or force light or dark",
17 Usage: "web theme set system|light|dark", Run: runWebThemeSet})
18}
19
20// themes are the values the layout knows how to stamp. system is the
21// default and stamps nothing: the stylesheet's media query decides.
22var themes = map[string]bool{"system": true, "light": true, "dark": true}
23
24func runWebThemeShow(c *Ctx, args []string) int {
25 if len(args) != 0 {
26 return c.usage()
27 }
28 return emitTheme(c)
29}
30
31func runWebThemeSet(c *Ctx, args []string) int {
32 if len(args) != 1 || !themes[args[0]] {
33 return c.usage()
34 }
35 if err := c.Store.SetTheme(c.User.ID, args[0]); err != nil {
36 return c.fail(protocol.ExitFailure, "%v", err)
37 }
38 return emitTheme(c)
39}
40
41func emitTheme(c *Ctx) int {
42 theme, err := c.Store.Theme(c.User.ID)
43 if err != nil {
44 return c.fail(protocol.ExitFailure, "%v", err)
45 }
46 return c.emit(map[string]string{"theme": theme}, func(w io.Writer) {
47 fmt.Fprintf(w, "theme: %s\n", theme)
48 })
49}
internal/control/theme_test.go added +36
@@ -0,0 +1,36 @@
1package control
2
3import (
4 "bytes"
5 "io"
6 "strings"
7 "testing"
8
9 "gitbay.org/gitbay/internal/protocol"
10 "gitbay.org/gitbay/internal/store"
11)
12
13// web theme show reports system for a new account, set refuses anything
14// but the three schemes, and a set value is what show reports next.
15func TestWebTheme(t *testing.T) {
16 st, _, uid := newQueueTestRepo(t)
17 var buf bytes.Buffer
18 c := &Ctx{User: store.User{ID: uid, Username: "alice"}, Store: st, Stdout: &buf, Stderr: io.Discard, JSON: true}
19
20 if code := runWebThemeShow(c, nil); code != protocol.ExitOK || !strings.Contains(buf.String(), `"theme":"system"`) {
21 t.Fatalf("show: %d %s", code, buf.String())
22 }
23 if code := runWebThemeSet(c, []string{"blue"}); code != protocol.ExitUsage {
24 t.Fatalf("bad value exited %d", code)
25 }
26 if code := runWebThemeSet(c, nil); code != protocol.ExitUsage {
27 t.Fatalf("no value exited %d", code)
28 }
29 buf.Reset()
30 if code := runWebThemeSet(c, []string{"dark"}); code != protocol.ExitOK || !strings.Contains(buf.String(), `"theme":"dark"`) {
31 t.Fatalf("set: %d %s", code, buf.String())
32 }
33 if got, _ := st.Theme(uid); got != "dark" {
34 t.Fatalf("stored theme: %q", got)
35 }
36}
internal/httpd/account.go +20 −12
@@ -59,22 +59,24 @@ func (s *Server) accountForm(w http.ResponseWriter, r *http.Request, u store.Use
5959 s.runControlInto(u, []string{"profile", "show"}, &profile)
6060 mailOn, _ := s.st.MailEnabled(u.ID)
6161 watchOn, _ := s.st.WatchEnabled(u.ID)
62 theme, _ := s.st.Theme(u.ID)
6263
6364 s.render(w, "account.html", struct {
6465 basePage
65 Tab string // marks the rail's Settings row as current
66 Keys []accountKey
67 PGP []accountPGP
68 Emails []store.Email
69 Profile control.ProfileOut
70 LinksText string
71 Host string
72 Notice string
73 Message string
74 MailOn bool
75 WatchOn bool
66 Tab string // marks the rail's Settings row as current
67 Keys []accountKey
68 PGP []accountPGP
69 Emails []store.Email
70 Profile control.ProfileOut
71 LinksText string
72 Host string
73 Notice string
74 Message string
75 MailOn bool
76 WatchOn bool
77 ThemeSetting string // system, light or dark: the form's selected option
7678 }{s.baseFor(u), "account", keys, pgp, emails, profile, profileLinksText(profile.Links), s.cfg.SiteHost(),
77 s.takeFlash(w, r), r.URL.Query().Get("m"), mailOn, watchOn})
79 s.takeFlash(w, r), r.URL.Query().Get("m"), mailOn, watchOn, theme})
7880}
7981
8082// accountExport hands the browser the same bundle `account export`
@@ -219,6 +221,12 @@ func (s *Server) accountSubmit(w http.ResponseWriter, r *http.Request, u store.U
219221 return
220222 }
221223 back("", "primary address changed")
224 case "theme":
225 if _, msg, ok := s.runControl(u, []string{"web", "theme", "set", r.FormValue("theme")}); !ok {
226 back(msg, "")
227 return
228 }
229 back("", "colour scheme saved")
222230 case "notify-mail", "notify-watch":
223231 pref := strings.TrimPrefix(r.FormValue("field"), "notify-")
224232 state := "off"
internal/httpd/compress.go added +85
@@ -0,0 +1,85 @@
1package httpd
2
3import (
4 "compress/gzip"
5 "net/http"
6 "strings"
7)
8
9// compressed gzips text responses for clients that accept it. Every body
10// the forge writes itself is text — pages, the stylesheet, feeds, JSON —
11// and none is precompressed; the stylesheet alone went from 67 KB to 15 KB
12// (#232). The decision is made when the headers are final, on the content
13// type, so git transport, LFS and binary downloads pass through untouched.
14func compressed(next http.Handler) http.Handler {
15 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
16 if !strings.Contains(r.Header.Get("Accept-Encoding"), "gzip") {
17 next.ServeHTTP(w, r)
18 return
19 }
20 cw := &gzipWriter{ResponseWriter: w}
21 defer cw.Close()
22 next.ServeHTTP(cw, r)
23 })
24}
25
26var compressibleTypes = []string{"text/", "application/json", "application/atom+xml", "image/svg+xml"}
27
28func compressible(contentType string) bool {
29 for _, p := range compressibleTypes {
30 if strings.HasPrefix(contentType, p) {
31 return true
32 }
33 }
34 return false
35}
36
37// gzipWriter decides on the first WriteHeader or Write whether the body
38// is compressed, then either wraps the underlying writer or steps aside.
39type gzipWriter struct {
40 http.ResponseWriter
41 gz *gzip.Writer
42 decided bool
43}
44
45func (g *gzipWriter) decide(status int) {
46 if g.decided {
47 return
48 }
49 g.decided = true
50 h := g.Header()
51 if status == http.StatusNotModified || status == http.StatusNoContent ||
52 h.Get("Content-Encoding") != "" || !compressible(h.Get("Content-Type")) {
53 return
54 }
55 h.Del("Content-Length")
56 h.Set("Content-Encoding", "gzip")
57 h.Add("Vary", "Accept-Encoding")
58 g.gz = gzip.NewWriter(g.ResponseWriter)
59}
60
61func (g *gzipWriter) WriteHeader(status int) {
62 g.decide(status)
63 g.ResponseWriter.WriteHeader(status)
64}
65
66func (g *gzipWriter) Write(b []byte) (int, error) {
67 if !g.decided {
68 // net/http would sniff the type on this write; do it first so the
69 // decision sees it.
70 if g.Header().Get("Content-Type") == "" {
71 g.Header().Set("Content-Type", http.DetectContentType(b))
72 }
73 g.decide(http.StatusOK)
74 }
75 if g.gz == nil {
76 return g.ResponseWriter.Write(b)
77 }
78 return g.gz.Write(b)
79}
80
81func (g *gzipWriter) Close() {
82 if g.gz != nil {
83 g.gz.Close()
84 }
85}
internal/httpd/compress_test.go added +81
@@ -0,0 +1,81 @@
1package httpd
2
3import (
4 "bytes"
5 "compress/gzip"
6 "io"
7 "net/http"
8 "net/http/httptest"
9 "testing"
10
11 "gitbay.org/gitbay/internal/config"
12)
13
14func plainServer() *Server {
15 cfg := config.Default()
16 cfg.Server.SiteURL = "https://forge.test/"
17 return &Server{cfg: cfg}
18}
19
20func get(t *testing.T, h http.Handler, path string, hdr map[string]string) *httptest.ResponseRecorder {
21 t.Helper()
22 r := httptest.NewRequest("GET", path, nil)
23 r.Host = "forge.test"
24 for k, v := range hdr {
25 r.Header.Set(k, v)
26 }
27 w := httptest.NewRecorder()
28 h.ServeHTTP(w, r)
29 return w
30}
31
32// The stylesheet is gzipped for a client that accepts it and served as-is
33// for one that does not; both bodies are the same bytes (#232).
34func TestStylesheetIsCompressed(t *testing.T) {
35 h := plainServer().Handler()
36 plain := get(t, h, "/static/style.css", nil)
37 if plain.Code != 200 || plain.Header().Get("Content-Encoding") != "" {
38 t.Fatalf("identity: %d %q", plain.Code, plain.Header().Get("Content-Encoding"))
39 }
40 zipped := get(t, h, "/static/style.css", map[string]string{"Accept-Encoding": "gzip, br"})
41 if zipped.Code != 200 || zipped.Header().Get("Content-Encoding") != "gzip" || zipped.Header().Get("Vary") != "Accept-Encoding" {
42 t.Fatalf("gzip: %d %q vary=%q", zipped.Code, zipped.Header().Get("Content-Encoding"), zipped.Header().Get("Vary"))
43 }
44 if zipped.Body.Len() >= plain.Body.Len()/2 {
45 t.Fatalf("gzip body %d bytes, plain %d", zipped.Body.Len(), plain.Body.Len())
46 }
47 zr, err := gzip.NewReader(zipped.Body)
48 if err != nil {
49 t.Fatal(err)
50 }
51 body, _ := io.ReadAll(zr)
52 if !bytes.Equal(body, plain.Body.Bytes()) {
53 t.Fatal("gunzipped body differs from the identity body")
54 }
55 if zipped.Header().Get("ETag") != plain.Header().Get("ETag") {
56 t.Fatal("ETag changed with encoding")
57 }
58 // A 304 carries no body to compress and no encoding header.
59 notMod := get(t, h, "/static/style.css", map[string]string{"Accept-Encoding": "gzip", "If-None-Match": plain.Header().Get("ETag")})
60 if notMod.Code != 304 || notMod.Header().Get("Content-Encoding") != "" {
61 t.Fatalf("304: %d %q", notMod.Code, notMod.Header().Get("Content-Encoding"))
62 }
63}
64
65// A binary type is not touched: the font route keeps its bytes and no
66// encoding header.
67func TestBinaryResponsesPassThrough(t *testing.T) {
68 h := plainServer().Handler()
69 w := get(t, h, "/favicon.svg", map[string]string{"Accept-Encoding": "gzip"})
70 if w.Code != 200 || w.Header().Get("Content-Encoding") != "gzip" {
71 t.Fatalf("svg is text and should gzip: %d %q", w.Code, w.Header().Get("Content-Encoding"))
72 }
73 rec := httptest.NewRecorder()
74 compressed(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
75 w.Header().Set("Content-Type", "application/x-git-upload-pack-result")
76 w.Write([]byte("0000"))
77 })).ServeHTTP(rec, httptest.NewRequest("POST", "/x", nil))
78 if rec.Header().Get("Content-Encoding") != "" || rec.Body.String() != "0000" {
79 t.Fatalf("git transport touched: %q %q", rec.Header().Get("Content-Encoding"), rec.Body.String())
80 }
81}
internal/httpd/notfound_test.go added +26
@@ -0,0 +1,26 @@
1package httpd
2
3import (
4 "strings"
5 "testing"
6)
7
8// A path no route matches renders the 404 page, the same one a missing
9// record gets, instead of net/http's plain-text body (#232).
10func TestUnmatchedPathRendersNotFoundPage(t *testing.T) {
11 h := plainServer().Handler()
12 // One and two segment paths are owner and repository routes, which
13 // need a store; these fall past every pattern.
14 for _, p := range []string{"/krz/gitbay/mrs/315/files", "/krz/gitbay/nothing/at/all"} {
15 w := get(t, h, p, nil)
16 if w.Code != 404 {
17 t.Errorf("%s: status %d", p, w.Code)
18 }
19 if ct := w.Header().Get("Content-Type"); !strings.HasPrefix(ct, "text/html") {
20 t.Errorf("%s: content type %q", p, ct)
21 }
22 if !strings.Contains(w.Body.String(), "Page not found") {
23 t.Errorf("%s: body is not the 404 page:\n%s", p, w.Body.String())
24 }
25 }
26}
internal/httpd/orgrender_test.go +1 −1
@@ -107,7 +107,7 @@ func TestOrgRenderingIsUnaffectedByTheIncludeGuard(t *testing.T) {
107107 // The source block is chroma-highlighted, so its text is split across spans;
108108 // check the block and a token rather than the joined source line.
109109 for _, want := range []string{"Heading", "<em>emphasis</em>", "<code>code</code>",
110 `<pre class="chroma">`, "Println"} {
110 `<pre tabindex="0" class="chroma">`, "Println"} {
111111 if !strings.Contains(out, want) {
112112 t.Errorf("expected %q in output:\n%s", want, out)
113113 }
internal/httpd/page.go +6
@@ -31,6 +31,9 @@ type basePage struct {
3131 Viewer string
3232 Admin bool // the viewer is an instance admin: the rail shows /admin
3333 Rail rail
34 // Theme is stamped on <html> as data-theme: light or dark when the
35 // viewer chose one, empty when the browser's own scheme decides.
36 Theme string
3437}
3538
3639// base builds the layout-wide data for a request that has not already
@@ -52,6 +55,9 @@ func (s *Server) baseFor(viewer store.User) basePage {
5255 b.Viewer = viewer.Username
5356 b.Admin = viewer.IsAdmin
5457 b.Rail = s.railFor(viewer)
58 if theme, err := s.st.Theme(viewer.ID); err == nil && theme != "system" {
59 b.Theme = theme
60 }
5561 return b
5662}
5763
internal/httpd/rendered.go added +51
@@ -0,0 +1,51 @@
1package httpd
2
3import (
4 "html/template"
5 "path"
6 "regexp"
7 "strings"
8)
9
10var (
11 preTag = regexp.MustCompile(`<pre\b[^>]*>`)
12 imgTag = regexp.MustCompile(`<img\b[^>]*>`)
13 srcAttr = regexp.MustCompile(`\ssrc="([^"]*)"`)
14)
15
16// focusableBlocks gives every <pre> a tab stop. A block wider than its
17// column scrolls sideways, and without one a keyboard has no way to reach
18// the end of a long line (#232).
19func focusableBlocks(h template.HTML) template.HTML {
20 return template.HTML(preTag.ReplaceAllStringFunc(string(h), func(m string) string {
21 if strings.Contains(m, "tabindex") {
22 return m
23 }
24 return `<pre tabindex="0"` + m[len("<pre"):]
25 }))
26}
27
28// imageAlt names an image that arrived without alt text after its file.
29// go-org writes none for a bare image link, so a README badge was an image
30// with no text inside a link with no name (#232). Markdown always carries
31// alt, empty or not, and an empty alt is left alone: it means decorative.
32func imageAlt(h template.HTML) template.HTML {
33 return template.HTML(imgTag.ReplaceAllStringFunc(string(h), func(m string) string {
34 if strings.Contains(m, " alt=") {
35 return m
36 }
37 name := ""
38 if sub := srcAttr.FindStringSubmatch(m); sub != nil {
39 src := sub[1]
40 if i := strings.IndexAny(src, "?#"); i >= 0 {
41 src = src[:i]
42 }
43 name = path.Base(src)
44 name = strings.TrimSuffix(name, path.Ext(name))
45 if name == "." || name == "/" {
46 name = ""
47 }
48 }
49 return `<img alt="` + template.HTMLEscapeString(name) + `"` + m[len("<img"):]
50 }))
51}
internal/httpd/rendered_test.go added +44
@@ -0,0 +1,44 @@
1package httpd
2
3import (
4 "html/template"
5 "strings"
6 "testing"
7)
8
9func TestFocusableBlocks(t *testing.T) {
10 got := string(focusableBlocks(`<pre class="chroma">x</pre><pre>y</pre><pre tabindex="-1">z</pre>`))
11 want := `<pre tabindex="0" class="chroma">x</pre><pre tabindex="0">y</pre><pre tabindex="-1">z</pre>`
12 if got != want {
13 t.Fatalf("got %s\nwant %s", got, want)
14 }
15}
16
17func TestImageAlt(t *testing.T) {
18 cases := map[string]string{
19 `<a href="/b"><img src="https://forge.test/krz/gitbay/badge/build.svg"/></a>`: `<a href="/b"><img alt="build" src="https://forge.test/krz/gitbay/badge/build.svg"/></a>`,
20 `<img src="pic.png?v=2">`: `<img alt="pic" src="pic.png?v=2">`,
21 `<img alt="" src="pic.png">`: `<img alt="" src="pic.png">`,
22 `<img alt="a chart" src="c.png">`: `<img alt="a chart" src="c.png">`,
23 `<img>`: `<img alt="">`,
24 }
25 for in, want := range cases {
26 if got := string(imageAlt(template.HTML(in))); got != want {
27 t.Errorf("%s\n got %s\nwant %s", in, got, want)
28 }
29 }
30}
31
32// A README badge written as a bare org image link renders as a named
33// image, and an org source block takes keyboard focus.
34func TestOrgReadmeBadgeAndBlocks(t *testing.T) {
35 out := string(renderReadme("README.org", []byte("[[https://forge.test/krz/gitbay/builds][https://forge.test/krz/gitbay/badge/build.svg]]\n\n#+begin_src go\npackage main\n#+end_src\n")))
36 for _, want := range []string{`alt="build"`, `<pre tabindex="0"`} {
37 if !strings.Contains(out, want) {
38 t.Errorf("lacks %s:\n%s", want, out)
39 }
40 }
41 if got := string(renderReadme("README.md", []byte("```go\npackage main\n```\n"))); !strings.Contains(got, `<pre tabindex="0"`) {
42 t.Errorf("markdown fence has no tab stop:\n%s", got)
43 }
44}
internal/httpd/routes.go +4 −1
@@ -230,13 +230,16 @@ func (s *Server) Handler() http.Handler {
230230 for _, r := range s.Routes() {
231231 mux.HandleFunc(r.Method+" "+r.Pattern, r.Handler)
232232 }
233 // A path no pattern matches gets the 404 page, not net/http's
234 // plain-text body (#232).
235 mux.HandleFunc("/", s.notFound)
233236 var h http.Handler = mux
234237 if len(s.cfg.GoImport) > 0 {
235238 h = s.goImportHandler(mux)
236239 }
237240 // Always wrapped: custom pages domains work with or without the
238241 // built-in [pages] domain.
239 return s.pagesRouter(s.securityHeaders(h))
242 return compressed(s.pagesRouter(s.securityHeaders(h)))
240243}
241244
242245// securityHeaders sets defensive response headers on every reply. The CSP
internal/httpd/theme.go added +61
@@ -0,0 +1,61 @@
1package httpd
2
3import (
4 "regexp"
5 "strings"
6
7 "gitbay.org/gitbay/internal/web"
8)
9
10// themedCSS derives the served stylesheet from style.css. The source keeps
11// one dark token block under the media query, which the token tests read;
12// served, that block is guarded so a page stamped data-theme="light" keeps
13// the light tokens under a dark OS, and a copy of it applies, outside any
14// media query, when the page is stamped data-theme="dark" (#232).
15func themedCSS(src []byte) []byte {
16 const open = "@media (prefers-color-scheme: dark) {\n :root {"
17 const close = "\n }\n}"
18 s := string(src)
19 i := strings.Index(s, open)
20 if i < 0 {
21 return src
22 }
23 start := i + len(open)
24 n := strings.Index(s[start:], close)
25 if n < 0 {
26 return src
27 }
28 body := s[start : start+n]
29 var b strings.Builder
30 b.WriteString(s[:i])
31 b.WriteString("@media (prefers-color-scheme: dark) {\n :root:not([data-theme=\"light\"]) {")
32 b.WriteString(body)
33 b.WriteString(close)
34 b.WriteString("\n:root[data-theme=\"dark\"] {")
35 b.WriteString(body)
36 b.WriteString("\n}")
37 b.WriteString(s[start+n+len(close):])
38 return []byte(b.String())
39}
40
41// styleCSS is what /static/style.css serves, minus the syntax palettes.
42var styleCSS = themedCSS(web.StyleCSS)
43
44// chromaRule matches the start of one rule in chroma's generated CSS: an
45// optional token-name comment, then the .chroma or .bg selector.
46var chromaRule = regexp.MustCompile(`(?m)^((?:/\*[^*]*\*/ )?)(\.chroma|\.bg)`)
47
48// scopeChroma prefixes every rule of a chroma palette with a selector, so
49// the palette applies only under that scheme. The LineLink rule is dropped:
50// it sets outline: none on the line-number links, which hid the focus ring;
51// style.css carries the rest of it.
52func scopeChroma(css, prefix string) string {
53 var out []string
54 for _, line := range strings.Split(css, "\n") {
55 if strings.Contains(line, ".lnlinks") {
56 continue
57 }
58 out = append(out, chromaRule.ReplaceAllString(line, "${1}"+prefix+" $2"))
59 }
60 return strings.Join(out, "\n")
61}
internal/httpd/theme_test.go added +64
@@ -0,0 +1,64 @@
1package httpd
2
3import (
4 "strings"
5 "testing"
6
7 "gitbay.org/gitbay/internal/web"
8)
9
10// The served stylesheet guards the dark media block against an explicit
11// light choice and repeats it, verbatim, for an explicit dark one (#232).
12func TestThemedCSSCarriesBothDarkBlocks(t *testing.T) {
13 css := string(themedCSS(web.StyleCSS))
14 const guarded = "@media (prefers-color-scheme: dark) {\n :root:not([data-theme=\"light\"]) {"
15 const stamped = "\n:root[data-theme=\"dark\"] {"
16 gi, si := strings.Index(css, guarded), strings.Index(css, stamped)
17 if gi < 0 || si < 0 {
18 t.Fatalf("guarded %d stamped %d", gi, si)
19 }
20 body := func(from int) string {
21 rest := css[from:]
22 return rest[:strings.Index(rest, "\n }\n}")]
23 }
24 guardedBody := body(gi + len(guarded))
25 rest := css[si+len(stamped):]
26 stampedBody := rest[:strings.Index(rest, "\n}")]
27 if guardedBody != stampedBody {
28 t.Fatalf("dark blocks differ:\n%s\n---\n%s", guardedBody, stampedBody)
29 }
30 if !strings.Contains(guardedBody, "--canvas: #101114;") {
31 t.Fatalf("dark block lacks the canvas token:\n%s", guardedBody)
32 }
33 if strings.Contains(css, "@media (prefers-color-scheme: dark) {\n :root {") {
34 t.Fatal("the dark token block is served unguarded")
35 }
36 // The source keeps the plain block, which the token tests parse.
37 if !strings.Contains(string(web.StyleCSS), "@media (prefers-color-scheme: dark) {\n :root {") {
38 t.Fatal("style.css no longer has the plain dark block")
39 }
40}
41
42// Each syntax palette is served four ways: under its media query unless
43// the other theme is stamped, and unconditionally under its own stamp.
44// chroma's LineLink rule, which set outline: none, is not served at all.
45func TestChromaPalettesAreScoped(t *testing.T) {
46 css := string(chromaCSS)
47 for _, want := range []string{
48 `:root:not([data-theme="dark"]) .chroma .na { color: #6f5a21 }`,
49 `:root:not([data-theme="light"]) .chroma `,
50 `:root[data-theme="light"] .chroma `,
51 `:root[data-theme="dark"] .chroma `,
52 `:root[data-theme="dark"] .bg `,
53 } {
54 if !strings.Contains(css, want) {
55 t.Errorf("lacks %s", want)
56 }
57 }
58 if strings.Contains(css, "lnlinks") {
59 t.Error("chroma's LineLink rule is served")
60 }
61 if strings.Contains(css, "\n.chroma .k ") {
62 t.Error("an unscoped palette rule is served")
63 }
64}
internal/httpd/web.go +34 −22
@@ -70,7 +70,7 @@ func (s *Server) siteName() string {
7070// changes the bytes (#132).
7171var stylesheetETag = func() string {
7272 h := sha256.New()
73 h.Write(web.StyleCSS)
73 h.Write(styleCSS)
7474 h.Write(chromaCSS)
7575 return `"` + hex.EncodeToString(h.Sum(nil))[:16] + `"`
7676}()
@@ -83,7 +83,7 @@ func (s *Server) stylesheet(w http.ResponseWriter, r *http.Request) {
8383 return
8484 }
8585 w.Header().Set("Content-Type", "text/css; charset=utf-8")
86 w.Write(web.StyleCSS)
86 w.Write(styleCSS)
8787 w.Write(chromaCSS)
8888}
8989
@@ -953,9 +953,9 @@ func highlightWith(formatter *html.Formatter, filePath string, data []byte) temp
953953 }
954954 var buf bytes.Buffer
955955 if err := formatter.Format(&buf, styles.Get(lightStyle), iterator); err != nil {
956 return template.HTML("<pre>" + template.HTMLEscapeString(string(data)) + "</pre>")
956 return focusableBlocks(template.HTML("<pre>" + template.HTMLEscapeString(string(data)) + "</pre>"))
957957 }
958 return template.HTML(buf.String())
958 return focusableBlocks(template.HTML(buf.String()))
959959}
960960
961961// chromaCSS is both syntax palettes, each scoped to the scheme it is for.
@@ -974,21 +974,31 @@ const (
974974)
975975
976976var chromaCSS = func() []byte {
977 var buf bytes.Buffer
978 buf.WriteString("@media (prefers-color-scheme: light) {\n")
979 chromaFormatter.WriteCSS(&buf, styles.Get(lightStyle))
977 var light, dark bytes.Buffer
978 chromaFormatter.WriteCSS(&light, styles.Get(lightStyle))
980979 // xcode's NameAttribute is its one token under 4.5:1 against the diff
981980 // tints (4.51 on additions, 4.38 on deletions); darkened it clears both.
982 buf.WriteString(".chroma .na { color: #6f5a21 }\n")
981 light.WriteString(".chroma .na { color: #6f5a21 }\n")
982 chromaFormatter.WriteCSS(&dark, styles.Get(darkStyle))
983 // Each palette applies under its media query unless the page is
984 // stamped with the other theme, and again, outside any media query,
985 // when the page is stamped with its own (#232).
986 var buf bytes.Buffer
987 buf.WriteString("@media (prefers-color-scheme: light) {\n")
988 buf.WriteString(scopeChroma(light.String(), `:root:not([data-theme="dark"])`))
983989 buf.WriteString("}\n@media (prefers-color-scheme: dark) {\n")
984 chromaFormatter.WriteCSS(&buf, styles.Get(darkStyle))
985 buf.WriteString("}\n.chroma, .bg { background: transparent !important; }\n")
990 buf.WriteString(scopeChroma(dark.String(), `:root:not([data-theme="light"])`))
991 buf.WriteString("}\n")
992 buf.WriteString(scopeChroma(light.String(), `:root[data-theme="light"]`))
993 buf.WriteString(scopeChroma(dark.String(), `:root[data-theme="dark"]`))
994 buf.WriteString(".chroma, .bg { background: transparent !important; }\n")
986995 // Line numbers take the site's own gutter colour in both schemes. Left
987996 // alone they are github-dark's #6e7681 (4.31:1 on the page) in dark and
988997 // chroma's built-in #7f7f7f (3.67:1 on a code block) in light — the
989998 // latter is a formatter fallback, not a style entry, so no palette test
990 // can see it.
991 buf.WriteString(".chroma .lnt, .chroma .ln { color: var(--muted) }\n")
999 // can see it. !important because the scoped palette rules above outrank
1000 // a bare .chroma .ln.
1001 buf.WriteString(".chroma .lnt, .chroma .ln { color: var(--muted) !important }\n")
9921002 return buf.Bytes()
9931003}()
9941004
@@ -1088,9 +1098,9 @@ func mdHTML(raw string) template.HTML {
10881098 }
10891099 var buf bytes.Buffer
10901100 if markdown.Convert([]byte(raw), &buf) != nil {
1091 return template.HTML("<pre>" + template.HTMLEscapeString(raw) + "</pre>")
1101 return focusableBlocks(template.HTML("<pre>" + template.HTMLEscapeString(raw) + "</pre>"))
10921102 }
1093 return template.HTML(buf.String())
1103 return focusableBlocks(template.HTML(buf.String()))
10941104}
10951105
10961106// aboutHTML renders a profile's about text. It has no filename to
@@ -1163,9 +1173,9 @@ type ugcRenderer func(raw, format string) template.HTML
11631173// rather than growing a second org renderer to keep in step.
11641174func ugcHTML(raw, format string) template.HTML {
11651175 if format == "org" {
1166 return renderOrg("body.org", []byte(raw), false, func() template.HTML {
1176 return focusableBlocks(renderOrg("body.org", []byte(raw), false, func() template.HTML {
11671177 return template.HTML("<pre>" + template.HTMLEscapeString(raw) + "</pre>")
1168 })
1178 }))
11691179 }
11701180 return mdHTML(raw)
11711181}
@@ -1265,7 +1275,7 @@ func renderOrg(name string, raw []byte, contents bool, fallback func() template.
12651275 if err != nil {
12661276 return fallback()
12671277 }
1268 return template.HTML(ugcPolicy.Sanitize(out))
1278 return imageAlt(template.HTML(ugcPolicy.Sanitize(out)))
12691279}
12701280
12711281// orgWriter overrides go-org's autolink rendering. go-org ends a bare URL
@@ -1332,20 +1342,22 @@ func renderReadme(name string, raw []byte) template.HTML {
13321342 if gitutil.IsBinary(raw) {
13331343 return ""
13341344 }
1345 var out template.HTML
13351346 switch path.Ext(strings.ToLower(name)) {
13361347 case ".md", ".markdown":
13371348 var buf bytes.Buffer
13381349 if markdown.Convert(raw, &buf) != nil {
1339 return plain()
1350 return focusableBlocks(plain())
13401351 }
1341 return demoteHeadings(template.HTML(buf.String()))
1352 out = demoteHeadings(template.HTML(buf.String()))
13421353 case ".org":
1343 return demoteHeadings(renderOrg(name, raw, true, plain))
1354 out = demoteHeadings(renderOrg(name, raw, true, plain))
13441355 case ".html", ".htm":
1345 return template.HTML(ugcPolicy.Sanitize(string(raw)))
1356 out = template.HTML(ugcPolicy.Sanitize(string(raw)))
13461357 default:
1347 return plain()
1358 out = plain()
13481359 }
1360 return focusableBlocks(out)
13491361}
13501362
13511363type diffThread struct {
internal/store/migrations/0057_users_theme.down.sql added +1
@@ -0,0 +1 @@
1ALTER TABLE users DROP COLUMN theme;
internal/store/migrations/0057_users_theme.up.sql added +3
@@ -0,0 +1,3 @@
1-- The web colour scheme the account asked for. system follows the
2-- browser's prefers-color-scheme; light and dark override it (#232).
3ALTER TABLE users ADD COLUMN theme TEXT NOT NULL DEFAULT 'system';
internal/store/theme_test.go added +27
@@ -0,0 +1,27 @@
1package store
2
3import "testing"
4
5// A new account follows the system scheme; a set value reads back.
6func TestTheme(t *testing.T) {
7 s := open(t)
8 if err := s.MigrateUp(); err != nil {
9 t.Fatal(err)
10 }
11 uid, err := s.CreateUser("cmc", false)
12 if err != nil {
13 t.Fatal(err)
14 }
15 if got, err := s.Theme(uid); err != nil || got != "system" {
16 t.Fatalf("default theme: %q %v", got, err)
17 }
18 if err := s.SetTheme(uid, "dark"); err != nil {
19 t.Fatal(err)
20 }
21 if got, _ := s.Theme(uid); got != "dark" {
22 t.Fatalf("theme after set: %q", got)
23 }
24 if _, err := s.Theme(uid + 1); err != ErrNotFound {
25 t.Fatalf("missing user: %v", err)
26 }
27}
internal/store/users.go +16
@@ -237,6 +237,22 @@ func (s *Store) SetWatchEnabled(userID int64, on bool) error {
237237 return err
238238}
239239
240// Theme is the web colour scheme the account chose: system, light or
241// dark (#232).
242func (s *Store) Theme(userID int64) (string, error) {
243 var theme string
244 err := s.DB.QueryRow("SELECT theme FROM users WHERE id = ?", userID).Scan(&theme)
245 if errors.Is(err, sql.ErrNoRows) {
246 return "", ErrNotFound
247 }
248 return theme, err
249}
250
251func (s *Store) SetTheme(userID int64, theme string) error {
252 _, err := s.DB.Exec("UPDATE users SET theme = ? WHERE id = ?", theme, userID)
253 return err
254}
255
240256func (s *Store) UserByID(id int64) (User, error) {
241257 var u User
242258 var admin, pending, disabled int
internal/web/static/style.css +19 −3
@@ -209,6 +209,12 @@ h3 { font-size: var(--fs-3); margin: var(--sp-5) 0 var(--sp-2); }
209209p { margin: 0 0 var(--sp-3); }
210210a { color: var(--link); text-decoration: none; }
211211a:hover { text-decoration: underline; }
212/* a link inside running text is told apart by its underline: link and
213 muted text are 1.07:1 apart in dark, and colour alone is not a cue
214 (#232). Navigation, chips and buttons keep position as their cue. */
215p a, .syscomment a { text-decoration: underline; }
216/* a title or a path is the whole line: position is the cue */
217p a.button, p a.btn, p a.chip, .repotitle a, p.title a, .pathbar a, .pager a { text-decoration: none; }
212218a.xref { color: var(--link); text-decoration: none; }
213219
214220code, pre, .mono, .code, td.mode, td.size {
@@ -919,7 +925,7 @@ ul.milestonelist .title { font-weight: 500; }
919925
920926article.release { padding: var(--sp-4); margin: var(--sp-4) 0; }
921927.releasehead { background: none; border: 0; padding: 0; }
922.releasehead h3 { margin: 0 0 var(--sp-1); }
928.releasehead h2 { margin: 0 0 var(--sp-1); font-size: var(--fs-3); }
923929.releasehead .meta { margin: 0 0 var(--sp-2); }
924930
925931ul.matchlist { list-style: none; margin: var(--sp-3) 0; padding: 0; }
@@ -1188,6 +1194,11 @@ table.difftable td.ln a.cmt:focus { color: var(--link); text-decoration: underli
11881194/* chroma writes the highlighted spans; the container only supplies the
11891195 ground and the face */
11901196.chroma { font-family: var(--mono); background: none; }
1197/* chroma's own LineLink rule is dropped from the served palette: it set
1198 outline: none and hid the focus ring. This is that rule without it, on
1199 a 24px box so a line number is a target and not a glyph (#232) */
1200.chroma .lnlinks { display: inline-block; min-width: 24px; min-height: 24px; line-height: 24px; text-decoration: none; color: inherit; }
1201.chroma .line { line-height: 24px; }
11911202
11921203.blamehunk { display: flex; gap: var(--sp-3); border-top: 1px solid var(--line); align-items: flex-start; }
11931204.blamehunk:first-child { border-top: 0; }
@@ -1268,6 +1279,8 @@ a.memberchip .role { color: var(--muted); }
12681279.pathbar { display: flex; align-items: center; gap: var(--sp-2) var(--sp-3); margin: 0 0 var(--sp-3); flex-wrap: wrap; font-size: var(--fs-2); }
12691280.pathbar .spacer { flex: 1; }
12701281.pathbar .actions { display: flex; gap: var(--sp-2); }
1282/* history · blame · raw were 21px tall and "raw" 22px wide (#232) */
1283.pathbar .actions a { display: inline-block; padding: 6px 4px; }
12711284.pathbar .acts { display: flex; gap: var(--sp-2); }
12721285.crumbs { color: var(--muted); }
12731286.crumbs a { color: var(--link); }
@@ -1460,9 +1473,9 @@ svg.icon { vertical-align: -0.125em; }
14601473
14611474/* ---- breakpoints ---- */
14621475@media (max-width: 62rem) {
1476 /* one column, the aside after the thread: a phone shows the
1477 description first, checks and reviewers after it (#232) */
14631478 .withaside { grid-template-columns: 1fr; }
1464 /* checks and reviewers before a long thread */
1465 .withaside .aside { order: -1; }
14661479}
14671480
14681481@media (max-width: 52rem) {
@@ -1486,6 +1499,9 @@ svg.icon { vertical-align: -0.125em; }
14861499 latest commit */
14871500 table.tree td.lastcommit, table.tree th.lastcommit { display: none; }
14881501 table.tree td.mode, table.tree th.mode { display: none; }
1502 /* at 320px a nowrap name plus the age column overran the card by the
1503 cell padding and the card clipped the age; a long name wraps (#232) */
1504 table.tree td.name { white-space: normal; overflow-wrap: anywhere; }
14891505 .clone pre { white-space: pre-wrap; word-break: break-all; }
14901506 .pathbar { overflow-wrap: anywhere; }
14911507 .readme .cardbody, .code { padding: var(--sp-3); }
internal/web/templates/account.html +13
@@ -118,6 +118,19 @@ account, and where notifications go.</p>
118118</form>
119119<p class="meta">Every issue and merge request on those repositories, as if you had watched each. A watch or mute on a repository still wins.</p>
120120
121<h2>Appearance</h2>
122<form method="post" action="/settings" class="setform">
123 <input type="hidden" name="field" value="theme">
124 <label for="theme">Colour scheme</label>
125 <select id="theme" name="theme">
126 <option value="system"{{if eq .ThemeSetting "system"}} selected{{end}}>follow the browser</option>
127 <option value="light"{{if eq .ThemeSetting "light"}} selected{{end}}>light</option>
128 <option value="dark"{{if eq .ThemeSetting "dark"}} selected{{end}}>dark</option>
129 </select>
130 <button type="submit" class="btn">Save</button>
131</form>
132<p class="meta">The same setting as <code>gitbay web theme set</code>. Following the browser takes its light or dark preference.</p>
133
121134<h2>Export</h2>
122135<p class="meta">Your profile, repositories, issues and merge requests as one
123136JSON bundle, the same one <code>gitbay account export</code> writes. Keys are
internal/web/templates/landing.html +1 −1
@@ -4,7 +4,7 @@
44<div class="landing">
55<h1>{{template "mark"}}{{.Site}}</h1>
66<p class="lede">A git forge you drive from the terminal. Repositories, issues, merge requests and CI over SSH, with a fast, readable web view of the same state.</p>
7<pre class="quickstart">ssh git@{{.Host}} help # every command, no client to install
7<pre class="quickstart" tabindex="0">ssh git@{{.Host}} help # every command, no client to install
88git clone ssh://git@{{.Host}}/owner/repo.git</pre>
99<div class="routes"><a class="button primary" href="/explore">Explore repositories</a>{{if .Signup}}<a class="button btn" href="/register">Create an account</a>{{end}}</div>
1010<div class="shot"><picture>
internal/web/templates/layout.html +1 −1
@@ -1,5 +1,5 @@
11{{define "layout"}}<!DOCTYPE html>
2<html lang="en">
2<html lang="en"{{with field . "Theme"}} data-theme="{{.}}"{{end}}>
33<head>
44<meta charset="utf-8">
55<meta name="viewport" content="width=device-width, initial-scale=1">
internal/web/templates/releases.html +1 −1
@@ -20,7 +20,7 @@
2020{{range $rel := .Releases}}
2121<article class="release">
2222 <header class="releasehead">
23 <h3>{{$rel.Title}}</h3>
23 <h2>{{$rel.Title}}</h2>
2424 <p class="meta"><span class="refchip">{{$rel.Tag}}</span> {{if $rel.Author}}{{$rel.Author}} · {{end}}{{when $rel.CreatedAt}} · <a href="/{{$.Repo.OwnerName}}/{{$.Repo.Name}}/archive/{{$rel.Tag}}.tar.gz">source tar.gz</a></p>
2525 </header>
2626 {{if $rel.NotesHTML}}<div class="rendered">{{$rel.NotesHTML}}</div>{{end}}