From a6091b8758366b1803a288f51654ce590852a6d2 Mon Sep 17 00:00:00 2001 From: Thomas Cravey Date: Sat, 16 Aug 2025 16:09:19 -0500 Subject: [PATCH] feat(webui): login interstitial with cookie auth (7d), hide token from main UI; auto-scroll tail/summary to bottom on update --- internal/httpapi/server.go | 85 ++++++++++++++++++++++++++++++++++++-- 1 file changed, 82 insertions(+), 3 deletions(-) diff --git a/internal/httpapi/server.go b/internal/httpapi/server.go index 8a721f9..fd26694 100644 --- a/internal/httpapi/server.go +++ b/internal/httpapi/server.go @@ -48,6 +48,9 @@ func (s *Server) Start(ctx context.Context) error { mux := http.NewServeMux() // Minimal web UI mux.HandleFunc("/", s.handleUI) + mux.HandleFunc("/login", s.handleLogin) + mux.HandleFunc("/auth", s.handleAuth) + mux.HandleFunc("/logout", s.handleLogout) mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte("ok")) @@ -206,6 +209,13 @@ func (s *Server) handleUI(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNotFound) return } + // redirect to login if token cookie missing/invalid + if s.AuthToken != "" { + if c, err := r.Cookie("auth_token"); err != nil || c.Value != s.AuthToken { + http.Redirect(w, r, "/login", http.StatusFound) + return + } + } w.Header().Set("Content-Type", "text/html; charset=utf-8") // Pico.css from CDN and a tiny app page := ` @@ -231,6 +241,7 @@ func (s *Server) handleUI(w http.ResponseWriter, r *http.Request) { const url = new URL(path, window.location.origin); if(params && params.query){ Object.entries(params.query).forEach(([k,v])=>url.searchParams.set(k,v)); } const opts = { headers: {} }; + // use cookie for auth; header optional if present if(st.token){ opts.headers['Authorization'] = 'Bearer '+st.token; } const res = await fetch(url, opts); if(!res.ok){ throw new Error('HTTP '+res.status); } @@ -259,7 +270,9 @@ func (s *Server) handleUI(w http.ResponseWriter, r *http.Request) { const lim = document.getElementById('limit').value || '100'; try{ const data = await api('/api/tail',{query:{channel:ch,limit:lim}}); const out = data.map(m => (m.time + ' ' + m.author + ': ' + m.body)).join('\n'); - document.getElementById('tail').textContent = out; + const el = document.getElementById('tail'); + el.textContent = out; + el.scrollTop = el.scrollHeight; }catch(e){ document.getElementById('tail').textContent = 'error: '+e; } st.tailLoading=false; } @@ -271,7 +284,9 @@ func (s *Server) handleUI(w http.ResponseWriter, r *http.Request) { const prog = document.getElementById('summProg'); btn.disabled = true; prog.style.display = 'inline-block'; try{ const data = await api('/api/trigger',{query:{channel:ch,window:win,push:push}}); - document.getElementById('summary').textContent = data.summary || '(empty)'; + const el = document.getElementById('summary'); + el.textContent = data.summary || '(empty)'; + el.scrollTop = el.scrollHeight; }catch(e){ document.getElementById('summary').textContent = 'error: '+e; } btn.disabled = false; prog.style.display = 'none'; } @@ -292,10 +307,10 @@ func (s *Server) handleUI(w http.ResponseWriter, r *http.Request) {

sojuboy

- + Logout

@@ -421,6 +436,10 @@ func checkAuth(r *http.Request, token string) bool {
 	if r.Header.Get("X-Auth-Token") == token {
 		return true
 	}
+	// Cookie-based
+	if c, err := r.Cookie("auth_token"); err == nil && c.Value == token {
+		return true
+	}
 	return false
 }
 
@@ -432,3 +451,63 @@ func getIntQuery(r *http.Request, key string, def int) int {
 	}
 	return def
 }
+
+// --- Login handlers ---
+
+func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
+	if s.AuthToken == "" {
+		http.Redirect(w, r, "/", http.StatusFound)
+		return
+	}
+	// If already authed, go to UI
+	if c, err := r.Cookie("auth_token"); err == nil && c.Value == s.AuthToken {
+		http.Redirect(w, r, "/", http.StatusFound)
+		return
+	}
+	w.Header().Set("Content-Type", "text/html; charset=utf-8")
+	page := `
+
+
+  
+  
+  Sign in ยท sojuboy
+  
+  
+
+
+  
+
+

Sign in

+
+ + +
+
+
+ +` + _, _ = w.Write([]byte(page)) +} + +func (s *Server) handleAuth(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { w.WriteHeader(http.StatusBadRequest); _, _ = w.Write([]byte("bad request")); return } + tok := r.Form.Get("token") + if tok == "" || s.AuthToken == "" || tok != s.AuthToken { + w.WriteHeader(http.StatusUnauthorized) + _, _ = w.Write([]byte("unauthorized")) + return + } + // set cookie for 7 days + maxAge := 7 * 24 * 60 * 60 + secure := r.TLS != nil || strings.EqualFold(r.Header.Get("X-Forwarded-Proto"), "https") + http.SetCookie(w, &http.Cookie{Name:"auth_token", Value:tok, Path:"/", MaxAge:maxAge, HttpOnly:true, Secure:secure, SameSite:http.SameSiteLaxMode}) + w.Header().Set("Location", "/") + w.WriteHeader(http.StatusFound) +} + +func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) { + http.SetCookie(w, &http.Cookie{Name:"auth_token", Value:"", Path:"/", MaxAge:-1}) + http.Redirect(w, r, "/login", http.StatusFound) +}