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 "html/template"
6 "log/slog"
7 "net/http"
8 "strconv"
9 "time"
10)
11
12// PageData is what every template gets. There is no analytics snippet here:
13// site is a login form, and pointing a third script at the page somebody types
14// a one time code into is the wrong trade even when the third script is ours.
15type PageData struct {
16 Title string
17 Description string
18 Path string
19 Canonical string
20 Staging bool
21 Year int
22 BaseURL string
23 SourceURL string
24 SiteName string
25 AuthorName string
26 Script string
27 Styles []string
28 PageScript string
29 PageStyles []string
30 Analytics bool
31 AnalyticsID string
32
33 Authenticated bool
34 Username string
35
36 // Set by whichever handler needs them.
37 Next string
38 Error string
39 Notice string
40 InSudo bool
41 Sessions []Session
42 Events []Event
43 Remaining int
44 NtfyHost string
45 NtfyUser string
46 NtfyTopic string
47 NewCodes []string
48 User User
49}
50
51func (s *site) page(r *http.Request, title, description string) PageData {
52 _, err := lookupSession(s.db, r)
53 name := ""
54 if u, uerr := loadUser(s.db); uerr == nil {
55 name = u.Username
56 }
57 return PageData{
58 Title: title,
59 Description: description,
60 Path: r.URL.Path,
61 Canonical: baseURL + r.URL.Path,
62 Staging: Staging,
63 Authenticated: err == nil,
64 Username: name,
65 Year: time.Now().Year(),
66 BaseURL: baseURL,
67 SourceURL: sourceURL,
68 SiteName: siteName,
69 AuthorName: authorName,
70 Script: s.baseScript,
71 Styles: s.baseStyles,
72 PageScript: s.pagesScript,
73 PageStyles: s.pagesStyles,
74 Analytics: !Staging,
75 AnalyticsID: analyticsID,
76 }
77}
78
79func (s *site) loginError(w http.ResponseWriter, r *http.Request, next, msg string, status int) {
80 data := s.page(r, "Sign in", "")
81 data.Next = next
82 data.Error = msg
83 s.renderer.Render(w, status, "login.html", data)
84}
85
86func (s *site) recoveryError(w http.ResponseWriter, r *http.Request, next, msg string, status int) {
87 data := s.page(r, "Recovery code", "")
88 data.Next = next
89 data.Error = msg
90 s.renderer.Render(w, status, "recovery.html", data)
91}
92
93func (s *site) codePage(w http.ResponseWriter, r *http.Request, next, msg string, status int) {
94 data := s.page(r, "Enter your code", "")
95 data.Next = next
96 if status >= 400 {
97 data.Error = msg
98 } else {
99 data.Notice = msg
100 }
101 s.renderer.Render(w, status, "code.html", data)
102}
103
104// uninitialized is what a fresh install serves until `make auth-init` has run.
105// It names the command rather than offering to create the account over HTTP,
106// which would let whoever found the hostname first claim it.
107func (s *site) uninitialized(w http.ResponseWriter, r *http.Request) {
108 data := s.page(r, "Not set up", "This installation has no account yet.")
109 s.renderer.Render(w, http.StatusServiceUnavailable, "uninitialized.html", data)
110}
111
112func (s *site) fail(w http.ResponseWriter, r *http.Request, doing string, err error) {
113 slog.Error("auth: "+doing+" failed",
114 slog.String("component", "auth"),
115 slog.Any("err", err))
116 data := s.page(r, "Something went wrong", "That did not work.")
117 data.Error = "Something went wrong " + doing + "."
118 s.renderer.Render(w, http.StatusInternalServerError, "error.html", data)
119}
120
121// requireAuth gates everything the operator alone may reach, carrying the
122// original destination so a bookmark survives the login.
123func (s *site) requireAuth(next func(http.ResponseWriter, *http.Request, live)) http.HandlerFunc {
124 return func(w http.ResponseWriter, r *http.Request) {
125 l, err := lookupSession(s.db, r)
126 if err != nil {
127 http.Redirect(w, r, "/login?next="+returnTo(r), http.StatusSeeOther)
128 return
129 }
130 next(w, r, l)
131 }
132}
133
134// requireSudo additionally wants a login inside the last few minutes, so a
135// cookie stolen later cannot rotate the credentials that would keep it alive.
136func (s *site) requireSudo(next func(http.ResponseWriter, *http.Request, live)) http.HandlerFunc {
137 return s.requireAuth(func(w http.ResponseWriter, r *http.Request, l live) {
138 if !l.inSudo() {
139 http.Redirect(w, r, "/login?next="+returnTo(r), http.StatusSeeOther)
140 return
141 }
142 next(w, r, l)
143 })
144}
145
146// returnTo is the current URL, escaped for the ?next= that survives a login.
147func returnTo(r *http.Request) string {
148 return template.URLQueryEscaper(r.URL.RequestURI())
149}
150
151func (s *site) account(w http.ResponseWriter, r *http.Request, l live) {
152 user, err := loadUser(s.db)
153 if err != nil {
154 s.uninitialized(w, r)
155 return
156 }
157 remaining, err := countRecoveryCodes(s.db)
158 if err != nil {
159 s.fail(w, r, "counting recovery codes", err)
160 return
161 }
162
163 data := s.page(r, "Account", "")
164 data.User = user
165 data.Remaining = remaining
166 data.InSudo = l.inSudo()
167 s.renderer.Render(w, http.StatusOK, "account.html", data)
168}
169
170func (s *site) sessions(w http.ResponseWriter, r *http.Request, l live) {
171 list, err := listSessions(s.db, l.Hash)
172 if err != nil {
173 s.fail(w, r, "listing sessions", err)
174 return
175 }
176 data := s.page(r, "Sessions", "Where this account is signed in.")
177 data.Sessions = list
178 s.renderer.Render(w, http.StatusOK, "sessions.html", data)
179}
180
181func (s *site) revoke(w http.ResponseWriter, r *http.Request, l live) {
182 if err := r.ParseForm(); err != nil {
183 http.Error(w, "bad request", http.StatusBadRequest)
184 return
185 }
186 id, err := strconv.ParseInt(r.PostFormValue("id"), 10, 64)
187 if err != nil {
188 http.Error(w, "bad request", http.StatusBadRequest)
189 return
190 }
191 if err := revokeSession(s.db, id); err != nil {
192 s.fail(w, r, "revoking a session", err)
193 return
194 }
195 audit(s.db, r, evSessionRevoked, "one")
196
197 // Revoking your own is a logout, and leaving the cookie in place would
198 // leave the browser holding one this site no longer honours.
199 if id == l.ID {
200 clearSessionCookie(w)
201 http.Redirect(w, r, "/", http.StatusSeeOther)
202 return
203 }
204 http.Redirect(w, r, "/sessions", http.StatusSeeOther)
205}
206
207func (s *site) revokeOthers(w http.ResponseWriter, r *http.Request, l live) {
208 n, err := revokeOthers(s.db, l.ID)
209 if err != nil {
210 s.fail(w, r, "revoking sessions", err)
211 return
212 }
213 audit(s.db, r, evSessionRevoked, strconv.FormatInt(n, 10)+" others")
214 http.Redirect(w, r, "/sessions", http.StatusSeeOther)
215}
216
217// security holds the credentials half. The ntfy password is not on it and
218// cannot be: ntfy stores it hashed and will not hand one back, and the only
219// ways around that are keeping a second reversible copy here or giving this
220// container the Docker socket. `make ntfy-passwd` is how it is changed.
221func (s *site) security(w http.ResponseWriter, r *http.Request, l live) {
222 user, err := loadUser(s.db)
223 if err != nil {
224 s.uninitialized(w, r)
225 return
226 }
227 remaining, err := countRecoveryCodes(s.db)
228 if err != nil {
229 s.fail(w, r, "counting recovery codes", err)
230 return
231 }
232
233 data := s.page(r, "Security", "Recovery codes and how the phone connects.")
234 data.User = user
235 data.Remaining = remaining
236 data.InSudo = l.inSudo()
237 data.NtfyHost = ntfyPublicURL
238 data.NtfyUser = user.NtfyAccount
239 data.NtfyTopic = ntfyTopic
240 s.renderer.Render(w, http.StatusOK, "security.html", data)
241}
242
243func (s *site) rotateRecovery(w http.ResponseWriter, r *http.Request, l live) {
244 codes, err := regenerateRecoveryCodes(s.db)
245 if err != nil {
246 s.fail(w, r, "generating recovery codes", err)
247 return
248 }
249 audit(s.db, r, evRecoveryRotated, "")
250
251 user, _ := loadUser(s.db)
252 data := s.page(r, "Recovery codes", "Written down once and never shown again.")
253 data.User = user
254 data.NewCodes = codes
255 data.Remaining = len(codes)
256 // Rendered rather than redirected, because a redirect would need the codes
257 // to survive somewhere between two requests and there is nowhere safe to
258 // put them.
259 s.renderer.Render(w, http.StatusOK, "codes.html", data)
260}
261
262func (s *site) changeUsername(w http.ResponseWriter, r *http.Request, l live) {
263 if err := r.ParseForm(); err != nil {
264 http.Error(w, "bad request", http.StatusBadRequest)
265 return
266 }
267 name := r.PostFormValue("username")
268 if err := setUsername(s.db, name); err != nil {
269 user, _ := loadUser(s.db)
270 remaining, _ := countRecoveryCodes(s.db)
271 data := s.page(r, "Account", "")
272 data.User = user
273 data.Remaining = remaining
274 data.InSudo = true
275 data.Error = err.Error()
276 s.renderer.Render(w, http.StatusBadRequest, "account.html", data)
277 return
278 }
279 audit(s.db, r, evUsernameChanged, "")
280 http.Redirect(w, r, "/account", http.StatusSeeOther)
281}
282
283func (s *site) activity(w http.ResponseWriter, r *http.Request, l live) {
284 events, err := recentEvents(s.db, 200)
285 if err != nil {
286 s.fail(w, r, "reading the activity log", err)
287 return
288 }
289 data := s.page(r, "Activity", "Every authentication event, newest first.")
290 data.Events = events
291 s.renderer.Render(w, http.StatusOK, "activity.html", data)
292}
293
294// verify is what the other sites call over the bridge to turn a cookie into an
295// answer. Caddy refuses this path on the public hostname, so reaching it really
296// does mean being inside the network.
297//
298// It returns the username and nothing else. A site behind this needs to know
299// somebody is signed in, and there is one somebody.
300func (s *site) verify(w http.ResponseWriter, r *http.Request) {
301 w.Header().Set("Content-Type", "application/json; charset=utf-8")
302 w.Header().Set("Cache-Control", "no-store")
303
304 l, err := lookupSession(s.db, r)
305 if err != nil {
306 w.WriteHeader(http.StatusUnauthorized)
307 _ = json.NewEncoder(w).Encode(map[string]any{"ok": false})
308 return
309 }
310 user, err := loadUser(s.db)
311 if err != nil {
312 w.WriteHeader(http.StatusUnauthorized)
313 _ = json.NewEncoder(w).Encode(map[string]any{"ok": false})
314 return
315 }
316 _ = json.NewEncoder(w).Encode(map[string]any{
317 "ok": true,
318 "username": user.Username,
319 "sudo": l.inSudo(),
320 })
321}
322
323func (s *site) notFound(w http.ResponseWriter, r *http.Request) {
324 data := s.page(r, "404", "That page does not exist.")
325 s.renderer.Render(w, http.StatusNotFound, "notfound.html", data)
326}