package httpapi import ( "encoding/json" "context" "fmt" "log/slog" "net/http" "net/url" "io" "strconv" "strings" "sync" "sync/atomic" "time" "sojuboy/internal/store" "sojuboy/internal/summarizer" xhtml "golang.org/x/net/html" ) 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 // SSE subscribers map subs map[string][]chan store.Message subsMu sync.RWMutex // Link card cache cardCache map[string]linkCard cardCacheExp map[string]time.Time } func (s *Server) Start(ctx context.Context) error { mux := http.NewServeMux() // Minimal web UI mux.HandleFunc("/", s.handleUI) mux.HandleFunc("/summarizer", s.handleSummarizerUI) 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) mux.HandleFunc("/api/history", s.handleHistory) mux.HandleFunc("/api/stream", s.handleStream) mux.HandleFunc("/api/linkcard", s.handleLinkCard) 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")) } } // Simple link card structure type linkCard struct{ URL, Title, Description, Image string } // handleLinkCard fetches basic OpenGraph/Twitter card metadata (best-effort) and returns a small card. func (s *Server) handleLinkCard(w http.ResponseWriter, r *http.Request) { if s.AuthToken != "" && !checkAuth(r, s.AuthToken) { w.WriteHeader(http.StatusUnauthorized) _, _ = w.Write([]byte("unauthorized")) return } raw := r.URL.Query().Get("url") if raw == "" { w.WriteHeader(http.StatusBadRequest); _, _ = w.Write([]byte("missing url")); return } u, err := url.Parse(raw) if err != nil || (u.Scheme != "http" && u.Scheme != "https") { w.WriteHeader(http.StatusBadRequest); _, _ = w.Write([]byte("bad url")); return } // cache lookup if s.cardCache == nil { s.cardCache = make(map[string]linkCard); s.cardCacheExp = make(map[string]time.Time) } if exp, ok := s.cardCacheExp[raw]; ok && time.Now().Before(exp) { w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(s.cardCache[raw]) return } // fetch minimal HTML and extract tags using a tolerant HTML parser client := &http.Client{ Timeout: 10 * time.Second } req, _ := http.NewRequestWithContext(r.Context(), http.MethodGet, raw, nil) resp, err := client.Do(req) if err != nil { w.WriteHeader(http.StatusBadGateway); _, _ = w.Write([]byte("fetch error")); return } defer resp.Body.Close() if resp.StatusCode < 200 || resp.StatusCode >= 300 { w.WriteHeader(http.StatusBadGateway); _, _ = w.Write([]byte("bad status")); return } // limit to 256KB and parse tokens limited := http.MaxBytesReader(w, resp.Body, 262144) doc, err := xhtml.Parse(limited) if err != nil { w.WriteHeader(http.StatusBadGateway); _, _ = w.Write([]byte("parse error")); return } var title, desc, img string var walker func(*xhtml.Node) getAttr := func(n *xhtml.Node, key string) string { for a := n.Attr; a != nil && len(n.Attr) > 0; a = nil { for _, at := range n.Attr { if strings.EqualFold(at.Key, key) { return at.Val } } ; return "" } } walker = func(n *xhtml.Node) { if n.Type == xhtml.ElementNode && strings.EqualFold(n.Data, "meta") { // property or name + content var pn = ""; var nm = ""; var content = "" for _, a := range n.Attr { if strings.EqualFold(a.Key, "property") { pn = a.Val } else if strings.EqualFold(a.Key, "name") { nm = a.Val } else if strings.EqualFold(a.Key, "content") { content = a.Val } } key := strings.ToLower(pn) if key == "" { key = strings.ToLower(nm) } switch key { case "og:title", "twitter:title": if title == "" { title = content } case "og:description", "twitter:description": if desc == "" { desc = content } case "og:image", "twitter:image": if img == "" { img = content } } } for c := n.FirstChild; c != nil; c = c.NextSibling { walker(c) } } walker(doc) card := linkCard{ URL: raw, Title: strings.TrimSpace(title), Description: strings.TrimSpace(desc), Image: strings.TrimSpace(img) } // cache for 24h s.cardCache[raw] = card s.cardCacheExp[raw] = time.Now().Add(24 * time.Hour) w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(card) } 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 := `