internal/web/web.go
353 lines · 10230 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//go:embed static/img/*.gif static/img/*.webm static/img/*.mp4
33var ImageFS embed.FS
34
35// version returns the short VCS revision baked into the binary, or "" when
36// built outside a checkout. Used by the layout footer.
37var version = sync.OnceValue(func() string {
38 info, ok := debug.ReadBuildInfo()
39 if !ok {
40 return ""
41 }
42 for _, s := range info.Settings {
43 if s.Key == "vcs.revision" && len(s.Value) >= 10 {
44 return s.Value[:10]
45 }
46 }
47 return ""
48})
49
50// fullVersion is the complete VCS revision, for linking the footer hash
51// to the upstream commit page.
52var fullVersion = sync.OnceValue(func() string {
53 info, ok := debug.ReadBuildInfo()
54 if !ok {
55 return ""
56 }
57 for _, s := range info.Settings {
58 if s.Key == "vcs.revision" {
59 return s.Value
60 }
61 }
62 return ""
63})
64
65// StyleVersion identifies the bytes /static/style.css serves. The httpd
66// package sets it; the layout stamps it on the stylesheet URL so a deploy
67// changes that URL and a browser holding a cached copy cannot miss it.
68var StyleVersion string
69
70// railItem is one destination the rail's icon strip and the phone
71// "More" menu both render — from this one list, so a destination added
72// here reaches both instead of the two being hand-kept in step (#271).
73type railItem struct {
74 Href string
75 Icon string
76 Name string
77 Current bool
78 Count int64 // unused by railOptItems; present so "raillink" can read it uniformly
79 Show bool
80}
81
82// railField and railBool read a named field off the page value the
83// layout was given — the same reflection str/field already do for the
84// repo header, duplicated narrowly here rather than exported, since
85// railOptItems is their only other caller.
86func railField(v any, name string) string {
87 rv := reflect.ValueOf(v)
88 for rv.Kind() == reflect.Ptr || rv.Kind() == reflect.Interface {
89 rv = rv.Elem()
90 }
91 if rv.Kind() != reflect.Struct {
92 return ""
93 }
94 f := rv.FieldByName(name)
95 if !f.IsValid() || f.Kind() != reflect.String {
96 return ""
97 }
98 return f.String()
99}
100
101func railBool(v any, name string) bool {
102 rv := reflect.ValueOf(v)
103 for rv.Kind() == reflect.Ptr || rv.Kind() == reflect.Interface {
104 rv = rv.Elem()
105 }
106 if rv.Kind() != reflect.Struct {
107 return false
108 }
109 f := rv.FieldByName(name)
110 return f.IsValid() && f.Kind() == reflect.Bool && f.Bool()
111}
112
113// railOptItems is the rail's "New repository", "Settings", "Admin" and
114// "Log out" destinations, in the order the rail shows them. v is the
115// page value the layout renders (any page struct that embeds
116// basePage), read by field name since the layout has no single common
117// type for every page.
118func railOptItems(v any) []railItem {
119 tab := railField(v, "Tab")
120 admin := railBool(v, "Admin")
121 return []railItem{
122 {Href: "/new", Icon: "plus", Name: "New repository", Show: true},
123 {Href: "/settings", Icon: "gear", Name: "Settings", Current: tab == "account", Show: true},
124 {Href: "/admin", Icon: "shield", Name: "Admin", Current: tab == "admin", Show: admin},
125 {Href: "/logout", Icon: "signout", Name: "Log out", Show: true},
126 }
127}
128
129var funcs = template.FuncMap{
130 "gitbayVersion": func() string { return version() },
131 "gitbayCommit": func() string { return fullVersion() },
132 "styleVersion": func() string { return StyleVersion },
133 "join": strings.Join,
134 // paragraphs splits plain text on blank lines for safe rich display.
135 "paragraphs": func(s string) []string {
136 var out []string
137 for _, p := range strings.Split(s, "\n\n") {
138 if p = strings.TrimSpace(p); p != "" {
139 out = append(out, p)
140 }
141 }
142 return out
143 },
144 // short abbreviates a commit SHA for display.
145 "short": func(s string) string {
146 if len(s) > 10 {
147 return s[:10]
148 }
149 return s
150 },
151 // dict builds a map for {{template}} calls that need several values.
152 "dict": func(pairs ...any) map[string]any {
153 m := map[string]any{}
154 for i := 0; i+1 < len(pairs); i += 2 {
155 if k, ok := pairs[i].(string); ok {
156 m[k] = pairs[i+1]
157 }
158 }
159 return m
160 },
161 "add": func(a, b int) int { return a + b },
162 "sub": func(a, b int) int { return a - b },
163 // topTab maps a page's Tab to the repo header tab that should read as
164 // current. Everything that browses the tree or its history sits under
165 // code, whose own toolbar carries the log, search, and refs links.
166 "topTab": func(tab string) string {
167 switch tab {
168 case "files", "log", "refs", "search":
169 return "code"
170 case "issues":
171 return "issues"
172 case "merge requests":
173 return "mrs"
174 }
175 return tab
176 },
177 // initial is the avatar letter for a username.
178 "initial": func(s string) string {
179 for _, r := range s {
180 return strings.ToUpper(string(r))
181 }
182 return "?"
183 },
184 "railOptItems": railOptItems,
185 // str is field, narrowed to strings: missing or non-string fields
186 // yield "", which comparisons handle without erroring.
187 "str": func(v any, name string) string {
188 rv := reflect.ValueOf(v)
189 for rv.Kind() == reflect.Ptr || rv.Kind() == reflect.Interface {
190 rv = rv.Elem()
191 }
192 if rv.Kind() != reflect.Struct {
193 return ""
194 }
195 f := rv.FieldByName(name)
196 if !f.IsValid() || f.Kind() != reflect.String {
197 return ""
198 }
199 return f.String()
200 },
201 // field reads a field the page struct may not have, so the layout can
202 // render repo navigation without every page carrying repo context.
203 // Missing fields yield nil, which templates treat as absent.
204 "field": func(v any, name string) any {
205 rv := reflect.ValueOf(v)
206 for rv.Kind() == reflect.Ptr || rv.Kind() == reflect.Interface {
207 rv = rv.Elem()
208 }
209 if rv.Kind() != reflect.Struct {
210 return nil
211 }
212 f := rv.FieldByName(name)
213 if !f.IsValid() || !f.CanInterface() {
214 return nil
215 }
216 return f.Interface()
217 },
218 // sigLabel turns a stored signature state into words. The state names
219 // are the CLI's vocabulary and belong in its output; a reader of the
220 // web UI needs to know what is wrong, not what the column is called.
221 "sigLabel": func(state string) string {
222 switch state {
223 case "verified":
224 return "Verified"
225 case "signed_unknown_key":
226 return "Unregistered key"
227 case "signed_email_mismatch":
228 return "Email mismatch"
229 case "signed_key_expired":
230 return "Key expired"
231 case "signed_key_revoked":
232 return "Key revoked"
233 case "bad_signature":
234 return "Bad signature"
235 case "unsigned":
236 return "Unsigned"
237 }
238 return state
239 },
240 // ago renders a time as a coarse relative age, which is what a
241 // listing column is actually read for. The zero time yields "".
242 // slug turns a language name into a CSS class suffix, so the palette
243 // lives in the stylesheet rather than in inline styles the CSP would
244 // have to allow.
245 "slug": func(s string) string {
246 var b strings.Builder
247 for _, r := range strings.ToLower(s) {
248 switch {
249 case r >= 'a' && r <= 'z', r >= '0' && r <= '9':
250 b.WriteRune(r)
251 default:
252 b.WriteByte('-')
253 }
254 }
255 return b.String()
256 },
257 // pct renders a share to one decimal, dropping a trailing ".0".
258 "pct": func(f float64) string {
259 s := strconv.FormatFloat(f, 'f', 1, 64)
260 return strings.TrimSuffix(s, ".0")
261 },
262 "ago": func(t time.Time) string {
263 if t.IsZero() {
264 return ""
265 }
266 d := time.Since(t)
267 if d < 0 {
268 d = 0
269 }
270 plural := func(n int, unit string) string {
271 if n == 1 {
272 return "1 " + unit + " ago"
273 }
274 return strconv.Itoa(n) + " " + unit + "s ago"
275 }
276 switch {
277 case d < time.Minute:
278 return "just now"
279 case d < time.Hour:
280 return plural(int(d/time.Minute), "minute")
281 case d < 24*time.Hour:
282 return plural(int(d/time.Hour), "hour")
283 case d < 30*24*time.Hour:
284 return plural(int(d/(24*time.Hour)), "day")
285 case d < 365*24*time.Hour:
286 return plural(int(d/(30*24*time.Hour)), "month")
287 }
288 return plural(int(d/(365*24*time.Hour)), "year")
289 },
290 // when formats a stored RFC3339 timestamp for display; unparseable
291 // values pass through unchanged.
292 "when": func(s string) string {
293 t, err := time.Parse(time.RFC3339Nano, s)
294 if err != nil {
295 return s
296 }
297 return t.UTC().Format("2006-01-02 15:04 UTC")
298 },
299 // whenT is when for a value that is already a time.Time rather than
300 // a stored string.
301 "whenT": func(t time.Time) string {
302 return t.UTC().Format("2006-01-02 15:04 UTC")
303 },
304}
305
306// pages holds every page template parsed once with the layout, at
307// start-up: a template that does not parse fails the process before it
308// serves anything, rather than the first visit to a rarely-hit page, and
309// a request no longer re-parses the layout (#116).
310var pages = func() map[string]*template.Template {
311 entries, err := fs.ReadDir(templateFS, "templates")
312 if err != nil {
313 panic(err)
314 }
315 m := map[string]*template.Template{}
316 for _, e := range entries {
317 name := e.Name()
318 if name == "layout.html" || !strings.HasSuffix(name, ".html") {
319 continue
320 }
321 layout := template.Must(template.New("layout.html").Funcs(funcs).ParseFS(templateFS, "templates/layout.html"))
322 m[name] = template.Must(layout.ParseFS(templateFS, "templates/"+name))
323 }
324 return m
325}()
326
327// TemplateSource returns a template's raw text, for tests that inspect the
328// markup rather than the rendered output — an accessibility rule about
329// labels and ids is a property of the source, and holds whatever data a
330// page is given.
331func TemplateSource(name string) (string, error) {
332 b, err := templateFS.ReadFile("templates/" + name)
333 return string(b), err
334}
335
336// Pages lists the page template names, for tests that render each one.
337func Pages() []string {
338 names := make([]string, 0, len(pages))
339 for name := range pages {
340 names = append(names, name)
341 }
342 sort.Strings(names)
343 return names
344}
345
346// Render executes the named page template with the shared layout.
347func Render(w io.Writer, page string, data any) error {
348 t, ok := pages[page]
349 if !ok {
350 return fmt.Errorf("no page template %q", page)
351 }
352 return t.ExecuteTemplate(w, "layout", data)
353}