internal/web/web.go
284 lines · 7804 bytes
1// Package web holds the server-rendered templates and static assets for the
2// read-only UI. No JavaScript, no build step.
3package web
4
5import (
6 "embed"
7 "fmt"
8 "html/template"
9 "io"
10 "io/fs"
11 "reflect"
12 "runtime/debug"
13 "sort"
14 "strconv"
15 "strings"
16 "sync"
17 "time"
18)
19
20//go:embed templates/*.html
21var templateFS embed.FS
22
23//go:embed static/style.css
24var StyleCSS []byte
25
26//go:embed static/favicon.svg
27var FaviconSVG []byte
28
29//go:embed static/fonts/*.woff2
30var FontFS embed.FS
31
32// version returns the short VCS revision baked into the binary, or "" when
33// built outside a checkout. Used by the layout footer.
34var version = sync.OnceValue(func() string {
35 info, ok := debug.ReadBuildInfo()
36 if !ok {
37 return ""
38 }
39 for _, s := range info.Settings {
40 if s.Key == "vcs.revision" && len(s.Value) >= 10 {
41 return s.Value[:10]
42 }
43 }
44 return ""
45})
46
47// fullVersion is the complete VCS revision, for linking the footer hash
48// to the upstream commit page.
49var fullVersion = sync.OnceValue(func() string {
50 info, ok := debug.ReadBuildInfo()
51 if !ok {
52 return ""
53 }
54 for _, s := range info.Settings {
55 if s.Key == "vcs.revision" {
56 return s.Value
57 }
58 }
59 return ""
60})
61
62var funcs = template.FuncMap{
63 "gitbayVersion": func() string { return version() },
64 "gitbayCommit": func() string { return fullVersion() },
65 "join": strings.Join,
66 // paragraphs splits plain text on blank lines for safe rich display.
67 "paragraphs": func(s string) []string {
68 var out []string
69 for _, p := range strings.Split(s, "\n\n") {
70 if p = strings.TrimSpace(p); p != "" {
71 out = append(out, p)
72 }
73 }
74 return out
75 },
76 // short abbreviates a commit SHA for display.
77 "short": func(s string) string {
78 if len(s) > 10 {
79 return s[:10]
80 }
81 return s
82 },
83 // dict builds a map for {{template}} calls that need several values.
84 "dict": func(pairs ...any) map[string]any {
85 m := map[string]any{}
86 for i := 0; i+1 < len(pairs); i += 2 {
87 if k, ok := pairs[i].(string); ok {
88 m[k] = pairs[i+1]
89 }
90 }
91 return m
92 },
93 "add": func(a, b int) int { return a + b },
94 "sub": func(a, b int) int { return a - b },
95 // topTab maps a page's Tab to the repo header tab that should read as
96 // current. Everything that browses the tree or its history sits under
97 // code, whose own toolbar carries the log, search, and refs links.
98 "topTab": func(tab string) string {
99 switch tab {
100 case "files", "log", "refs", "search":
101 return "code"
102 case "issues":
103 return "issues"
104 case "merge requests":
105 return "mrs"
106 }
107 return tab
108 },
109 // initial is the avatar letter for a username.
110 "initial": func(s string) string {
111 for _, r := range s {
112 return strings.ToUpper(string(r))
113 }
114 return "?"
115 },
116 // str is field, narrowed to strings: missing or non-string fields
117 // yield "", which comparisons handle without erroring.
118 "str": func(v any, name string) string {
119 rv := reflect.ValueOf(v)
120 for rv.Kind() == reflect.Ptr || rv.Kind() == reflect.Interface {
121 rv = rv.Elem()
122 }
123 if rv.Kind() != reflect.Struct {
124 return ""
125 }
126 f := rv.FieldByName(name)
127 if !f.IsValid() || f.Kind() != reflect.String {
128 return ""
129 }
130 return f.String()
131 },
132 // field reads a field the page struct may not have, so the layout can
133 // render repo navigation without every page carrying repo context.
134 // Missing fields yield nil, which templates treat as absent.
135 "field": func(v any, name string) any {
136 rv := reflect.ValueOf(v)
137 for rv.Kind() == reflect.Ptr || rv.Kind() == reflect.Interface {
138 rv = rv.Elem()
139 }
140 if rv.Kind() != reflect.Struct {
141 return nil
142 }
143 f := rv.FieldByName(name)
144 if !f.IsValid() || !f.CanInterface() {
145 return nil
146 }
147 return f.Interface()
148 },
149 // sigLabel turns a stored signature state into words. The state names
150 // are the CLI's vocabulary and belong in its output; a reader of the
151 // web UI needs to know what is wrong, not what the column is called.
152 "sigLabel": func(state string) string {
153 switch state {
154 case "verified":
155 return "Verified"
156 case "signed_unknown_key":
157 return "Unregistered key"
158 case "signed_email_mismatch":
159 return "Email mismatch"
160 case "signed_key_expired":
161 return "Key expired"
162 case "signed_key_revoked":
163 return "Key revoked"
164 case "bad_signature":
165 return "Bad signature"
166 case "unsigned":
167 return "Unsigned"
168 }
169 return state
170 },
171 // ago renders a time as a coarse relative age, which is what a
172 // listing column is actually read for. The zero time yields "".
173 // slug turns a language name into a CSS class suffix, so the palette
174 // lives in the stylesheet rather than in inline styles the CSP would
175 // have to allow.
176 "slug": func(s string) string {
177 var b strings.Builder
178 for _, r := range strings.ToLower(s) {
179 switch {
180 case r >= 'a' && r <= 'z', r >= '0' && r <= '9':
181 b.WriteRune(r)
182 default:
183 b.WriteByte('-')
184 }
185 }
186 return b.String()
187 },
188 // pct renders a share to one decimal, dropping a trailing ".0".
189 "pct": func(f float64) string {
190 s := strconv.FormatFloat(f, 'f', 1, 64)
191 return strings.TrimSuffix(s, ".0")
192 },
193 "ago": func(t time.Time) string {
194 if t.IsZero() {
195 return ""
196 }
197 d := time.Since(t)
198 if d < 0 {
199 d = 0
200 }
201 plural := func(n int, unit string) string {
202 if n == 1 {
203 return "1 " + unit + " ago"
204 }
205 return strconv.Itoa(n) + " " + unit + "s ago"
206 }
207 switch {
208 case d < time.Minute:
209 return "just now"
210 case d < time.Hour:
211 return plural(int(d/time.Minute), "minute")
212 case d < 24*time.Hour:
213 return plural(int(d/time.Hour), "hour")
214 case d < 30*24*time.Hour:
215 return plural(int(d/(24*time.Hour)), "day")
216 case d < 365*24*time.Hour:
217 return plural(int(d/(30*24*time.Hour)), "month")
218 }
219 return plural(int(d/(365*24*time.Hour)), "year")
220 },
221 // when formats a stored RFC3339 timestamp for display; unparseable
222 // values pass through unchanged.
223 "when": func(s string) string {
224 t, err := time.Parse(time.RFC3339Nano, s)
225 if err != nil {
226 return s
227 }
228 return t.UTC().Format("2006-01-02 15:04 UTC")
229 },
230 // whenT is when for a value that is already a time.Time rather than
231 // a stored string.
232 "whenT": func(t time.Time) string {
233 return t.UTC().Format("2006-01-02 15:04 UTC")
234 },
235}
236
237// pages holds every page template parsed once with the layout, at
238// start-up: a template that does not parse fails the process before it
239// serves anything, rather than the first visit to a rarely-hit page, and
240// a request no longer re-parses the layout (#116).
241var pages = func() map[string]*template.Template {
242 entries, err := fs.ReadDir(templateFS, "templates")
243 if err != nil {
244 panic(err)
245 }
246 m := map[string]*template.Template{}
247 for _, e := range entries {
248 name := e.Name()
249 if name == "layout.html" || !strings.HasSuffix(name, ".html") {
250 continue
251 }
252 layout := template.Must(template.New("layout.html").Funcs(funcs).ParseFS(templateFS, "templates/layout.html"))
253 m[name] = template.Must(layout.ParseFS(templateFS, "templates/"+name))
254 }
255 return m
256}()
257
258// TemplateSource returns a template's raw text, for tests that inspect the
259// markup rather than the rendered output — an accessibility rule about
260// labels and ids is a property of the source, and holds whatever data a
261// page is given.
262func TemplateSource(name string) (string, error) {
263 b, err := templateFS.ReadFile("templates/" + name)
264 return string(b), err
265}
266
267// Pages lists the page template names, for tests that render each one.
268func Pages() []string {
269 names := make([]string, 0, len(pages))
270 for name := range pages {
271 names = append(names, name)
272 }
273 sort.Strings(names)
274 return names
275}
276
277// Render executes the named page template with the shared layout.
278func Render(w io.Writer, page string, data any) error {
279 t, ok := pages[page]
280 if !ok {
281 return fmt.Errorf("no page template %q", page)
282 }
283 return t.ExecuteTemplate(w, "layout", data)
284}