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

8.7 KB · 284 lines · Go Raw History
  1// status.bythewood.me is single-operator uptime monitoring. An in-process
  2// scheduler probes every tracked URL, audits it with Lighthouse and crawls it
  3// for SEO; the handlers mostly render what the scheduler wrote.
  4package main
  5
  6import (
  7	"context"
  8	"database/sql"
  9	"embed"
 10	"flag"
 11	"fmt"
 12	"io/fs"
 13	"log/slog"
 14	"net/http"
 15	"os"
 16	"os/signal"
 17	"strings"
 18	"syscall"
 19	"time"
 20
 21	"github.com/google/uuid"
 22	"status.bythewood.me/web"
 23)
 24
 25// Templates are source and ship in the binary unconditionally; the Vite bundle
 26// is build output and ships only in a release build.
 27//
 28//go:embed templates
 29var templateFS embed.FS
 30
 31const listenAddr = ":8000"
 32
 33func dir(env, fallback string) string {
 34	if v := os.Getenv(env); v != "" {
 35		return v
 36	}
 37	return fallback
 38}
 39
 40// script-src needs 'unsafe-inline' because the dashboard ships its chart data as
 41// inline <script type="application/json"> blocks, and style-src carries it for
 42// Bootstrap's inline style attributes.
 43func csp() string {
 44	return strings.Join([]string{
 45		"default-src 'self'",
 46		"script-src 'self' 'unsafe-inline' https://analytics.bythewood.me",
 47		"style-src 'self' 'unsafe-inline'",
 48		"img-src 'self' data:",
 49		"font-src 'self'",
 50		"connect-src 'self' https://analytics.bythewood.me",
 51		"base-uri 'self'",
 52		"form-action 'self'",
 53		"frame-ancestors 'self'",
 54	}, "; ")
 55}
 56
 57// site is everything the handlers share.
 58type site struct {
 59	renderer *web.Renderer
 60	db       *sql.DB
 61	dist     fs.FS
 62	assets   *web.Assets
 63	typst    *Typst
 64
 65	auth *web.Authenticator
 66
 67	baseScript  string
 68	baseStyles  []string
 69	pagesScript string
 70	pagesStyles []string
 71	propsScript string
 72	propsStyles []string
 73}
 74
 75// The template sets, shared with the tests, which parse them for real. A page
 76// listed here with no file behind it parses at boot and not at build, so
 77// nothing but doing it catches a template that was deleted and left listed.
 78var (
 79	layoutTemplates = []string{"base.html", "partials.html"}
 80	pageTemplates   = []string{
 81		"home.html",
 82		"properties.html", "property.html", "notfound.html",
 83	}
 84)
 85
 86func main() {
 87	web.SetupLogging()
 88
 89	previewKind := flag.String("preview-alert", "",
 90		"print the ntfy notification for 'down' or 'recovery' and exit")
 91	// The container HEALTHCHECK runs this, since a scratch image has no shell.
 92	healthcheck := flag.Bool("healthcheck", false, "probe a running server on this host and exit")
 93	flag.Parse()
 94
 95	if *healthcheck {
 96		if err := web.HealthCheck("http://127.0.0.1:8000/healthz", 3*time.Second); err != nil {
 97			slog.Info(fmt.Sprintf("healthcheck: %v", err))
 98			os.Exit(1)
 99		}
100		return
101	}
102
103	// Tees onto the stdout handler rather than replacing it. Kept after the
104	// healthcheck branch, so a HEALTHCHECK never starts a queue it cannot flush.
105	shipper := web.ShipLogs("status", web.HTTPSink())
106	defer shipper.Close()
107
108	if *previewKind != "" {
109		if err := previewAlert(*previewKind); err != nil {
110			slog.Error("startup failed", slog.Any("err", err))
111			os.Exit(1)
112		}
113		return
114	}
115
116	dataDir := dir("SITE_DATA", "data")
117	db, err := openDB(dataDir + "/db.sqlite3")
118	if err != nil {
119		slog.Error("startup failed", slog.Any("err", err))
120		os.Exit(1)
121	}
122	defer db.Close()
123
124	dist := distFS()
125
126	assets, err := web.LoadAssets(dist)
127	if err != nil {
128		slog.Error("startup failed", slog.Any("err", err))
129		os.Exit(1)
130	}
131
132	templates, err := fs.Sub(templateFS, "templates")
133	if err != nil {
134		slog.Error("startup failed", slog.Any("err", err))
135		os.Exit(1)
136	}
137
138	renderer, err := web.NewRenderer(templates, templateFuncs, layoutTemplates, pageTemplates)
139	if err != nil {
140		slog.Error("startup failed", slog.Any("err", err))
141		os.Exit(1)
142	}
143
144	s := &site{
145		renderer:    renderer,
146		db:          db,
147		dist:        dist,
148		assets:      assets,
149		typst:       NewTypst(),
150		auth:        web.NewAuthenticator(),
151		baseScript:  assets.Script("static_src/base/index.js"),
152		baseStyles:  assets.Styles("static_src/base/index.js"),
153		pagesScript: assets.Script("static_src/pages/index.js"),
154		pagesStyles: assets.Styles("static_src/pages/index.js"),
155		propsScript: assets.Script("static_src/properties/index.js"),
156		propsStyles: assets.Styles("static_src/properties/index.js"),
157	}
158
159	// The scheduler stops with the process, so a deploy does not kill a crawl
160	// halfway and leave the row wedged for the watchdog to find.
161	ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
162	defer stop()
163
164	scheduler := NewScheduler(db, NewNotifier(), dir("SITE_ROOT", "."))
165	if err := scheduler.ResetOnBoot(ctx); err != nil {
166		slog.Error(fmt.Sprintf("reset wedged states: %v", err))
167		os.Exit(1)
168	}
169	go scheduler.Run(ctx)
170
171	mux := http.NewServeMux()
172
173	mux.HandleFunc("GET /{$}", s.home)
174
175	// Signing in happens on auth.bythewood.me. This stays so an old bookmark
176	// and the "access dashboard" button both land somewhere useful.
177	mux.HandleFunc("GET /login", func(w http.ResponseWriter, r *http.Request) {
178		http.Redirect(w, r, web.LoginURL(r), http.StatusSeeOther)
179	})
180
181	mux.HandleFunc("GET /properties", s.auth.RequireAuth(s.properties))
182	mux.HandleFunc("POST /properties", s.auth.RequireAuth(s.propertyCreate))
183	mux.HandleFunc("POST /properties/{id}/delete", s.auth.RequireAuth(s.propertyDelete))
184	mux.HandleFunc("POST /properties/{id}/public", s.auth.RequireAuthJSON(s.propertyPublic))
185
186	// Reachable without a session for a public property; the handler does that
187	// check itself, because it needs the property row to know.
188	mux.HandleFunc("GET /properties/{id}/status", s.propertyStatus)
189	// Every property in one answer, for chat.bythewood.me's tools. Behind the
190	// session, since it does not filter out the private ones.
191	mux.HandleFunc("GET /api/properties", s.auth.RequireAuthJSON(s.apiProperties))
192	mux.HandleFunc("POST /properties/{id}/recrawl", s.auth.RequireAuthJSON(s.propertyRecrawl))
193	mux.HandleFunc("POST /properties/{id}/rerun-lighthouse", s.auth.RequireAuthJSON(s.propertyRerunLighthouse))
194
195	// A wrong method on an existing route should be 405 with Allow. The mux only
196	// does that when nothing else matches, and "GET /" below always matches.
197	for path, allow := range map[string]string{
198		"/properties/{id}/delete":           "POST",
199		"/properties/{id}/public":           "POST",
200		"/properties/{id}/recrawl":          "POST",
201		"/properties/{id}/rerun-lighthouse": "POST",
202	} {
203		mux.HandleFunc("GET "+path, methodNotAllowed(allow))
204	}
205
206	mux.HandleFunc("GET /favicon.ico", favicon)
207	mux.HandleFunc("GET /robots.txt", robots)
208	mux.HandleFunc("GET /sitemap.xml", sitemap)
209
210	mux.Handle("GET /static/", web.Static(dist, assets))
211
212	mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) {
213		w.Header().Set("Content-Type", "text/plain; charset=utf-8")
214		// EdgeCache fills in the site policy whenever a handler sets no
215		// Cache-Control of its own, so saying nothing here means the edge
216		// answers a liveness check out of cache long after this process has
217		// stopped serving.
218		w.Header().Set("Cache-Control", "no-store")
219		_, _ = w.Write([]byte("ok\n"))
220	})
221
222	// Registered as "/" rather than "/{id}" so it cannot shadow /login and
223	// /properties; "/{id}" would also claim /nonsense-that-should-404.
224	mux.HandleFunc("GET /", s.dashboardOrNotFound)
225
226	handler := web.Chain(mux,
227		web.Recovered,
228		web.Logged,
229		web.SecurityHeaders(csp()),
230	)
231
232	slog.Info(fmt.Sprintf("status serving %s (staging=%t)", baseURL, Staging))
233	if err := web.Serve(listenAddr, handler); err != nil {
234		slog.Error("startup failed", slog.Any("err", err))
235		os.Exit(1)
236	}
237}
238
239func methodNotAllowed(allow string) http.HandlerFunc {
240	return func(w http.ResponseWriter, r *http.Request) {
241		w.Header().Set("Allow", allow)
242		http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
243	}
244}
245
246// dashboardOrNotFound routes a bare "/<uuid>" to a dashboard and everything
247// else to a 404.
248func (s *site) dashboardOrNotFound(w http.ResponseWriter, r *http.Request) {
249	slug := strings.Trim(r.URL.Path, "/")
250	if id, err := uuid.Parse(slug); err == nil && !strings.Contains(slug, "/") {
251		s.dashboard(w, r, id)
252		return
253	}
254	s.notFound(w, r)
255}
256
257func (s *site) notFound(w http.ResponseWriter, r *http.Request) {
258	data := s.page(r, "404", "That page does not exist.")
259	s.renderer.Render(w, http.StatusNotFound, "notfound.html", data)
260}
261
262// page builds the half of PageData every template needs.
263func (s *site) page(r *http.Request, title, description string) PageData {
264	return PageData{
265		Title:         title,
266		Description:   description,
267		Path:          r.URL.Path,
268		Canonical:     baseURL + r.URL.Path,
269		Staging:       Staging,
270		Analytics:     !Staging,
271		AnalyticsID:   analyticsID,
272		Authenticated: s.auth.Authenticated(r),
273		Year:          time.Now().Year(),
274		BaseURL:       baseURL,
275		SourceURL:     sourceURL,
276		OGImage:       baseURL + "/static/og/card.png",
277		JSONLD:        pageGraph(title, description, baseURL+r.URL.Path),
278		SiteName:      siteName,
279		AuthorName:    authorName,
280		Script:        s.baseScript,
281		Styles:        s.baseStyles,
282	}
283}