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:
parent
118cb921f0
commit
a6091b8758
1 changed files with 82 additions and 3 deletions
|
|
@ -48,6 +48,9 @@ func (s *Server) Start(ctx context.Context) error {
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
// Minimal web UI
|
// Minimal web UI
|
||||||
mux.HandleFunc("/", s.handleUI)
|
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) {
|
mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
|
||||||
w.WriteHeader(http.StatusOK)
|
w.WriteHeader(http.StatusOK)
|
||||||
_, _ = w.Write([]byte("ok"))
|
_, _ = w.Write([]byte("ok"))
|
||||||
|
|
@ -206,6 +209,13 @@ func (s *Server) handleUI(w http.ResponseWriter, r *http.Request) {
|
||||||
w.WriteHeader(http.StatusNotFound)
|
w.WriteHeader(http.StatusNotFound)
|
||||||
return
|
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")
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
// Pico.css from CDN and a tiny app
|
// Pico.css from CDN and a tiny app
|
||||||
page := `<!doctype html>
|
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);
|
const url = new URL(path, window.location.origin);
|
||||||
if(params && params.query){ Object.entries(params.query).forEach(([k,v])=>url.searchParams.set(k,v)); }
|
if(params && params.query){ Object.entries(params.query).forEach(([k,v])=>url.searchParams.set(k,v)); }
|
||||||
const opts = { headers: {} };
|
const opts = { headers: {} };
|
||||||
|
// use cookie for auth; header optional if present
|
||||||
if(st.token){ opts.headers['Authorization'] = 'Bearer '+st.token; }
|
if(st.token){ opts.headers['Authorization'] = 'Bearer '+st.token; }
|
||||||
const res = await fetch(url, opts);
|
const res = await fetch(url, opts);
|
||||||
if(!res.ok){ throw new Error('HTTP '+res.status); }
|
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';
|
const lim = document.getElementById('limit').value || '100';
|
||||||
try{ const data = await api('/api/tail',{query:{channel:ch,limit:lim}});
|
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');
|
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; }
|
}catch(e){ document.getElementById('tail').textContent = 'error: '+e; }
|
||||||
st.tailLoading=false;
|
st.tailLoading=false;
|
||||||
}
|
}
|
||||||
|
|
@ -271,7 +284,9 @@ func (s *Server) handleUI(w http.ResponseWriter, r *http.Request) {
|
||||||
const prog = document.getElementById('summProg');
|
const prog = document.getElementById('summProg');
|
||||||
btn.disabled = true; prog.style.display = 'inline-block';
|
btn.disabled = true; prog.style.display = 'inline-block';
|
||||||
try{ const data = await api('/api/trigger',{query:{channel:ch,window:win,push:push}});
|
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; }
|
}catch(e){ document.getElementById('summary').textContent = 'error: '+e; }
|
||||||
btn.disabled = false; prog.style.display = 'none';
|
btn.disabled = false; prog.style.display = 'none';
|
||||||
}
|
}
|
||||||
|
|
@ -292,10 +307,10 @@ func (s *Server) handleUI(w http.ResponseWriter, r *http.Request) {
|
||||||
<h1>sojuboy</h1>
|
<h1>sojuboy</h1>
|
||||||
<article>
|
<article>
|
||||||
<div class="grid">
|
<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>Channel<select id="channel" onchange="onChannelChange()"></select></label>
|
||||||
<label>Limit<input type="number" id="limit" value="25"/></label>
|
<label>Limit<input type="number" id="limit" value="25"/></label>
|
||||||
<label><input type="checkbox" id="follow" onchange="onFollowToggle(this)"/> Follow</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>
|
<button onclick="doTail()">Refresh tail</button>
|
||||||
</div>
|
</div>
|
||||||
<pre id="tail"></pre>
|
<pre id="tail"></pre>
|
||||||
|
|
@ -421,6 +436,10 @@ func checkAuth(r *http.Request, token string) bool {
|
||||||
if r.Header.Get("X-Auth-Token") == token {
|
if r.Header.Get("X-Auth-Token") == token {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
// Cookie-based
|
||||||
|
if c, err := r.Cookie("auth_token"); err == nil && c.Value == token {
|
||||||
|
return true
|
||||||
|
}
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -432,3 +451,63 @@ func getIntQuery(r *http.Request, key string, def int) int {
|
||||||
}
|
}
|
||||||
return def
|
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)
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue