internal/web/web.go

f8b976a97290a20d552056a999511f5d27d8e8ec
gitbay/internal/web/web.go history · blame · raw

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}