Gitfed
bastien-mrq/gitfed/ Commits/ 7859815

Add light/dark/system theme

Every CSS custom property gets a light-mode value (media query for automatic, :root[data-theme] override for the manual nav switcher), persisted via a cookie — same server-rendered pattern as the existing EN/FR language switcher, no client JS. Fixed a real bug found while wiring the new template field: the CSP script hash was computed from a second, separate shellTpl execution that didn't get the new Theme field, silently failing and pinning the hash of an empty string — would have broken every page's inline script in production.

bastien-mrq 2026-07-30 12:20 commit 785981502dc760dd92229d0f5c8b4605c621bc7e parent 675b8a8f03d6b6e68f182d61ebd1ebd7da9cf4a8
9 files changed +114 −19
M CHANGELOG.md +4 −0
M ROADMAP.md +0 −7
M cmd/gitfed-web/render.go +47 −6
M cmd/gitfed-web/routes.go +1 −0
M cmd/gitfed-web/security_headers.go +3 −3
M cmd/gitfed-web/security_headers_test.go +3 −3
A cmd/gitfed-web/theme.go +50 −0
M internal/i18n/strings_en.go +3 −0
M internal/i18n/strings_fr.go +3 −0
CHANGELOG.md
diff --git a/CHANGELOG.md b/CHANGELOG.md index 88fa72a..8380a1a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ A bullet starting with `**BREAKING:**` flags a change gitfed-ctl's update wizard makes you acknowledge individually before it will let you upgrade past that version. +## 1.2.16 + +- Light/dark/system theme. Every CSS token gets a light-mode value, applied automatically via `prefers-color-scheme` or forced via a new nav switcher (Auto/Clair/Sombre) that persists across visits in a cookie — same server-rendered pattern as the existing EN/FR language switcher, no client-side JS. Found and fixed a real bug while wiring the new template field through: the CSP script hash was computed from a second, separate template execution that didn't get the new field, silently failing and pinning the hash of an *empty* string — would have broken every page's inline script under the CSP in production. + ## 1.2.15 - Fixed a real command-injection bug found during a security review: `gitfed-ctl` and `gitfed-install` both built `sh -c "docker save '<tag>' | k3s ctr images import -"` with `shellQuote`, a bare wrap in single quotes that doesn't escape an embedded single quote — a `VERSION` file (or a cloned source's VERSION) containing one could break out of the quoting and run arbitrary shell commands as root. Both now pipe `docker save`/`k3s ctr images import` directly in Go (`runPiped`, no shell at all), so there's nothing to escape. Drop-in fix, no action needed.
ROADMAP.md
diff --git a/ROADMAP.md b/ROADMAP.md index 7d4672a..84d123f 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -102,13 +102,6 @@ celui-là la prochaine fois qu'on rouvre ce document. *(effort élevé — indexation à construire, faible valeur tant que le nombre de dépôts reste petit)*. -### Personnalisation - -- **Thème clair/sombre/système** — aujourd'hui une seule palette sombre - fixe (`render.go`), aucune bascule. Bien cadré (redéfinir chaque - token en version claire, mécanisme de bascule, persistance) et sans - risque architectural. *(effort moyen)*. - ### Fédération & import - **Mirroring continu** depuis un dépôt externe (re-fetch périodique,
cmd/gitfed-web/render.go
diff --git a/cmd/gitfed-web/render.go b/cmd/gitfed-web/render.go index 91e92dc..9639c9e 100644 --- a/cmd/gitfed-web/render.go +++ b/cmd/gitfed-web/render.go @@ -220,7 +220,7 @@ func roleLabel(lang i18n.Lang, role string) string { } const shellHeadSrc = `<!doctype html> -<html> +<html{{if .Theme}} data-theme="{{.Theme}}"{{end}}> <head> <meta charset="utf-8"> <meta name="viewport" content="width=device-width, initial-scale=1"> @@ -239,6 +239,42 @@ const shellHeadSrc = `<!doctype html> --violet-bg: rgba(167,139,250,0.14); --violet-fg: #b9a3f7; --mono: "SF Mono", "IBM Plex Mono", ui-monospace, Menlo, Consolas, monospace; } + /* Light theme: automatic (OS preference) ... */ + @media (prefers-color-scheme: light) { + :root { + --canvas: #f4f5f7; --surface: #ffffff; --surface-2: #edeff2; --surface-3: #e1e4e9; + --border: #dde1e6; --border-strong: #c5cbd3; + --text: #1a1d23; --text-dim: #565d6b; --text-faint: #868fa0; + --accent: #3f6fd1; --accent-dim: #d7e3fb; --accent-ink: #ffffff; + --ok-bg: rgba(30,122,76,0.12); --ok-fg: #1e7a4c; + --pending-bg: rgba(150,101,10,0.12); --pending-fg: #96650a; + --danger-bg: rgba(178,58,46,0.12); --danger-fg: #b23a2e; + --violet-bg: rgba(104,66,194,0.12); --violet-fg: #6842c2; + } + } + /* ... and forced, via the nav switcher (see theme.go) — these win over + the media query above (and over each other vs. the base :root) purely + by selector specificity, regardless of source order. */ + :root[data-theme="light"] { + --canvas: #f4f5f7; --surface: #ffffff; --surface-2: #edeff2; --surface-3: #e1e4e9; + --border: #dde1e6; --border-strong: #c5cbd3; + --text: #1a1d23; --text-dim: #565d6b; --text-faint: #868fa0; + --accent: #3f6fd1; --accent-dim: #d7e3fb; --accent-ink: #ffffff; + --ok-bg: rgba(30,122,76,0.12); --ok-fg: #1e7a4c; + --pending-bg: rgba(150,101,10,0.12); --pending-fg: #96650a; + --danger-bg: rgba(178,58,46,0.12); --danger-fg: #b23a2e; + --violet-bg: rgba(104,66,194,0.12); --violet-fg: #6842c2; + } + :root[data-theme="dark"] { + --canvas: #0d0f13; --surface: #161a21; --surface-2: #1c212a; --surface-3: #232933; + --border: #262c36; --border-strong: #333a46; + --text: #e8eaed; --text-dim: #9aa1ac; --text-faint: #6b7280; + --accent: #6c9df5; --accent-dim: #3a5a8f; --accent-ink: #0d0f13; + --ok-bg: rgba(95,191,143,0.14); --ok-fg: #7bd6a8; + --pending-bg: rgba(224,179,78,0.14); --pending-fg: #e0b34e; + --danger-bg: rgba(242,139,130,0.14); --danger-fg: #f28b82; + --violet-bg: rgba(167,139,250,0.14); --violet-fg: #b9a3f7; + } * { box-sizing: border-box; } body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; margin: 0; background: var(--canvas); color: var(--text); font-size: 15px; line-height: 1.5; -webkit-font-smoothing: antialiased; } a { color: inherit; } @@ -709,6 +745,11 @@ const shellHeadSrc = `<!doctype html> </div> </form> <div class="gf-nav-right"> + <div class="gf-lang"> + <a href="/theme/system?next={{.NextPath}}"{{if not .Theme}} class="active"{{end}}>{{t .Lang "theme.system"}}</a> + <a href="/theme/light?next={{.NextPath}}"{{if eq .Theme "light"}} class="active"{{end}}>{{t .Lang "theme.light"}}</a> + <a href="/theme/dark?next={{.NextPath}}"{{if eq .Theme "dark"}} class="active"{{end}}>{{t .Lang "theme.dark"}}</a> + </div> <div class="gf-lang"> <a href="/lang/en?next={{.NextPath}}"{{if eq .Lang "en"}} class="active"{{end}}>EN</a> <a href="/lang/fr?next={{.NextPath}}"{{if eq .Lang "fr"}} class="active"{{end}}>FR</a> @@ -856,13 +897,13 @@ func (s *server) render(w http.ResponseWriter, r *http.Request, title, active st } w.Header().Set("Content-Type", "text/html; charset=utf-8") _ = shellTpl.Execute(w, struct { - Title, Domain, Active, Username, Version, Initials, SearchQuery, Lang, NextPath string - LoggedIn, IsAdmin bool - PendingNotifs, PendingTrust int - Body, BrandMark, IconSprite template.HTML + Title, Domain, Active, Username, Version, Initials, SearchQuery, Lang, Theme, NextPath string + LoggedIn, IsAdmin bool + PendingNotifs, PendingTrust int + Body, BrandMark, IconSprite template.HTML }{ title, s.domain, active, sess.Username, version.Version, initials(sess.Username), r.URL.Query().Get("q"), - string(s.lang(r)), r.URL.RequestURI(), + string(s.lang(r)), s.theme(r), r.URL.RequestURI(), loggedIn, sess.IsAdmin, pendingNotifs, pendingTrust, body, template.HTML(brandMark), template.HTML(iconSprite), }) }
cmd/gitfed-web/routes.go
diff --git a/cmd/gitfed-web/routes.go b/cmd/gitfed-web/routes.go index 84344c3..851805d 100644 --- a/cmd/gitfed-web/routes.go +++ b/cmd/gitfed-web/routes.go @@ -20,6 +20,7 @@ func (s *server) routes(mux *http.ServeMux) { mux.HandleFunc("GET /explore", s.handleExplore) mux.HandleFunc("GET /u/{username}", s.handleUserProfile) mux.HandleFunc("GET /lang/{lang}", s.handleSetLang) + mux.HandleFunc("GET /theme/{theme}", s.handleSetTheme) // Anonymous read-only git-over-HTTPS for public repos (clone/fetch // only — no receive-pack). Deliberately at the site root, not under a
cmd/gitfed-web/security_headers.go
diff --git a/cmd/gitfed-web/security_headers.go b/cmd/gitfed-web/security_headers.go index 5820ac1..c94a736 100644 --- a/cmd/gitfed-web/security_headers.go +++ b/cmd/gitfed-web/security_headers.go @@ -26,9 +26,9 @@ var scriptHash = "sha256-" + func() string { func renderedShellScript() string { var buf bytes.Buffer _ = shellTpl.Execute(&buf, struct { - Title, Domain, Active, Username, Version, Initials, SearchQuery, Lang, NextPath string - LoggedIn, IsAdmin bool - Body, BrandMark, IconSprite template.HTML + Title, Domain, Active, Username, Version, Initials, SearchQuery, Lang, Theme, NextPath string + LoggedIn, IsAdmin bool + Body, BrandMark, IconSprite template.HTML }{Lang: "en"}) html := buf.String() open := strings.Index(html, "<script>")
cmd/gitfed-web/security_headers_test.go
diff --git a/cmd/gitfed-web/security_headers_test.go b/cmd/gitfed-web/security_headers_test.go index 7be49ae..8bdf055 100644 --- a/cmd/gitfed-web/security_headers_test.go +++ b/cmd/gitfed-web/security_headers_test.go @@ -17,9 +17,9 @@ import ( func TestCSPScriptHashMatchesRenderedScript(t *testing.T) { var buf bytes.Buffer err := shellTpl.Execute(&buf, struct { - Title, Domain, Active, Username, Version, Initials, SearchQuery, Lang, NextPath string - LoggedIn, IsAdmin bool - Body, BrandMark, IconSprite template.HTML + Title, Domain, Active, Username, Version, Initials, SearchQuery, Lang, Theme, NextPath string + LoggedIn, IsAdmin bool + Body, BrandMark, IconSprite template.HTML }{Title: "t", Lang: "en"}) if err != nil { t.Fatalf("execute shell: %v", err)
cmd/gitfed-web/theme.go
diff --git a/cmd/gitfed-web/theme.go b/cmd/gitfed-web/theme.go new file mode 100644 index 0000000..206a6f1 --- /dev/null +++ b/cmd/gitfed-web/theme.go @@ -0,0 +1,50 @@ +package main + +import ( + "net/http" + "strings" + "time" +) + +const themeCookieName = "gitfed_theme" + +// theme resolves the UI theme for a request from its cookie (set via the +// nav switcher) — "light" or "dark" force that theme regardless of the +// browser's own preference, "" (unset, or any other value) means "system": +// no data-theme attribute is rendered at all, so the page's +// prefers-color-scheme media query decides instead. There's no +// Accept-Language-style fallback here on purpose — unlike language, "system" +// is a legitimate, common default, not a missing signal to guess around. +func (s *server) theme(r *http.Request) string { + if c, err := r.Cookie(themeCookieName); err == nil && (c.Value == "light" || c.Value == "dark") { + return c.Value + } + return "" +} + +// handleSetTheme stores the chosen theme in a cookie (or clears it for +// "system") and bounces back to wherever the switcher was clicked from — +// same shape as handleSetLang. +func (s *server) handleSetTheme(w http.ResponseWriter, r *http.Request) { + choice := r.PathValue("theme") + cookie := &http.Cookie{ + Name: themeCookieName, + Path: "/", + HttpOnly: true, + Secure: true, + SameSite: http.SameSiteLaxMode, + } + if choice == "light" || choice == "dark" { + cookie.Value = choice + cookie.Expires = time.Now().Add(365 * 24 * time.Hour) + } else { + cookie.MaxAge = -1 // "system" — clear any previous explicit choice + } + http.SetCookie(w, cookie) + + next := r.URL.Query().Get("next") + if next == "" || !strings.HasPrefix(next, "/") || strings.HasPrefix(next, "//") { + next = "/" + } + http.Redirect(w, r, next, http.StatusSeeOther) +}
internal/i18n/strings_en.go
diff --git a/internal/i18n/strings_en.go b/internal/i18n/strings_en.go index fa0321a..6beecfe 100644 --- a/internal/i18n/strings_en.go +++ b/internal/i18n/strings_en.go @@ -13,6 +13,9 @@ var en = map[string]string{ "nav.admin": "Admin", "nav.logout": "Log out", "nav.notifications": "Notifications", + "theme.system": "Auto", + "theme.light": "Light", + "theme.dark": "Dark", "footer.changelog": "Gitfed %s", // ---------- explore ----------
internal/i18n/strings_fr.go
diff --git a/internal/i18n/strings_fr.go b/internal/i18n/strings_fr.go index 1f12201..d5df62c 100644 --- a/internal/i18n/strings_fr.go +++ b/internal/i18n/strings_fr.go @@ -13,6 +13,9 @@ var fr = map[string]string{ "nav.admin": "Administration", "nav.logout": "Se déconnecter", "nav.notifications": "Notifications", + "theme.system": "Auto", + "theme.light": "Clair", + "theme.dark": "Sombre", "footer.changelog": "Gitfed %s", // ---------- explore ----------