Commit fd81fb5899
Verified · cmc ci/build: success ci/test: failure
.gitbay/wiki/Parity.org +1
| @@ -308,6 +308,7 @@ client has no use for one (krz/gitbay#57). | ||
| 308 | 308 | | notification inbox | yes | yes | yes | |
| 309 | 309 | | activity mail on, off | yes | yes | yes | |
| 310 | 310 | | watch writable repos | yes | yes | yes | |
| 311 | | web colour scheme | yes | yes | n/a | | |
| 311 | 312 | | API token mint | yes | no | no | |
| 312 | 313 | | account export bundle | yes | yes | n/a | |
| 313 | 314 | | 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>= | ||
| 653 | 653 | or =--all= ends them from the terminal, which is where a lost laptop is |
| 654 | 654 | handled. |
| 655 | 655 | |
| 656 | =web theme set light= or =dark= fixes the web UI's colour scheme for | |
| 657 | your account; =system=, the default, follows the browser's own | |
| 658 | preference. =web theme show= prints it. The account page has the same | |
| 659 | control under Appearance. | |
| 660 | ||
| 656 | 661 | * Notifications |
| 657 | 662 | |
| 658 | 663 | When 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 | ||
| 4 | 4 | automatically on daemon start; upgrade notes appear per release when |
| 5 | 5 | anything beyond "replace the binary and restart" is needed. |
| 6 | 6 | |
| 7 | * v1.30.0 — unreleased | |
| 8 | ||
| 9 | The 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 | ||
| 7 | 32 | * v1.29.0 — 2026-09-19 |
| 8 | 33 | |
| 9 | 34 | The icon nav is a top bar at every width. |
cmd/gitbay/main.go +4
| @@ -647,6 +647,10 @@ func webCmd() *cobra.Command { | ||
| 647 | 647 | pass("list", "list your browser sessions", passOpts{server: []string{"web", "sessions", "list"}}), |
| 648 | 648 | pass("revoke", "end a browser session: <id>|--all", passOpts{server: []string{"web", "sessions", "revoke"}}), |
| 649 | 649 | ), |
| 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 | ), | |
| 650 | 654 | ) |
| 651 | 655 | } |
| 652 | 656 | |
e2e/theme_test.go added +44
| @@ -0,0 +1,44 @@ | ||
| 1 | package e2e | |
| 2 | ||
| 3 | import ( | |
| 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). | |
| 11 | func 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 @@ | ||
| 1 | package control | |
| 2 | ||
| 3 | import ( | |
| 4 | "fmt" | |
| 5 | "io" | |
| 6 | ||
| 7 | "gitbay.org/gitbay/internal/protocol" | |
| 8 | ) | |
| 9 | ||
| 10 | func 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. | |
| 22 | var themes = map[string]bool{"system": true, "light": true, "dark": true} | |
| 23 | ||
| 24 | func runWebThemeShow(c *Ctx, args []string) int { | |
| 25 | if len(args) != 0 { | |
| 26 | return c.usage() | |
| 27 | } | |
| 28 | return emitTheme(c) | |
| 29 | } | |
| 30 | ||
| 31 | func 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 | ||
| 41 | func 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 @@ | ||
| 1 | package control | |
| 2 | ||
| 3 | import ( | |
| 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. | |
| 15 | func 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 | ||
| 59 | 59 | s.runControlInto(u, []string{"profile", "show"}, &profile) |
| 60 | 60 | mailOn, _ := s.st.MailEnabled(u.ID) |
| 61 | 61 | watchOn, _ := s.st.WatchEnabled(u.ID) |
| 62 | theme, _ := s.st.Theme(u.ID) | |
| 62 | 63 | |
| 63 | 64 | s.render(w, "account.html", struct { |
| 64 | 65 | 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 | |
| 76 | 78 | }{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}) | |
| 78 | 80 | } |
| 79 | 81 | |
| 80 | 82 | // 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 | ||
| 219 | 221 | return |
| 220 | 222 | } |
| 221 | 223 | 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") | |
| 222 | 230 | case "notify-mail", "notify-watch": |
| 223 | 231 | pref := strings.TrimPrefix(r.FormValue("field"), "notify-") |
| 224 | 232 | state := "off" |
internal/httpd/compress.go added +85
| @@ -0,0 +1,85 @@ | ||
| 1 | package httpd | |
| 2 | ||
| 3 | import ( | |
| 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. | |
| 14 | func 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 | ||
| 26 | var compressibleTypes = []string{"text/", "application/json", "application/atom+xml", "image/svg+xml"} | |
| 27 | ||
| 28 | func 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. | |
| 39 | type gzipWriter struct { | |
| 40 | http.ResponseWriter | |
| 41 | gz *gzip.Writer | |
| 42 | decided bool | |
| 43 | } | |
| 44 | ||
| 45 | func (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 | ||
| 61 | func (g *gzipWriter) WriteHeader(status int) { | |
| 62 | g.decide(status) | |
| 63 | g.ResponseWriter.WriteHeader(status) | |
| 64 | } | |
| 65 | ||
| 66 | func (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 | ||
| 81 | func (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 @@ | ||
| 1 | package httpd | |
| 2 | ||
| 3 | import ( | |
| 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 | ||
| 14 | func plainServer() *Server { | |
| 15 | cfg := config.Default() | |
| 16 | cfg.Server.SiteURL = "https://forge.test/" | |
| 17 | return &Server{cfg: cfg} | |
| 18 | } | |
| 19 | ||
| 20 | func 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). | |
| 34 | func 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. | |
| 67 | func 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 @@ | ||
| 1 | package httpd | |
| 2 | ||
| 3 | import ( | |
| 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). | |
| 10 | func 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) { | ||
| 107 | 107 | // The source block is chroma-highlighted, so its text is split across spans; |
| 108 | 108 | // check the block and a token rather than the joined source line. |
| 109 | 109 | for _, want := range []string{"Heading", "<em>emphasis</em>", "<code>code</code>", |
| 110 | `<pre class="chroma">`, "Println"} { | |
| 110 | `<pre tabindex="0" class="chroma">`, "Println"} { | |
| 111 | 111 | if !strings.Contains(out, want) { |
| 112 | 112 | t.Errorf("expected %q in output:\n%s", want, out) |
| 113 | 113 | } |
internal/httpd/page.go +6
| @@ -31,6 +31,9 @@ type basePage struct { | ||
| 31 | 31 | Viewer string |
| 32 | 32 | Admin bool // the viewer is an instance admin: the rail shows /admin |
| 33 | 33 | 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 | |
| 34 | 37 | } |
| 35 | 38 | |
| 36 | 39 | // 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 { | ||
| 52 | 55 | b.Viewer = viewer.Username |
| 53 | 56 | b.Admin = viewer.IsAdmin |
| 54 | 57 | b.Rail = s.railFor(viewer) |
| 58 | if theme, err := s.st.Theme(viewer.ID); err == nil && theme != "system" { | |
| 59 | b.Theme = theme | |
| 60 | } | |
| 55 | 61 | return b |
| 56 | 62 | } |
| 57 | 63 | |
internal/httpd/rendered.go added +51
| @@ -0,0 +1,51 @@ | ||
| 1 | package httpd | |
| 2 | ||
| 3 | import ( | |
| 4 | "html/template" | |
| 5 | "path" | |
| 6 | "regexp" | |
| 7 | "strings" | |
| 8 | ) | |
| 9 | ||
| 10 | var ( | |
| 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). | |
| 19 | func 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. | |
| 32 | func 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 @@ | ||
| 1 | package httpd | |
| 2 | ||
| 3 | import ( | |
| 4 | "html/template" | |
| 5 | "strings" | |
| 6 | "testing" | |
| 7 | ) | |
| 8 | ||
| 9 | func 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 | ||
| 17 | func 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. | |
| 34 | func 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 { | ||
| 230 | 230 | for _, r := range s.Routes() { |
| 231 | 231 | mux.HandleFunc(r.Method+" "+r.Pattern, r.Handler) |
| 232 | 232 | } |
| 233 | // A path no pattern matches gets the 404 page, not net/http's | |
| 234 | // plain-text body (#232). | |
| 235 | mux.HandleFunc("/", s.notFound) | |
| 233 | 236 | var h http.Handler = mux |
| 234 | 237 | if len(s.cfg.GoImport) > 0 { |
| 235 | 238 | h = s.goImportHandler(mux) |
| 236 | 239 | } |
| 237 | 240 | // Always wrapped: custom pages domains work with or without the |
| 238 | 241 | // built-in [pages] domain. |
| 239 | return s.pagesRouter(s.securityHeaders(h)) | |
| 242 | return compressed(s.pagesRouter(s.securityHeaders(h))) | |
| 240 | 243 | } |
| 241 | 244 | |
| 242 | 245 | // securityHeaders sets defensive response headers on every reply. The CSP |
internal/httpd/theme.go added +61
| @@ -0,0 +1,61 @@ | ||
| 1 | package httpd | |
| 2 | ||
| 3 | import ( | |
| 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). | |
| 15 | func 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. | |
| 42 | var 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. | |
| 46 | var 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. | |
| 52 | func 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 @@ | ||
| 1 | package httpd | |
| 2 | ||
| 3 | import ( | |
| 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). | |
| 12 | func 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. | |
| 45 | func 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 { | ||
| 70 | 70 | // changes the bytes (#132). |
| 71 | 71 | var stylesheetETag = func() string { |
| 72 | 72 | h := sha256.New() |
| 73 | h.Write(web.StyleCSS) | |
| 73 | h.Write(styleCSS) | |
| 74 | 74 | h.Write(chromaCSS) |
| 75 | 75 | return `"` + hex.EncodeToString(h.Sum(nil))[:16] + `"` |
| 76 | 76 | }() |
| @@ -83,7 +83,7 @@ func (s *Server) stylesheet(w http.ResponseWriter, r *http.Request) { | ||
| 83 | 83 | return |
| 84 | 84 | } |
| 85 | 85 | w.Header().Set("Content-Type", "text/css; charset=utf-8") |
| 86 | w.Write(web.StyleCSS) | |
| 86 | w.Write(styleCSS) | |
| 87 | 87 | w.Write(chromaCSS) |
| 88 | 88 | } |
| 89 | 89 | |
| @@ -953,9 +953,9 @@ func highlightWith(formatter *html.Formatter, filePath string, data []byte) temp | ||
| 953 | 953 | } |
| 954 | 954 | var buf bytes.Buffer |
| 955 | 955 | 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>")) | |
| 957 | 957 | } |
| 958 | return template.HTML(buf.String()) | |
| 958 | return focusableBlocks(template.HTML(buf.String())) | |
| 959 | 959 | } |
| 960 | 960 | |
| 961 | 961 | // chromaCSS is both syntax palettes, each scoped to the scheme it is for. |
| @@ -974,21 +974,31 @@ const ( | ||
| 974 | 974 | ) |
| 975 | 975 | |
| 976 | 976 | var 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)) | |
| 980 | 979 | // xcode's NameAttribute is its one token under 4.5:1 against the diff |
| 981 | 980 | // 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"])`)) | |
| 983 | 989 | 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") | |
| 986 | 995 | // Line numbers take the site's own gutter colour in both schemes. Left |
| 987 | 996 | // alone they are github-dark's #6e7681 (4.31:1 on the page) in dark and |
| 988 | 997 | // chroma's built-in #7f7f7f (3.67:1 on a code block) in light — the |
| 989 | 998 | // 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") | |
| 992 | 1002 | return buf.Bytes() |
| 993 | 1003 | }() |
| 994 | 1004 | |
| @@ -1088,9 +1098,9 @@ func mdHTML(raw string) template.HTML { | ||
| 1088 | 1098 | } |
| 1089 | 1099 | var buf bytes.Buffer |
| 1090 | 1100 | 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>")) | |
| 1092 | 1102 | } |
| 1093 | return template.HTML(buf.String()) | |
| 1103 | return focusableBlocks(template.HTML(buf.String())) | |
| 1094 | 1104 | } |
| 1095 | 1105 | |
| 1096 | 1106 | // aboutHTML renders a profile's about text. It has no filename to |
| @@ -1163,9 +1173,9 @@ type ugcRenderer func(raw, format string) template.HTML | ||
| 1163 | 1173 | // rather than growing a second org renderer to keep in step. |
| 1164 | 1174 | func ugcHTML(raw, format string) template.HTML { |
| 1165 | 1175 | 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 { | |
| 1167 | 1177 | return template.HTML("<pre>" + template.HTMLEscapeString(raw) + "</pre>") |
| 1168 | }) | |
| 1178 | })) | |
| 1169 | 1179 | } |
| 1170 | 1180 | return mdHTML(raw) |
| 1171 | 1181 | } |
| @@ -1265,7 +1275,7 @@ func renderOrg(name string, raw []byte, contents bool, fallback func() template. | ||
| 1265 | 1275 | if err != nil { |
| 1266 | 1276 | return fallback() |
| 1267 | 1277 | } |
| 1268 | return template.HTML(ugcPolicy.Sanitize(out)) | |
| 1278 | return imageAlt(template.HTML(ugcPolicy.Sanitize(out))) | |
| 1269 | 1279 | } |
| 1270 | 1280 | |
| 1271 | 1281 | // 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 { | ||
| 1332 | 1342 | if gitutil.IsBinary(raw) { |
| 1333 | 1343 | return "" |
| 1334 | 1344 | } |
| 1345 | var out template.HTML | |
| 1335 | 1346 | switch path.Ext(strings.ToLower(name)) { |
| 1336 | 1347 | case ".md", ".markdown": |
| 1337 | 1348 | var buf bytes.Buffer |
| 1338 | 1349 | if markdown.Convert(raw, &buf) != nil { |
| 1339 | return plain() | |
| 1350 | return focusableBlocks(plain()) | |
| 1340 | 1351 | } |
| 1341 | return demoteHeadings(template.HTML(buf.String())) | |
| 1352 | out = demoteHeadings(template.HTML(buf.String())) | |
| 1342 | 1353 | case ".org": |
| 1343 | return demoteHeadings(renderOrg(name, raw, true, plain)) | |
| 1354 | out = demoteHeadings(renderOrg(name, raw, true, plain)) | |
| 1344 | 1355 | case ".html", ".htm": |
| 1345 | return template.HTML(ugcPolicy.Sanitize(string(raw))) | |
| 1356 | out = template.HTML(ugcPolicy.Sanitize(string(raw))) | |
| 1346 | 1357 | default: |
| 1347 | return plain() | |
| 1358 | out = plain() | |
| 1348 | 1359 | } |
| 1360 | return focusableBlocks(out) | |
| 1349 | 1361 | } |
| 1350 | 1362 | |
| 1351 | 1363 | type diffThread struct { |
internal/store/migrations/0057_users_theme.down.sql added +1
| @@ -0,0 +1 @@ | ||
| 1 | ALTER 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). | |
| 3 | ALTER TABLE users ADD COLUMN theme TEXT NOT NULL DEFAULT 'system'; | |
internal/store/theme_test.go added +27
| @@ -0,0 +1,27 @@ | ||
| 1 | package store | |
| 2 | ||
| 3 | import "testing" | |
| 4 | ||
| 5 | // A new account follows the system scheme; a set value reads back. | |
| 6 | func 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 { | ||
| 237 | 237 | return err |
| 238 | 238 | } |
| 239 | 239 | |
| 240 | // Theme is the web colour scheme the account chose: system, light or | |
| 241 | // dark (#232). | |
| 242 | func (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 | ||
| 251 | func (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 | ||
| 240 | 256 | func (s *Store) UserByID(id int64) (User, error) { |
| 241 | 257 | var u User |
| 242 | 258 | 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); } | ||
| 209 | 209 | p { margin: 0 0 var(--sp-3); } |
| 210 | 210 | a { color: var(--link); text-decoration: none; } |
| 211 | 211 | a: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. */ | |
| 215 | p a, .syscomment a { text-decoration: underline; } | |
| 216 | /* a title or a path is the whole line: position is the cue */ | |
| 217 | p a.button, p a.btn, p a.chip, .repotitle a, p.title a, .pathbar a, .pager a { text-decoration: none; } | |
| 212 | 218 | a.xref { color: var(--link); text-decoration: none; } |
| 213 | 219 | |
| 214 | 220 | code, pre, .mono, .code, td.mode, td.size { |
| @@ -919,7 +925,7 @@ ul.milestonelist .title { font-weight: 500; } | ||
| 919 | 925 | |
| 920 | 926 | article.release { padding: var(--sp-4); margin: var(--sp-4) 0; } |
| 921 | 927 | .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); } | |
| 923 | 929 | .releasehead .meta { margin: 0 0 var(--sp-2); } |
| 924 | 930 | |
| 925 | 931 | ul.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 | ||
| 1188 | 1194 | /* chroma writes the highlighted spans; the container only supplies the |
| 1189 | 1195 | ground and the face */ |
| 1190 | 1196 | .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; } | |
| 1191 | 1202 | |
| 1192 | 1203 | .blamehunk { display: flex; gap: var(--sp-3); border-top: 1px solid var(--line); align-items: flex-start; } |
| 1193 | 1204 | .blamehunk:first-child { border-top: 0; } |
| @@ -1268,6 +1279,8 @@ a.memberchip .role { color: var(--muted); } | ||
| 1268 | 1279 | .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); } |
| 1269 | 1280 | .pathbar .spacer { flex: 1; } |
| 1270 | 1281 | .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; } | |
| 1271 | 1284 | .pathbar .acts { display: flex; gap: var(--sp-2); } |
| 1272 | 1285 | .crumbs { color: var(--muted); } |
| 1273 | 1286 | .crumbs a { color: var(--link); } |
| @@ -1460,9 +1473,9 @@ svg.icon { vertical-align: -0.125em; } | ||
| 1460 | 1473 | |
| 1461 | 1474 | /* ---- breakpoints ---- */ |
| 1462 | 1475 | @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) */ | |
| 1463 | 1478 | .withaside { grid-template-columns: 1fr; } |
| 1464 | /* checks and reviewers before a long thread */ | |
| 1465 | .withaside .aside { order: -1; } | |
| 1466 | 1479 | } |
| 1467 | 1480 | |
| 1468 | 1481 | @media (max-width: 52rem) { |
| @@ -1486,6 +1499,9 @@ svg.icon { vertical-align: -0.125em; } | ||
| 1486 | 1499 | latest commit */ |
| 1487 | 1500 | table.tree td.lastcommit, table.tree th.lastcommit { display: none; } |
| 1488 | 1501 | 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; } | |
| 1489 | 1505 | .clone pre { white-space: pre-wrap; word-break: break-all; } |
| 1490 | 1506 | .pathbar { overflow-wrap: anywhere; } |
| 1491 | 1507 | .readme .cardbody, .code { padding: var(--sp-3); } |
internal/web/templates/account.html +13
| @@ -118,6 +118,19 @@ account, and where notifications go.</p> | ||
| 118 | 118 | </form> |
| 119 | 119 | <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> |
| 120 | 120 | |
| 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 | ||
| 121 | 134 | <h2>Export</h2> |
| 122 | 135 | <p class="meta">Your profile, repositories, issues and merge requests as one |
| 123 | 136 | JSON bundle, the same one <code>gitbay account export</code> writes. Keys are |
internal/web/templates/landing.html +1 −1
| @@ -4,7 +4,7 @@ | ||
| 4 | 4 | <div class="landing"> |
| 5 | 5 | <h1>{{template "mark"}}{{.Site}}</h1> |
| 6 | 6 | <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 | |
| 8 | 8 | git clone ssh://git@{{.Host}}/owner/repo.git</pre> |
| 9 | 9 | <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> |
| 10 | 10 | <div class="shot"><picture> |
internal/web/templates/layout.html +1 −1
| @@ -1,5 +1,5 @@ | ||
| 1 | 1 | {{define "layout"}}<!DOCTYPE html> |
| 2 | <html lang="en"> | |
| 2 | <html lang="en"{{with field . "Theme"}} data-theme="{{.}}"{{end}}> | |
| 3 | 3 | <head> |
| 4 | 4 | <meta charset="utf-8"> |
| 5 | 5 | <meta name="viewport" content="width=device-width, initial-scale=1"> |
internal/web/templates/releases.html +1 −1
| @@ -20,7 +20,7 @@ | ||
| 20 | 20 | {{range $rel := .Releases}} |
| 21 | 21 | <article class="release"> |
| 22 | 22 | <header class="releasehead"> |
| 23 | <h3>{{$rel.Title}}</h3> | |
| 23 | <h2>{{$rel.Title}}</h2> | |
| 24 | 24 | <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> |
| 25 | 25 | </header> |
| 26 | 26 | {{if $rel.NotesHTML}}<div class="rendered">{{$rel.NotesHTML}}</div>{{end}} |