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.7 KB · 196 lines · Go Raw History
  1// Command dash serves dash.bythewood.me: markets, news, weather and the state
  2// of every site in this repo, on one page that updates itself. Everything it
  3// shows is fetched by one poller and pushed to every open browser over
  4// server-sent events, so the page costs the upstreams the same whether nobody
  5// or everybody is watching.
  6package main
  7
  8import (
  9	"context"
 10	"embed"
 11	"flag"
 12	"fmt"
 13	"io/fs"
 14	"log/slog"
 15	"net/http"
 16	"os"
 17	"os/signal"
 18	"strings"
 19	"syscall"
 20	"time"
 21
 22	"dash.bythewood.me/web"
 23)
 24
 25// Templates are source, so they ship in the binary unconditionally; the Vite
 26// bundle is build output and only embeds in a release build.
 27//
 28//go:embed templates
 29var templateFS embed.FS
 30
 31const listenAddr = ":8000"
 32
 33// waitForListener blocks until this process answers its own health check. The
 34// health strip probes over loopback, so a first round started before Serve has
 35// bound the socket reports this site as unknown for a minute after every
 36// deploy. There is no deadline because a server that never binds has already
 37// exited on the error.
 38func waitForListener(ctx context.Context) {
 39	for {
 40		if err := web.HealthCheck("http://127.0.0.1:8000/healthz", time.Second); err == nil {
 41			return
 42		}
 43		select {
 44		case <-ctx.Done():
 45			return
 46		case <-time.After(50 * time.Millisecond):
 47		}
 48	}
 49}
 50
 51func dir(env, fallback string) string {
 52	if v := os.Getenv(env); v != "" {
 53		return v
 54	}
 55	return fallback
 56}
 57
 58// csp allows 'unsafe-inline' for the analytics collector snippet and for the
 59// sparkline paths, which are computed per quote and written as style attributes
 60// rather than into a stylesheet that would need a nonce per render.
 61func csp() string {
 62	return strings.Join([]string{
 63		"default-src 'self'",
 64		"script-src 'self' 'unsafe-inline' https://analytics.bythewood.me",
 65		"style-src 'self' 'unsafe-inline'",
 66		"img-src 'self' data:",
 67		"font-src 'self'",
 68		"connect-src 'self' https://analytics.bythewood.me",
 69		"base-uri 'self'",
 70		"form-action 'self'",
 71		"frame-ancestors 'self'",
 72	}, "; ")
 73}
 74
 75type site struct {
 76	renderer *web.Renderer
 77	store    *Store
 78	hub      *Hub
 79	guard    *Guard
 80
 81	script string
 82	styles []string
 83}
 84
 85func main() {
 86	web.SetupLogging()
 87
 88	healthcheck := flag.Bool("healthcheck", false, "probe a running server on this host and exit")
 89	flag.Parse()
 90
 91	if *healthcheck {
 92		if err := web.HealthCheck("http://127.0.0.1:8000/healthz", 3*time.Second); err != nil {
 93			slog.Info(fmt.Sprintf("healthcheck: %v", err))
 94			os.Exit(1)
 95		}
 96		return
 97	}
 98
 99	shipper := web.ShipLogs(selfSource, web.HTTPSink())
100	defer shipper.Close()
101
102	dist := distFS()
103
104	assets, err := web.LoadAssets(dist)
105	if err != nil {
106		slog.Error("startup failed", slog.Any("err", err))
107		os.Exit(1)
108	}
109
110	templates, err := fs.Sub(templateFS, "templates")
111	if err != nil {
112		slog.Error("startup failed", slog.Any("err", err))
113		os.Exit(1)
114	}
115
116	renderer, err := web.NewRenderer(
117		templates,
118		templateFuncs,
119		[]string{"base.html", "partials.html"},
120		[]string{"home.html", "notfound.html"},
121	)
122	if err != nil {
123		slog.Error("startup failed", slog.Any("err", err))
124		os.Exit(1)
125	}
126
127	dataDir := dir("SITE_DATA", "build/data")
128	if err := os.MkdirAll(dataDir, 0o755); err != nil {
129		slog.Error("startup failed", slog.Any("err", err))
130		os.Exit(1)
131	}
132
133	hub := NewHub()
134	guard := NewGuard(dataDir)
135	store := NewStore(hub)
136
137	s := &site{
138		renderer: renderer,
139		store:    store,
140		hub:      hub,
141		guard:    guard,
142		script:   assets.Script("index.js"),
143		styles:   assets.Styles("index.js"),
144	}
145
146	// Cancelled on shutdown so an in-flight fetch is abandoned rather than
147	// holding the process past the stop grace period.
148	pollCtx, stopPolling := context.WithCancel(context.Background())
149	defer stopPolling()
150
151	store.Prime(pollCtx, guard)
152	go func() {
153		waitForListener(pollCtx)
154		store.Run(pollCtx, guard)
155	}()
156
157	// The guard's counters only reach disk on Flush, and a container stop that
158	// skipped it would forget an open breaker.
159	stopping := make(chan os.Signal, 1)
160	signal.Notify(stopping, syscall.SIGINT, syscall.SIGTERM)
161	go func() {
162		<-stopping
163		stopPolling()
164		guard.Flush()
165	}()
166
167	mux := http.NewServeMux()
168
169	mux.HandleFunc("GET /{$}", s.home)
170	mux.HandleFunc("GET /events", s.events)
171	mux.HandleFunc("GET /api/state", s.state)
172
173	mux.HandleFunc("GET /favicon.ico", favicon)
174	mux.HandleFunc("GET /favicon.svg", favicon)
175	mux.HandleFunc("GET /robots.txt", robots)
176	mux.HandleFunc("GET /sitemap.xml", sitemap)
177
178	mux.Handle("GET /static/", web.Static(dist, assets))
179
180	mux.HandleFunc("GET /healthz", s.healthz)
181
182	mux.HandleFunc("GET /", s.notFound)
183
184	handler := web.Chain(mux,
185		web.Recovered,
186		web.Logged,
187		web.SecurityHeaders(csp()),
188	)
189
190	slog.Info(fmt.Sprintf("dash serving %s (staging=%t)", baseURL, Staging))
191	if err := web.Serve(listenAddr, handler); err != nil {
192		slog.Error("startup failed", slog.Any("err", err))
193		os.Exit(1)
194	}
195}