sojuboy/internal/httpapi/server.go

513 lines
18 KiB
Go

package httpapi
import (
"encoding/json"
"context"
"fmt"
"log/slog"
"net/http"
"strconv"
"strings"
"sync/atomic"
"time"
"sojuboy/internal/store"
"sojuboy/internal/summarizer"
)
type Metrics struct {
MessagesIngested int64 // counter
NotificationsSent int64 // counter
MessagesPruned int64 // counter
ConnectedGauge int64 // 0/1
}
type Server struct {
ListenAddr string
AuthToken string
Store *store.Store
Summarizer summarizer.Summarizer
Notifier interface {
Notify(context.Context, string, string) error
}
Logger *slog.Logger
Metrics *Metrics
Ready func() bool
// Optional timeout override for summarizer
SummarizerTimeout time.Duration
// Build/runtime info for UI
Version string
Commit string
BuiltAt string
StartedAt time.Time
// Optional seed list from config for /api/channels when DB is empty
KnownChannels []string
}
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"))
})
mux.HandleFunc("/ready", func(w http.ResponseWriter, r *http.Request) {
if s.Ready != nil && !s.Ready() {
w.WriteHeader(http.StatusServiceUnavailable)
_, _ = w.Write([]byte("not ready"))
return
}
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("ready"))
})
mux.HandleFunc("/trigger", s.handleTrigger)
mux.HandleFunc("/tail", s.handleTail)
mux.HandleFunc("/metrics", s.handleMetrics)
// JSON endpoints for UI
mux.HandleFunc("/api/info", s.handleInfo)
mux.HandleFunc("/api/channels", s.handleChannels)
mux.HandleFunc("/api/tail", s.handleTailJSON)
mux.HandleFunc("/api/trigger", s.handleTriggerJSON)
srv := &http.Server{
Addr: s.ListenAddr,
Handler: mux,
}
go func() {
<-ctx.Done()
_ = srv.Shutdown(context.Background())
}()
if s.Logger != nil {
s.Logger.Info("http listening", "addr", s.ListenAddr)
}
return srv.ListenAndServe()
}
func (s *Server) handleTrigger(w http.ResponseWriter, r *http.Request) {
if s.AuthToken != "" {
if !checkAuth(r, s.AuthToken) {
w.WriteHeader(http.StatusUnauthorized)
_, _ = w.Write([]byte("unauthorized"))
return
}
}
channel := r.URL.Query().Get("channel")
if channel == "" {
w.WriteHeader(http.StatusBadRequest)
_, _ = w.Write([]byte("missing channel"))
return
}
windowStr := r.URL.Query().Get("window")
if windowStr == "" {
windowStr = "6h"
}
window, err := time.ParseDuration(windowStr)
if err != nil {
w.WriteHeader(http.StatusBadRequest)
_, _ = w.Write([]byte("bad window"))
return
}
ctx := r.Context()
msgs, err := s.Store.ListMessagesSince(ctx, channel, time.Now().Add(-window))
if err != nil {
if s.Logger != nil {
s.Logger.Error("http trigger store", "err", err)
}
w.WriteHeader(http.StatusInternalServerError)
_, _ = w.Write([]byte("store error"))
return
}
if s.Summarizer == nil {
w.WriteHeader(http.StatusServiceUnavailable)
_, _ = w.Write([]byte("summarizer not configured"))
return
}
// Timeout summarization using configurable timeout (default 5m)
tout := s.SummarizerTimeout
if tout <= 0 {
tout = 5 * time.Minute
}
ctxSum, cancel := context.WithTimeout(ctx, tout)
defer cancel()
summary, err := s.Summarizer.Summarize(ctxSum, channel, msgs, window)
if err != nil {
if s.Logger != nil {
s.Logger.Error("http trigger summarizer", "err", err)
}
w.WriteHeader(http.StatusBadGateway)
_, _ = w.Write([]byte("summarizer error"))
return
}
if s.Notifier != nil {
title := fmt.Sprintf("IRC digest %s (%s)", channel, window)
_ = s.Notifier.Notify(ctx, title, summary)
if s.Metrics != nil {
atomic.AddInt64(&s.Metrics.NotificationsSent, 1)
}
}
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
_, _ = w.Write([]byte(summary))
}
func (s *Server) handleTail(w http.ResponseWriter, r *http.Request) {
if s.AuthToken != "" {
if !checkAuth(r, s.AuthToken) {
w.WriteHeader(http.StatusUnauthorized)
_, _ = w.Write([]byte("unauthorized"))
return
}
}
channel := r.URL.Query().Get("channel")
if channel == "" {
w.WriteHeader(http.StatusBadRequest)
_, _ = w.Write([]byte("missing channel"))
return
}
limit := getIntQuery(r, "limit", 50)
msgs, err := s.Store.ListRecentMessages(r.Context(), channel, limit)
if err != nil {
if s.Logger != nil {
s.Logger.Error("http tail store", "err", err)
}
w.WriteHeader(http.StatusInternalServerError)
_, _ = w.Write([]byte("store error"))
return
}
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
for i := len(msgs) - 1; i >= 0; i-- {
m := msgs[i]
_, _ = w.Write([]byte(m.Time.UTC().Format(time.RFC3339) + " " + m.Author + " " + channel + " " + m.Body + "\n"))
}
}
func (s *Server) handleMetrics(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/plain; version=0.0.4")
msgs := int64(0)
nots := int64(0)
pruned := int64(0)
conn := int64(0)
if s.Metrics != nil {
msgs = atomic.LoadInt64(&s.Metrics.MessagesIngested)
nots = atomic.LoadInt64(&s.Metrics.NotificationsSent)
pruned = atomic.LoadInt64(&s.Metrics.MessagesPruned)
conn = atomic.LoadInt64(&s.Metrics.ConnectedGauge)
}
_, _ = fmt.Fprintf(w, "sojuboy_messages_ingested_total %d\n", msgs)
_, _ = fmt.Fprintf(w, "sojuboy_notifications_sent_total %d\n", nots)
_, _ = fmt.Fprintf(w, "sojuboy_messages_pruned_total %d\n", pruned)
_, _ = fmt.Fprintf(w, "sojuboy_connected %d\n", conn)
}
// --- Web UI handlers ---
func (s *Server) handleUI(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/" {
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>
<html>
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<title>sojuboy</title>
<link rel="stylesheet" href="https://unpkg.com/@picocss/pico@2/css/pico.min.css">
<style>
body { padding: 1rem; }
pre { max-height: 50vh; overflow: auto; white-space: pre-wrap; word-break: break-word; overflow-wrap: anywhere; }
.row { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; }
@media (max-width: 768px) {
.grid { grid-template-columns: 1fr !important; }
pre { max-height: 40vh; }
}
</style>
<script>
const st={token: localStorage.getItem('token')||'', tailTimer:null, tailLoading:false};
function setToken(v){ st.token=v; localStorage.setItem('token', v); }
async function api(path, params){
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); }
return res.json();
}
async function loadInfo(){
try{ const data = await api('/api/info');
document.getElementById('version').textContent = data.version+ ' ('+data.commit+')';
document.getElementById('built').textContent = data.builtAt;
document.getElementById('uptime').textContent = data.uptime;
document.getElementById('connected').textContent = data.connected? 'yes':'no';
document.getElementById('counts').textContent = 'ingested ' + data.messagesIngested + ', notified ' + data.notificationsSent + ', pruned ' + data.messagesPruned;
}catch(e){ console.error(e); }
}
async function loadChannels(){
try{ const data = await api('/api/channels');
const sel = document.getElementById('channel'); sel.innerHTML = '';
data.forEach(function(c){ const o=document.createElement('option'); o.value=c; o.textContent=c; sel.appendChild(o); });
// Auto-tail default channel (first) with 25 lines
if(data.length>0){ document.getElementById('limit').value = '25'; doTail(); }
}catch(e){ console.error(e); }
}
async function doTail(){
if(st.tailLoading) return; st.tailLoading=true;
const ch = document.getElementById('channel').value;
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');
const el = document.getElementById('tail');
el.textContent = out;
el.scrollTop = el.scrollHeight;
}catch(e){ document.getElementById('tail').textContent = 'error: '+e; }
st.tailLoading=false;
}
async function doSumm(){
const ch = document.getElementById('channel').value;
const win = document.getElementById('window').value || '6h';
const push = document.getElementById('push').checked ? '1' : '0';
const btn = document.getElementById('summBtn');
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}});
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';
}
window.addEventListener('DOMContentLoaded', ()=>{ loadInfo(); loadChannels(); });
function onFollowToggle(cb){
if(cb.checked){
if(st.tailTimer) clearInterval(st.tailTimer);
st.tailTimer = setInterval(doTail, 3000);
}else{
if(st.tailTimer) { clearInterval(st.tailTimer); st.tailTimer=null; }
}
}
function onChannelChange(){ doTail(); }
</script>
</head>
<body>
<main class="container">
<h1>sojuboy</h1>
<article>
<div class="grid">
<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>
</article>
<article>
<div class="grid">
<label>Window<input type="text" id="window" value="6h"/></label>
<label><input type="checkbox" id="push"/> Send via Pushover</label>
<div>
<button id="summBtn" onclick="doSumm()">Summarize</button>
<progress id="summProg" value="0" max="1" style="display:none; vertical-align: middle;"></progress>
</div>
</div>
<pre id="summary"></pre>
</article>
<article>
<h3>Status</h3>
<ul>
<li>Version: <span id="version"></span></li>
<li>Built: <span id="built"></span></li>
<li>Uptime: <span id="uptime"></span></li>
<li>Connected: <span id="connected"></span></li>
<li>Counters: <span id="counts"></span></li>
</ul>
</article>
</main>
</body>
</html>`
_, _ = w.Write([]byte(page))
}
func (s *Server) handleInfo(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
resp := map[string]any{
"version": s.Version,
"commit": s.Commit,
"builtAt": s.BuiltAt,
"startedAt": s.StartedAt.Format(time.RFC3339),
"uptime": time.Since(s.StartedAt).Round(time.Second).String(),
"messagesIngested": func() int64 { if s.Metrics==nil {return 0}; return atomic.LoadInt64(&s.Metrics.MessagesIngested) }(),
"notificationsSent": func() int64 { if s.Metrics==nil {return 0}; return atomic.LoadInt64(&s.Metrics.NotificationsSent) }(),
"messagesPruned": func() int64 { if s.Metrics==nil {return 0}; return atomic.LoadInt64(&s.Metrics.MessagesPruned) }(),
"connected": func() bool { if s.Metrics==nil {return false}; return atomic.LoadInt64(&s.Metrics.ConnectedGauge)==1 }(),
}
_ = json.NewEncoder(w).Encode(resp)
}
func (s *Server) handleChannels(w http.ResponseWriter, r *http.Request) {
if s.AuthToken != "" && !checkAuth(r, s.AuthToken) {
w.WriteHeader(http.StatusUnauthorized)
_, _ = w.Write([]byte("unauthorized"))
return
}
chs, err := s.Store.ListChannels(r.Context())
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
_, _ = w.Write([]byte("store error"))
return
}
if len(chs) == 0 && len(s.KnownChannels) > 0 {
chs = append(chs, s.KnownChannels...)
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(chs)
}
func (s *Server) handleTailJSON(w http.ResponseWriter, r *http.Request) {
if s.AuthToken != "" && !checkAuth(r, s.AuthToken) {
w.WriteHeader(http.StatusUnauthorized)
_, _ = w.Write([]byte("unauthorized"))
return
}
channel := r.URL.Query().Get("channel")
if channel == "" { w.WriteHeader(http.StatusBadRequest); _, _ = w.Write([]byte("missing channel")); return }
limit := getIntQuery(r, "limit", 100)
msgs, err := s.Store.ListRecentMessages(r.Context(), channel, limit)
if err != nil { w.WriteHeader(http.StatusInternalServerError); _, _ = w.Write([]byte("store error")); return }
w.Header().Set("Content-Type", "application/json")
type outMsg struct{ Time string `json:"time"`; Author string `json:"author"`; Body string `json:"body"`; Channel string `json:"channel"` }
arr := make([]outMsg, 0, len(msgs))
for i := len(msgs)-1; i>=0; i-- { m := msgs[i]; arr = append(arr, outMsg{Time: m.Time.UTC().Format(time.RFC3339), Author: m.Author, Body: m.Body, Channel: channel}) }
_ = json.NewEncoder(w).Encode(arr)
}
func (s *Server) handleTriggerJSON(w http.ResponseWriter, r *http.Request) {
if s.AuthToken != "" && !checkAuth(r, s.AuthToken) {
w.WriteHeader(http.StatusUnauthorized)
_, _ = w.Write([]byte("unauthorized"))
return
}
channel := r.URL.Query().Get("channel")
if channel == "" { w.WriteHeader(http.StatusBadRequest); _, _ = w.Write([]byte("missing channel")); return }
win := r.URL.Query().Get("window"); if win=="" { win = "6h" }
push := r.URL.Query().Get("push") == "1"
dur, err := time.ParseDuration(win)
if err != nil { w.WriteHeader(http.StatusBadRequest); _, _ = w.Write([]byte("bad window")); return }
msgs, err := s.Store.ListMessagesSince(r.Context(), channel, time.Now().Add(-dur))
if err != nil { w.WriteHeader(http.StatusInternalServerError); _, _ = w.Write([]byte("store error")); return }
if s.Summarizer == nil { w.WriteHeader(http.StatusServiceUnavailable); _, _ = w.Write([]byte("summarizer not configured")); return }
tout := s.SummarizerTimeout; if tout<=0 { tout = 5*time.Minute }
ctx, cancel := context.WithTimeout(r.Context(), tout); defer cancel()
sum, err := s.Summarizer.Summarize(ctx, channel, msgs, dur)
if err != nil { w.WriteHeader(http.StatusBadGateway); _, _ = w.Write([]byte("summarizer error")); return }
if push && s.Notifier != nil { title := fmt.Sprintf("IRC digest %s (%s)", channel, dur); _ = s.Notifier.Notify(r.Context(), title, sum); if s.Metrics != nil { atomic.AddInt64(&s.Metrics.NotificationsSent, 1) } }
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{"summary": sum})
}
func checkAuth(r *http.Request, token string) bool {
auth := r.Header.Get("Authorization")
if strings.HasPrefix(auth, "Bearer ") {
if strings.TrimPrefix(auth, "Bearer ") == token {
return true
}
}
if r.URL.Query().Get("token") == token {
return true
}
user, pass, ok := r.BasicAuth()
if ok && user == "token" && pass == token {
return true
}
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
}
func getIntQuery(r *http.Request, key string, def int) int {
if v := r.URL.Query().Get(key); v != "" {
if n, err := strconv.Atoi(v); err == nil {
return n
}
}
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)
}