repos
/ orchard main

orchard

mirror

Every site I host, in one repo, along with the Cloudflare Tunnel and Caddy that front them. It's all Go, Vite, and SQLite, and it runs on a desktop at home with nothing listening on an inbound port.

blogbuncaddycloudflare-tunneldockergogolanghomelabhtml-templatemonorepoself-hostedseosqlitestatic-sitetypstuptime-monitoringviteweb-analytics

4.5 KB · 172 lines · Go Raw History
  1package main
  2
  3import (
  4	"html/template"
  5	"net/http"
  6	"strings"
  7	"time"
  8)
  9
 10var templateFuncs = template.FuncMap{
 11	"ago":        ago,
 12	"when":       when,
 13	"eventLabel": eventLabel,
 14	"eventClass": eventClass,
 15	"browser":    browser,
 16	"pips":       pips,
 17	"dict":       dict,
 18}
 19
 20// dict builds a map inline, so a partial can take named arguments rather than
 21// one positional value.
 22func dict(pairs ...any) map[string]any {
 23	out := make(map[string]any, len(pairs)/2)
 24	for i := 0; i+1 < len(pairs); i += 2 {
 25		key, ok := pairs[i].(string)
 26		if !ok {
 27			continue
 28		}
 29		out[key] = pairs[i+1]
 30	}
 31	return out
 32}
 33
 34// pips renders the recovery code meter as ten lit or unlit marks, because eight
 35// of ten is read at a glance and the number 8 is not.
 36func pips(remaining int) []bool {
 37	out := make([]bool, recoveryCount)
 38	for i := range out {
 39		out[i] = i < remaining
 40	}
 41	return out
 42}
 43
 44// when is the one timestamp format on this site. Everything here is UTC,
 45// because a container's local time silently differs from the host's.
 46func when(t time.Time) string { return t.UTC().Format("2006-01-02 15:04 UTC") }
 47
 48func ago(t time.Time) string {
 49	d := time.Since(t)
 50	switch {
 51	case d < time.Minute:
 52		return "just now"
 53	case d < time.Hour:
 54		return itoa(int(d.Minutes())) + "m ago"
 55	case d < 24*time.Hour:
 56		return itoa(int(d.Hours())) + "h ago"
 57	default:
 58		return itoa(int(d.Hours()/24)) + "d ago"
 59	}
 60}
 61
 62func itoa(n int) string {
 63	if n == 0 {
 64		return "0"
 65	}
 66	var b [20]byte
 67	i := len(b)
 68	for n > 0 {
 69		i--
 70		b[i] = byte('0' + n%10)
 71		n /= 10
 72	}
 73	return string(b[i:])
 74}
 75
 76// eventLabel turns a stored kind into something readable without changing the
 77// stored value, which stays machine-facing so a query written today still runs.
 78func eventLabel(kind string) string {
 79	switch kind {
 80	case evCodeRequested:
 81		return "code requested"
 82	case evCodeSent:
 83		return "code sent"
 84	case evCodeFailed:
 85		return "wrong code"
 86	case evCodeExpired:
 87		return "code expired"
 88	case evLogin:
 89		return "signed in"
 90	case evLogout:
 91		return "signed out"
 92	case evSessionRevoked:
 93		return "session revoked"
 94	case evRecoveryUsed:
 95		return "recovery code used"
 96	case evRecoveryFailed:
 97		return "wrong recovery code"
 98	case evRecoveryRotated:
 99		return "recovery codes replaced"
100	case evRateLimited:
101		return "rate limited"
102	case evCeilingHit:
103		return "send ceiling hit"
104	case evUsernameChanged:
105		return "username changed"
106	}
107	return strings.ReplaceAll(kind, "_", " ")
108}
109
110// eventClass colours the ones worth noticing on the activity page.
111func eventClass(kind string) string {
112	switch kind {
113	case evCodeFailed, evRecoveryFailed, evRateLimited, evCeilingHit:
114		return "is-warn"
115	case evLogin, evRecoveryUsed, evRecoveryRotated, evUsernameChanged:
116		return "is-note"
117	}
118	return ""
119}
120
121// browser reduces a user agent to something that fits in a table cell. It is
122// display only and guesses, so it never claims more than the family.
123func browser(ua string) string {
124	switch {
125	case ua == "":
126		return "unknown"
127	case strings.Contains(ua, "Firefox/"):
128		return "Firefox"
129	case strings.Contains(ua, "Edg/"):
130		return "Edge"
131	case strings.Contains(ua, "OPR/"):
132		return "Opera"
133	case strings.Contains(ua, "Chrome/"):
134		return "Chrome"
135	case strings.Contains(ua, "Safari/"):
136		return "Safari"
137	case strings.Contains(ua, "curl/"):
138		return "curl"
139	}
140	return "other"
141}
142
143// Inline rather than a file so it cannot drift from the navbar logo, which is
144// the same markup. A north star with three companions, which is the same
145// language as the field behind the sign in form and still reads at 16px, where
146// a full constellation turns to mush.
147const faviconSVG = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
148  <g stroke="#6b9e78" stroke-width="1.6" opacity="0.55" stroke-linecap="round">
149    <path d="M46 15 L34 28"/>
150    <path d="M18 47 L29 36"/>
151    <path d="M46 15 L52 40"/>
152  </g>
153  <path d="M32 4 L36.4 26.2 L58 32 L36.4 37.8 L32 60 L27.6 37.8 L6 32 L27.6 26.2 Z"
154        fill="#6b9e78"/>
155  <circle cx="46" cy="15" r="3.4" fill="#c9d9cb"/>
156  <circle cx="18" cy="47" r="2.6" fill="#7eaab8"/>
157  <circle cx="52" cy="40" r="2.2" fill="#c9a84c"/>
158</svg>`
159
160func favicon(w http.ResponseWriter, r *http.Request) {
161	w.Header().Set("Content-Type", "image/svg+xml")
162	w.Header().Set("Cache-Control", "public, max-age=86400")
163	_, _ = w.Write([]byte(faviconSVG))
164}
165
166// robots refuses the whole site. There is nothing here worth indexing and the
167// login form is the only page a stranger can reach at all.
168func robots(w http.ResponseWriter, r *http.Request) {
169	w.Header().Set("Content-Type", "text/plain; charset=utf-8")
170	_, _ = w.Write([]byte("User-agent: *\nDisallow: /\n"))
171}