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

9.7 KB · 327 lines · Go Raw History
  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}