feat(webui): login interstitial with cookie auth (7d), hide token from main UI; auto-scroll tail/summary to bottom on update

This commit is contained in:
Thomas Cravey 2025-08-16 16:09:19 -05:00
parent 118cb921f0
commit a6091b8758

View file

@ -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 := `<!doctype html>
@ -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) {
<h1>sojuboy</h1>
<article>
<div class="grid">
<label>Auth token<input type="password" id="tok" placeholder="HTTP_TOKEN" oninput="setToken(this.value)"/></label>
<label>Channel<select id="channel" onchange="onChannelChange()"></select></label>
<label>Limit<input type="number" id="limit" value="25"/></label>
<label><input type="checkbox" id="follow" onchange="onFollowToggle(this)"/> Follow</label>
<a href="/logout" role="button" class="contrast">Logout</a>
<button onclick="doTail()">Refresh tail</button>
</div>
<pre id="tail"></pre>
@ -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 := `<!doctype html>
<html>
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<title>Sign in · sojuboy</title>
<link rel="stylesheet" href="https://unpkg.com/@picocss/pico@2/css/pico.min.css">
<style>body{padding:1rem;} main{max-width:480px;margin:auto;margin-top:15vh}</style>
</head>
<body>
<main class="container">
<article>
<h2>Sign in</h2>
<form id="f" method="post" action="/auth">
<label>Access token
<input type="password" name="token" autocomplete="current-password" required placeholder="HTTP_TOKEN"/>
</label>
<button type="submit">Continue</button>
</form>
</article>
</main>
</body>
</html>`
_, _ = 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)
}