orchard
mirrorEvery 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
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}