web: the ten findings from the forge comparison !430
29 files changed, +724 −42
Layout: unified · split
.gitbay/wiki/Parity.org +1
| @@ -308,6 +308,7 @@ client has no use for one (krz/gitbay#57). | |||
| 308 | | notification inbox | yes | yes | yes | | 308 | | notification inbox | yes | yes | yes | |
| 309 | | activity mail on, off | yes | yes | yes | | 309 | | activity mail on, off | yes | yes | yes | |
| 310 | | watch writable repos | yes | yes | yes | | 310 | | watch writable repos | yes | yes | yes | |
| 311 | | web colour scheme | yes | yes | n/a | | ||
| 311 | | API token mint | yes | no | no | | 312 | | API token mint | yes | no | no | |
| 312 | | account export bundle | yes | yes | n/a | | 313 | | account export bundle | yes | yes | n/a | |
| 313 | | profile set | yes | yes | yes | | 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 | or =--all= ends them from the terminal, which is where a lost laptop is | 653 | or =--all= ends them from the terminal, which is where a lost laptop is |
| 654 | handled. | 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 | * Notifications | 661 | * Notifications |
| 657 | 662 | ||
| 658 | When the instance has SMTP configured, activity mails you as well as | 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 | automatically on daemon start; upgrade notes appear per release when | 4 | automatically on daemon start; upgrade notes appear per release when |
| 5 | anything beyond "replace the binary and restart" is needed. | 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 | * v1.29.0 — 2026-09-19 | 32 | * v1.29.0 — 2026-09-19 |
| 8 | 33 | ||
| 9 | The icon nav is a top bar at every width. | 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 | pass("list", "list your browser sessions", passOpts{server: []string{"web", "sessions", "list"}}), | 647 | pass("list", "list your browser sessions", passOpts{server: []string{"web", "sessions", "list"}}), |
| 648 | pass("revoke", "end a browser session: <id>|--all", passOpts{server: []string{"web", "sessions", "revoke"}}), | 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/readonly_test.go +1
| @@ -97,6 +97,7 @@ func TestReadOnlyCommandsWriteNothing(t *testing.T) { | |||
| 97 | "pgp list": {}, | 97 | "pgp list": {}, |
| 98 | "token list": {}, | 98 | "token list": {}, |
| 99 | "web sessions list": {}, | 99 | "web sessions list": {}, |
| 100 | "web theme show": {}, | ||
| 100 | "account export": {}, | 101 | "account export": {}, |
| 101 | "org list": {}, | 102 | "org list": {}, |
| 102 | "repo list": {}, | 103 | "repo list": {}, |
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 | s.runControlInto(u, []string{"profile", "show"}, &profile) | 59 | s.runControlInto(u, []string{"profile", "show"}, &profile) |
| 60 | mailOn, _ := s.st.MailEnabled(u.ID) | 60 | mailOn, _ := s.st.MailEnabled(u.ID) |
| 61 | watchOn, _ := s.st.WatchEnabled(u.ID) | 61 | watchOn, _ := s.st.WatchEnabled(u.ID) |
| 62 | theme, _ := s.st.Theme(u.ID) | ||
| 62 | 63 | ||
| 63 | s.render(w, "account.html", struct { | 64 | s.render(w, "account.html", struct { |
| 64 | basePage | 65 | basePage |
| 65 | Tab string // marks the rail's Settings row as current | 66 | Tab string // marks the rail's Settings row as current |
| 66 | Keys []accountKey | 67 | Keys []accountKey |
| 67 | PGP []accountPGP | 68 | PGP []accountPGP |
| 68 | Emails []store.Email | 69 | Emails []store.Email |
| 69 | Profile control.ProfileOut | 70 | Profile control.ProfileOut |
| 70 | LinksText string | 71 | LinksText string |
| 71 | Host string | 72 | Host string |
| 72 | Notice string | 73 | Notice string |
| 73 | Message string | 74 | Message string |
| 74 | MailOn bool | 75 | MailOn bool |
| 75 | WatchOn bool | 76 | WatchOn bool |
| 77 | ThemeSetting string // system, light or dark: the form's selected option | ||
| 76 | }{s.baseFor(u), "account", keys, pgp, emails, profile, profileLinksText(profile.Links), s.cfg.SiteHost(), | 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 | // accountExport hands the browser the same bundle `account export` | 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 | return | 221 | return |
| 220 | } | 222 | } |
| 221 | back("", "primary address changed") | 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 | case "notify-mail", "notify-watch": | 230 | case "notify-mail", "notify-watch": |
| 223 | pref := strings.TrimPrefix(r.FormValue("field"), "notify-") | 231 | pref := strings.TrimPrefix(r.FormValue("field"), "notify-") |
| 224 | state := "off" | 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 | // The source block is chroma-highlighted, so its text is split across spans; | 107 | // The source block is chroma-highlighted, so its text is split across spans; |
| 108 | // check the block and a token rather than the joined source line. | 108 | // check the block and a token rather than the joined source line. |
| 109 | for _, want := range []string{"Heading", "<em>emphasis</em>", "<code>code</code>", | 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 | if !strings.Contains(out, want) { | 111 | if !strings.Contains(out, want) { |
| 112 | t.Errorf("expected %q in output:\n%s", want, out) | 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 | Viewer string | 31 | Viewer string |
| 32 | Admin bool // the viewer is an instance admin: the rail shows /admin | 32 | Admin bool // the viewer is an instance admin: the rail shows /admin |
| 33 | Rail rail | 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 | // base builds the layout-wide data for a request that has not already | 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 | b.Viewer = viewer.Username | 55 | b.Viewer = viewer.Username |
| 53 | b.Admin = viewer.IsAdmin | 56 | b.Admin = viewer.IsAdmin |
| 54 | b.Rail = s.railFor(viewer) | 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 | return b | 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 | for _, r := range s.Routes() { | 230 | for _, r := range s.Routes() { |
| 231 | mux.HandleFunc(r.Method+" "+r.Pattern, r.Handler) | 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 | var h http.Handler = mux | 236 | var h http.Handler = mux |
| 234 | if len(s.cfg.GoImport) > 0 { | 237 | if len(s.cfg.GoImport) > 0 { |
| 235 | h = s.goImportHandler(mux) | 238 | h = s.goImportHandler(mux) |
| 236 | } | 239 | } |
| 237 | // Always wrapped: custom pages domains work with or without the | 240 | // Always wrapped: custom pages domains work with or without the |
| 238 | // built-in [pages] domain. | 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 | // securityHeaders sets defensive response headers on every reply. The CSP | 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 | // changes the bytes (#132). | 70 | // changes the bytes (#132). |
| 71 | var stylesheetETag = func() string { | 71 | var stylesheetETag = func() string { |
| 72 | h := sha256.New() | 72 | h := sha256.New() |
| 73 | h.Write(web.StyleCSS) | 73 | h.Write(styleCSS) |
| 74 | h.Write(chromaCSS) | 74 | h.Write(chromaCSS) |
| 75 | return `"` + hex.EncodeToString(h.Sum(nil))[:16] + `"` | 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 | return | 83 | return |
| 84 | } | 84 | } |
| 85 | w.Header().Set("Content-Type", "text/css; charset=utf-8") | 85 | w.Header().Set("Content-Type", "text/css; charset=utf-8") |
| 86 | w.Write(web.StyleCSS) | 86 | w.Write(styleCSS) |
| 87 | w.Write(chromaCSS) | 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 | var buf bytes.Buffer | 954 | var buf bytes.Buffer |
| 955 | if err := formatter.Format(&buf, styles.Get(lightStyle), iterator); err != nil { | 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 | // chromaCSS is both syntax palettes, each scoped to the scheme it is for. | 961 | // chromaCSS is both syntax palettes, each scoped to the scheme it is for. |
| @@ -974,21 +974,31 @@ const ( | |||
| 974 | ) | 974 | ) |
| 975 | 975 | ||
| 976 | var chromaCSS = func() []byte { | 976 | var chromaCSS = func() []byte { |
| 977 | var buf bytes.Buffer | 977 | var light, dark bytes.Buffer |
| 978 | buf.WriteString("@media (prefers-color-scheme: light) {\n") | 978 | chromaFormatter.WriteCSS(&light, styles.Get(lightStyle)) |
| 979 | chromaFormatter.WriteCSS(&buf, styles.Get(lightStyle)) | ||
| 980 | // xcode's NameAttribute is its one token under 4.5:1 against the diff | 979 | // xcode's NameAttribute is its one token under 4.5:1 against the diff |
| 981 | // tints (4.51 on additions, 4.38 on deletions); darkened it clears both. | 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 | buf.WriteString("}\n@media (prefers-color-scheme: dark) {\n") | 989 | buf.WriteString("}\n@media (prefers-color-scheme: dark) {\n") |
| 984 | chromaFormatter.WriteCSS(&buf, styles.Get(darkStyle)) | 990 | buf.WriteString(scopeChroma(dark.String(), `:root:not([data-theme="light"])`)) |
| 985 | buf.WriteString("}\n.chroma, .bg { background: transparent !important; }\n") | 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 | // Line numbers take the site's own gutter colour in both schemes. Left | 995 | // Line numbers take the site's own gutter colour in both schemes. Left |
| 987 | // alone they are github-dark's #6e7681 (4.31:1 on the page) in dark and | 996 | // alone they are github-dark's #6e7681 (4.31:1 on the page) in dark and |
| 988 | // chroma's built-in #7f7f7f (3.67:1 on a code block) in light — the | 997 | // chroma's built-in #7f7f7f (3.67:1 on a code block) in light — the |
| 989 | // latter is a formatter fallback, not a style entry, so no palette test | 998 | // latter is a formatter fallback, not a style entry, so no palette test |
| 990 | // can see it. | 999 | // can see it. !important because the scoped palette rules above outrank |
| 991 | buf.WriteString(".chroma .lnt, .chroma .ln { color: var(--muted) }\n") | 1000 | // a bare .chroma .ln. |
| 1001 | buf.WriteString(".chroma .lnt, .chroma .ln { color: var(--muted) !important }\n") | ||
| 992 | return buf.Bytes() | 1002 | return buf.Bytes() |
| 993 | }() | 1003 | }() |
| 994 | 1004 | ||
| @@ -1088,9 +1098,9 @@ func mdHTML(raw string) template.HTML { | |||
| 1088 | } | 1098 | } |
| 1089 | var buf bytes.Buffer | 1099 | var buf bytes.Buffer |
| 1090 | if markdown.Convert([]byte(raw), &buf) != nil { | 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 | // aboutHTML renders a profile's about text. It has no filename to | 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 | // rather than growing a second org renderer to keep in step. | 1173 | // rather than growing a second org renderer to keep in step. |
| 1164 | func ugcHTML(raw, format string) template.HTML { | 1174 | func ugcHTML(raw, format string) template.HTML { |
| 1165 | if format == "org" { | 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 | return template.HTML("<pre>" + template.HTMLEscapeString(raw) + "</pre>") | 1177 | return template.HTML("<pre>" + template.HTMLEscapeString(raw) + "</pre>") |
| 1168 | }) | 1178 | })) |
| 1169 | } | 1179 | } |
| 1170 | return mdHTML(raw) | 1180 | return mdHTML(raw) |
| 1171 | } | 1181 | } |
| @@ -1265,7 +1275,7 @@ func renderOrg(name string, raw []byte, contents bool, fallback func() template. | |||
| 1265 | if err != nil { | 1275 | if err != nil { |
| 1266 | return fallback() | 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 | // orgWriter overrides go-org's autolink rendering. go-org ends a bare URL | 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 | if gitutil.IsBinary(raw) { | 1342 | if gitutil.IsBinary(raw) { |
| 1333 | return "" | 1343 | return "" |
| 1334 | } | 1344 | } |
| 1345 | var out template.HTML | ||
| 1335 | switch path.Ext(strings.ToLower(name)) { | 1346 | switch path.Ext(strings.ToLower(name)) { |
| 1336 | case ".md", ".markdown": | 1347 | case ".md", ".markdown": |
| 1337 | var buf bytes.Buffer | 1348 | var buf bytes.Buffer |
| 1338 | if markdown.Convert(raw, &buf) != nil { | 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 | case ".org": | 1353 | case ".org": |
| 1343 | return demoteHeadings(renderOrg(name, raw, true, plain)) | 1354 | out = demoteHeadings(renderOrg(name, raw, true, plain)) |
| 1344 | case ".html", ".htm": | 1355 | case ".html", ".htm": |
| 1345 | return template.HTML(ugcPolicy.Sanitize(string(raw))) | 1356 | out = template.HTML(ugcPolicy.Sanitize(string(raw))) |
| 1346 | default: | 1357 | default: |
| 1347 | return plain() | 1358 | out = plain() |
| 1348 | } | 1359 | } |
| 1360 | return focusableBlocks(out) | ||
| 1349 | } | 1361 | } |
| 1350 | 1362 | ||
| 1351 | type diffThread struct { | 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 | return err | 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 | func (s *Store) UserByID(id int64) (User, error) { | 256 | func (s *Store) UserByID(id int64) (User, error) { |
| 241 | var u User | 257 | var u User |
| 242 | var admin, pending, disabled int | 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 | p { margin: 0 0 var(--sp-3); } | 209 | p { margin: 0 0 var(--sp-3); } |
| 210 | a { color: var(--link); text-decoration: none; } | 210 | a { color: var(--link); text-decoration: none; } |
| 211 | a:hover { text-decoration: underline; } | 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 | a.xref { color: var(--link); text-decoration: none; } | 218 | a.xref { color: var(--link); text-decoration: none; } |
| 213 | 219 | ||
| 214 | code, pre, .mono, .code, td.mode, td.size { | 220 | code, pre, .mono, .code, td.mode, td.size { |
| @@ -919,7 +925,7 @@ ul.milestonelist .title { font-weight: 500; } | |||
| 919 | 925 | ||
| 920 | article.release { padding: var(--sp-4); margin: var(--sp-4) 0; } | 926 | article.release { padding: var(--sp-4); margin: var(--sp-4) 0; } |
| 921 | .releasehead { background: none; border: 0; padding: 0; } | 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 | .releasehead .meta { margin: 0 0 var(--sp-2); } | 929 | .releasehead .meta { margin: 0 0 var(--sp-2); } |
| 924 | 930 | ||
| 925 | ul.matchlist { list-style: none; margin: var(--sp-3) 0; padding: 0; } | 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 | /* chroma writes the highlighted spans; the container only supplies the | 1194 | /* chroma writes the highlighted spans; the container only supplies the |
| 1189 | ground and the face */ | 1195 | ground and the face */ |
| 1190 | .chroma { font-family: var(--mono); background: none; } | 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 | .blamehunk { display: flex; gap: var(--sp-3); border-top: 1px solid var(--line); align-items: flex-start; } | 1203 | .blamehunk { display: flex; gap: var(--sp-3); border-top: 1px solid var(--line); align-items: flex-start; } |
| 1193 | .blamehunk:first-child { border-top: 0; } | 1204 | .blamehunk:first-child { border-top: 0; } |
| @@ -1268,6 +1279,8 @@ a.memberchip .role { color: var(--muted); } | |||
| 1268 | .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); } | 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 | .pathbar .spacer { flex: 1; } | 1280 | .pathbar .spacer { flex: 1; } |
| 1270 | .pathbar .actions { display: flex; gap: var(--sp-2); } | 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 | .pathbar .acts { display: flex; gap: var(--sp-2); } | 1284 | .pathbar .acts { display: flex; gap: var(--sp-2); } |
| 1272 | .crumbs { color: var(--muted); } | 1285 | .crumbs { color: var(--muted); } |
| 1273 | .crumbs a { color: var(--link); } | 1286 | .crumbs a { color: var(--link); } |
| @@ -1460,9 +1473,9 @@ svg.icon { vertical-align: -0.125em; } | |||
| 1460 | 1473 | ||
| 1461 | /* ---- breakpoints ---- */ | 1474 | /* ---- breakpoints ---- */ |
| 1462 | @media (max-width: 62rem) { | 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 | .withaside { grid-template-columns: 1fr; } | 1478 | .withaside { grid-template-columns: 1fr; } |
| 1464 | /* checks and reviewers before a long thread */ | ||
| 1465 | .withaside .aside { order: -1; } | ||
| 1466 | } | 1479 | } |
| 1467 | 1480 | ||
| 1468 | @media (max-width: 52rem) { | 1481 | @media (max-width: 52rem) { |
| @@ -1486,6 +1499,9 @@ svg.icon { vertical-align: -0.125em; } | |||
| 1486 | latest commit */ | 1499 | latest commit */ |
| 1487 | table.tree td.lastcommit, table.tree th.lastcommit { display: none; } | 1500 | table.tree td.lastcommit, table.tree th.lastcommit { display: none; } |
| 1488 | table.tree td.mode, table.tree th.mode { display: none; } | 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 | .clone pre { white-space: pre-wrap; word-break: break-all; } | 1505 | .clone pre { white-space: pre-wrap; word-break: break-all; } |
| 1490 | .pathbar { overflow-wrap: anywhere; } | 1506 | .pathbar { overflow-wrap: anywhere; } |
| 1491 | .readme .cardbody, .code { padding: var(--sp-3); } | 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 | </form> | 118 | </form> |
| 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> | 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 | <h2>Export</h2> | 134 | <h2>Export</h2> |
| 122 | <p class="meta">Your profile, repositories, issues and merge requests as one | 135 | <p class="meta">Your profile, repositories, issues and merge requests as one |
| 123 | JSON bundle, the same one <code>gitbay account export</code> writes. Keys are | 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 | <div class="landing"> | 4 | <div class="landing"> |
| 5 | <h1>{{template "mark"}}{{.Site}}</h1> | 5 | <h1>{{template "mark"}}{{.Site}}</h1> |
| 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> | 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 | git clone ssh://git@{{.Host}}/owner/repo.git</pre> | 8 | git clone ssh://git@{{.Host}}/owner/repo.git</pre> |
| 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> | 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 | <div class="shot"><picture> | 10 | <div class="shot"><picture> |
internal/web/templates/layout.html +1 −1
| @@ -1,5 +1,5 @@ | |||
| 1 | {{define "layout"}}<!DOCTYPE html> | 1 | {{define "layout"}}<!DOCTYPE html> |
| 2 | <html lang="en"> | 2 | <html lang="en"{{with field . "Theme"}} data-theme="{{.}}"{{end}}> |
| 3 | <head> | 3 | <head> |
| 4 | <meta charset="utf-8"> | 4 | <meta charset="utf-8"> |
| 5 | <meta name="viewport" content="width=device-width, initial-scale=1"> | 5 | <meta name="viewport" content="width=device-width, initial-scale=1"> |
internal/web/templates/releases.html +1 −1
| @@ -20,7 +20,7 @@ | |||
| 20 | {{range $rel := .Releases}} | 20 | {{range $rel := .Releases}} |
| 21 | <article class="release"> | 21 | <article class="release"> |
| 22 | <header class="releasehead"> | 22 | <header class="releasehead"> |
| 23 | <h3>{{$rel.Title}}</h3> | 23 | <h2>{{$rel.Title}}</h2> |
| 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> | 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 | </header> | 25 | </header> |
| 26 | {{if $rel.NotesHTML}}<div class="rendered">{{$rel.NotesHTML}}</div>{{end}} | 26 | {{if $rel.NotesHTML}}<div class="rendered">{{$rel.NotesHTML}}</div>{{end}} |