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

6.9 KB · 230 lines · Go Raw History
  1package main
  2
  3import (
  4	"encoding/json"
  5	"fmt"
  6	"net/http"
  7	"net/netip"
  8	"time"
  9
 10	"dash.bythewood.me/web"
 11)
 12
 13// PageData is what base.html and home.html read.
 14type PageData struct {
 15	Title       string
 16	Description string
 17	Canonical   string
 18	Staging     bool
 19	Year        int
 20	BaseURL     string
 21	SourceURL   string
 22	SiteName    string
 23	AuthorName  string
 24	Script      string
 25	Styles      []string
 26	Favicon     string
 27	Analytics   bool
 28	AnalyticsID string
 29
 30	// Every upstream the page reads, for the footer. Built from the same list
 31	// the UPLINK panel is built from, since the hand written version of this
 32	// named five of the thirteen and there was nothing to notice the other
 33	// eight arriving.
 34	Sources []string
 35
 36	// The tab's own wording, handed to the page so the script that keeps the
 37	// title live does not carry a second copy of it.
 38	TitleBase string
 39
 40	State State
 41}
 42
 43func (s *site) home(w http.ResponseWriter, r *http.Request) {
 44	data := PageData{
 45		Title:       "",
 46		Description: "Markets, news and the state of everything " + authorName + " runs, on one page.",
 47		Canonical:   baseURL + "/",
 48		Staging:     Staging,
 49		Year:        time.Now().Year(),
 50		BaseURL:     baseURL,
 51		SourceURL:   sourceURL,
 52		SiteName:    siteName,
 53		AuthorName:  authorName,
 54		Script:      s.script,
 55		Styles:      s.styles,
 56		Favicon:     faviconHref,
 57		Analytics:   !Staging,
 58		AnalyticsID: analyticsID,
 59		Sources:     sourceNames(),
 60		TitleBase:   titleBase,
 61		State:       s.store.Snapshot(),
 62	}
 63	s.renderer.Render(w, http.StatusOK, "home.html", data)
 64}
 65
 66// state is the same snapshot the page was rendered from, for anything that
 67// wants to read it without holding an SSE connection open.
 68func (s *site) state(w http.ResponseWriter, r *http.Request) {
 69	w.Header().Set("Content-Type", "application/json; charset=utf-8")
 70	w.Header().Set("Cache-Control", "no-store")
 71	enc := json.NewEncoder(w)
 72	enc.SetIndent("", "  ")
 73	_ = enc.Encode(s.store.Snapshot())
 74}
 75
 76// events is the live feed. The browser opens one EventSource and every poll
 77// that changes anything arrives here, so the page never polls this server and
 78// this server never polls an upstream per viewer.
 79func (s *site) events(w http.ResponseWriter, r *http.Request) {
 80	// ResponseController rather than a type assertion for http.Flusher, because
 81	// the request logger wraps the writer and an assertion would see the
 82	// wrapper. It follows Unwrap down to the real one.
 83	rc := http.NewResponseController(w)
 84
 85	// web/server.go sets no write bound for this site, and this clears any
 86	// per-connection deadline anyway, so a stream is never cut mid-frame. It
 87	// doubles as the check that this writer can be flushed at all.
 88	if err := rc.SetWriteDeadline(time.Time{}); err != nil {
 89		http.Error(w, "streaming unsupported", http.StatusInternalServerError)
 90		return
 91	}
 92
 93	w.Header().Set("Content-Type", "text/event-stream")
 94	w.Header().Set("Cache-Control", "no-store")
 95	w.Header().Set("Connection", "keep-alive")
 96	// Caddy is configured not to compress this path and Cloudflare streams it,
 97	// but a proxy that reads this header is one that would otherwise sit on the
 98	// frames until its buffer filled.
 99	w.Header().Set("X-Accel-Buffering", "no")
100	w.WriteHeader(http.StatusOK)
101	_ = rc.Flush()
102
103	frames, unsubscribe := s.hub.Subscribe()
104	defer unsubscribe()
105
106	// Cloudflare drops an idle connection at 100 seconds, so a comment goes out
107	// well inside that whenever there is nothing else to send. The browser
108	// ignores it and the connection stays open.
109	keepalive := time.NewTicker(25 * time.Second)
110	defer keepalive.Stop()
111
112	for {
113		select {
114		case <-r.Context().Done():
115			return
116
117		case frame, open := <-frames:
118			if !open {
119				return
120			}
121			if _, err := fmt.Fprintf(w, "data: %s\n\n", frame); err != nil {
122				return
123			}
124			if err := rc.Flush(); err != nil {
125				return
126			}
127
128		case <-keepalive.C:
129			if _, err := fmt.Fprint(w, ": keepalive\n\n"); err != nil {
130				return
131			}
132			if err := rc.Flush(); err != nil {
133				return
134			}
135		}
136	}
137}
138
139// healthz stays shallow: this process is serving or it is not, and a failing
140// upstream is not fixed by the restart a failing check would cause. The panels
141// say so on the page instead.
142func (s *site) healthz(w http.ResponseWriter, r *http.Request) {
143	if !r.URL.Query().Has("verbose") || !isLoopback(web.ClientIP(r)) {
144		w.Header().Set("Content-Type", "text/plain; charset=utf-8")
145		// EdgeCache fills in the site policy whenever a handler sets no
146		// Cache-Control of its own, so saying nothing here means the edge
147		// answers a liveness check out of cache long after this process has
148		// stopped serving.
149		w.Header().Set("Cache-Control", "no-store")
150		_, _ = w.Write([]byte("ok\n"))
151		return
152	}
153
154	w.Header().Set("Content-Type", "application/json; charset=utf-8")
155	w.Header().Set("Cache-Control", "no-store")
156	enc := json.NewEncoder(w)
157	enc.SetIndent("", "  ")
158	_ = enc.Encode(map[string]any{
159		"watching": s.hub.Watching(),
160		"guarded":  s.guard.Status(),
161		"updated":  s.store.Snapshot().Updated,
162	})
163}
164
165func isLoopback(ip string) bool {
166	addr, err := netip.ParseAddr(ip)
167	return err == nil && addr.IsLoopback()
168}
169
170func (s *site) notFound(w http.ResponseWriter, r *http.Request) {
171	data := PageData{
172		Title:       "404",
173		Description: "That page does not exist.",
174		Canonical:   baseURL + r.URL.Path,
175		Staging:     Staging,
176		Year:        time.Now().Year(),
177		BaseURL:     baseURL,
178		SourceURL:   sourceURL,
179		SiteName:    siteName,
180		AuthorName:  authorName,
181		Script:      s.script,
182		Styles:      s.styles,
183		Favicon:     faviconHref,
184		Analytics:   !Staging,
185		AnalyticsID: analyticsID,
186		Sources:     sourceNames(),
187		TitleBase:   titleBase,
188	}
189	s.renderer.Render(w, http.StatusNotFound, "notfound.html", data)
190}
191
192func robots(w http.ResponseWriter, r *http.Request) {
193	w.Header().Set("Content-Type", "text/plain; charset=utf-8")
194	body := "User-agent: *\nDisallow: /\n"
195	if !Staging {
196		// /events is a connection a crawler would hold open until it timed out,
197		// and /api/state is the same page as JSON.
198		body = "User-agent: *\nDisallow: /events\nDisallow: /api/\nAllow: /\n" +
199			"Sitemap: " + baseURL + "/sitemap.xml\n"
200	}
201	_, _ = w.Write([]byte(body))
202}
203
204func sitemap(w http.ResponseWriter, r *http.Request) {
205	w.Header().Set("Content-Type", "application/xml; charset=utf-8")
206	_, _ = fmt.Fprintf(w, `<?xml version="1.0" encoding="UTF-8"?>
207<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
208  <url><loc>%s/</loc></url>
209</urlset>
210`, baseURL)
211}
212
213// faviconHref carries the content hash, so replacing the icon replaces the URL.
214var faviconHref = "/favicon.svg?v=" + faviconVersion
215
216// sourceNames is the footer's credit line. Reading it off feedOrder is what
217// keeps it honest, since the two are the same list and a source added to one
218// has to appear in the other.
219func sourceNames() []string {
220	out := make([]string, 0, len(feedOrder))
221	for _, f := range feedOrder {
222		// Isaac's own logging site, which is not an outside source to credit.
223		if f.key == "logging" {
224			continue
225		}
226		out = append(out, f.label)
227	}
228	return out
229}