feat: initial Beta 1 release
- soju raw connector with event playback and CHATHISTORY fallback
- SQLite store with msgid de-dup and retention job
- Mentions + Pushover + tuning; structured JSON logs
- Summaries: concise, link-following, multi-line grouping
- HTTP: /healthz, /ready, /tail, /trigger, /metrics
- Docker: distroless, healthcheck, version metadata
- Docs: README, CHANGELOG, compose
2025-08-15 18:06:28 -05:00
|
|
|
package httpapi
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"context"
|
2025-08-16 21:38:49 -05:00
|
|
|
"encoding/json"
|
feat: initial Beta 1 release
- soju raw connector with event playback and CHATHISTORY fallback
- SQLite store with msgid de-dup and retention job
- Mentions + Pushover + tuning; structured JSON logs
- Summaries: concise, link-following, multi-line grouping
- HTTP: /healthz, /ready, /tail, /trigger, /metrics
- Docker: distroless, healthcheck, version metadata
- Docs: README, CHANGELOG, compose
2025-08-15 18:06:28 -05:00
|
|
|
"fmt"
|
2025-08-16 21:38:49 -05:00
|
|
|
"io/fs"
|
feat: initial Beta 1 release
- soju raw connector with event playback and CHATHISTORY fallback
- SQLite store with msgid de-dup and retention job
- Mentions + Pushover + tuning; structured JSON logs
- Summaries: concise, link-following, multi-line grouping
- HTTP: /healthz, /ready, /tail, /trigger, /metrics
- Docker: distroless, healthcheck, version metadata
- Docs: README, CHANGELOG, compose
2025-08-15 18:06:28 -05:00
|
|
|
"log/slog"
|
|
|
|
|
"net/http"
|
2025-08-16 19:18:31 -05:00
|
|
|
"net/url"
|
feat: initial Beta 1 release
- soju raw connector with event playback and CHATHISTORY fallback
- SQLite store with msgid de-dup and retention job
- Mentions + Pushover + tuning; structured JSON logs
- Summaries: concise, link-following, multi-line grouping
- HTTP: /healthz, /ready, /tail, /trigger, /metrics
- Docker: distroless, healthcheck, version metadata
- Docs: README, CHANGELOG, compose
2025-08-15 18:06:28 -05:00
|
|
|
"strconv"
|
|
|
|
|
"strings"
|
2025-08-16 19:18:31 -05:00
|
|
|
"sync"
|
feat: initial Beta 1 release
- soju raw connector with event playback and CHATHISTORY fallback
- SQLite store with msgid de-dup and retention job
- Mentions + Pushover + tuning; structured JSON logs
- Summaries: concise, link-following, multi-line grouping
- HTTP: /healthz, /ready, /tail, /trigger, /metrics
- Docker: distroless, healthcheck, version metadata
- Docs: README, CHANGELOG, compose
2025-08-15 18:06:28 -05:00
|
|
|
"sync/atomic"
|
|
|
|
|
"time"
|
|
|
|
|
|
|
|
|
|
"sojuboy/internal/store"
|
|
|
|
|
"sojuboy/internal/summarizer"
|
2025-08-16 21:38:49 -05:00
|
|
|
|
|
|
|
|
xhtml "golang.org/x/net/html"
|
feat: initial Beta 1 release
- soju raw connector with event playback and CHATHISTORY fallback
- SQLite store with msgid de-dup and retention job
- Mentions + Pushover + tuning; structured JSON logs
- Summaries: concise, link-following, multi-line grouping
- HTTP: /healthz, /ready, /tail, /trigger, /metrics
- Docker: distroless, healthcheck, version metadata
- Docs: README, CHANGELOG, compose
2025-08-15 18:06:28 -05:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
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
|
2025-08-15 20:41:31 -05:00
|
|
|
// Optional timeout override for summarizer
|
|
|
|
|
SummarizerTimeout time.Duration
|
2025-08-16 15:24:50 -05:00
|
|
|
// Build/runtime info for UI
|
|
|
|
|
Version string
|
|
|
|
|
Commit string
|
|
|
|
|
BuiltAt string
|
|
|
|
|
StartedAt time.Time
|
2025-08-16 15:32:57 -05:00
|
|
|
// Optional seed list from config for /api/channels when DB is empty
|
|
|
|
|
KnownChannels []string
|
2025-08-16 19:18:31 -05:00
|
|
|
// SSE subscribers map
|
|
|
|
|
subs map[string][]chan store.Message
|
|
|
|
|
subsMu sync.RWMutex
|
|
|
|
|
// Link card cache
|
|
|
|
|
cardCache map[string]linkCard
|
|
|
|
|
cardCacheExp map[string]time.Time
|
2025-08-16 21:38:49 -05:00
|
|
|
// Link summary cache
|
|
|
|
|
summaryCache map[string]string
|
|
|
|
|
summaryCacheExp map[string]time.Time
|
feat: initial Beta 1 release
- soju raw connector with event playback and CHATHISTORY fallback
- SQLite store with msgid de-dup and retention job
- Mentions + Pushover + tuning; structured JSON logs
- Summaries: concise, link-following, multi-line grouping
- HTTP: /healthz, /ready, /tail, /trigger, /metrics
- Docker: distroless, healthcheck, version metadata
- Docs: README, CHANGELOG, compose
2025-08-15 18:06:28 -05:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (s *Server) Start(ctx context.Context) error {
|
|
|
|
|
mux := http.NewServeMux()
|
2025-08-16 21:38:49 -05:00
|
|
|
// Minimal web UI (templated)
|
|
|
|
|
mux.HandleFunc("/", s.handleUIDash)
|
|
|
|
|
mux.HandleFunc("/summarizer", s.handleUISummarizer)
|
|
|
|
|
// Serve embedded static files under /static/
|
|
|
|
|
if sub, err := fs.Sub(staticFS, "."); err == nil {
|
|
|
|
|
mux.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.FS(sub))))
|
|
|
|
|
}
|
2025-08-16 16:09:19 -05:00
|
|
|
mux.HandleFunc("/login", s.handleLogin)
|
|
|
|
|
mux.HandleFunc("/auth", s.handleAuth)
|
|
|
|
|
mux.HandleFunc("/logout", s.handleLogout)
|
feat: initial Beta 1 release
- soju raw connector with event playback and CHATHISTORY fallback
- SQLite store with msgid de-dup and retention job
- Mentions + Pushover + tuning; structured JSON logs
- Summaries: concise, link-following, multi-line grouping
- HTTP: /healthz, /ready, /tail, /trigger, /metrics
- Docker: distroless, healthcheck, version metadata
- Docs: README, CHANGELOG, compose
2025-08-15 18:06:28 -05:00
|
|
|
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)
|
2025-08-16 15:24:50 -05:00
|
|
|
// 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)
|
2025-08-16 19:18:31 -05:00
|
|
|
mux.HandleFunc("/api/history", s.handleHistory)
|
|
|
|
|
mux.HandleFunc("/api/stream", s.handleStream)
|
|
|
|
|
mux.HandleFunc("/api/linkcard", s.handleLinkCard)
|
2025-08-16 21:38:49 -05:00
|
|
|
mux.HandleFunc("/api/linksummary", s.handleLinkSummary)
|
feat: initial Beta 1 release
- soju raw connector with event playback and CHATHISTORY fallback
- SQLite store with msgid de-dup and retention job
- Mentions + Pushover + tuning; structured JSON logs
- Summaries: concise, link-following, multi-line grouping
- HTTP: /healthz, /ready, /tail, /trigger, /metrics
- Docker: distroless, healthcheck, version metadata
- Docs: README, CHANGELOG, compose
2025-08-15 18:06:28 -05:00
|
|
|
|
|
|
|
|
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
|
|
|
|
|
}
|
2025-08-15 20:41:31 -05:00
|
|
|
// Timeout summarization using configurable timeout (default 5m)
|
|
|
|
|
tout := s.SummarizerTimeout
|
|
|
|
|
if tout <= 0 {
|
|
|
|
|
tout = 5 * time.Minute
|
|
|
|
|
}
|
|
|
|
|
ctxSum, cancel := context.WithTimeout(ctx, tout)
|
feat: initial Beta 1 release
- soju raw connector with event playback and CHATHISTORY fallback
- SQLite store with msgid de-dup and retention job
- Mentions + Pushover + tuning; structured JSON logs
- Summaries: concise, link-following, multi-line grouping
- HTTP: /healthz, /ready, /tail, /trigger, /metrics
- Docker: distroless, healthcheck, version metadata
- Docs: README, CHANGELOG, compose
2025-08-15 18:06:28 -05:00
|
|
|
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
|
|
|
|
|
}
|
2025-08-16 20:59:23 -05:00
|
|
|
// Only push if explicitly requested
|
|
|
|
|
pushFlag := strings.EqualFold(r.URL.Query().Get("push"), "1") || strings.EqualFold(r.URL.Query().Get("push"), "true")
|
|
|
|
|
if pushFlag && s.Notifier != nil {
|
feat: initial Beta 1 release
- soju raw connector with event playback and CHATHISTORY fallback
- SQLite store with msgid de-dup and retention job
- Mentions + Pushover + tuning; structured JSON logs
- Summaries: concise, link-following, multi-line grouping
- HTTP: /healthz, /ready, /tail, /trigger, /metrics
- Docker: distroless, healthcheck, version metadata
- Docs: README, CHANGELOG, compose
2025-08-15 18:06:28 -05:00
|
|
|
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"))
|
|
|
|
|
}
|
|
|
|
|
}
|
2025-08-16 21:38:49 -05:00
|
|
|
|
2025-08-16 19:18:31 -05:00
|
|
|
// Simple link card structure
|
2025-08-16 21:38:49 -05:00
|
|
|
type linkCard struct {
|
|
|
|
|
URL string `json:"url"`
|
|
|
|
|
Title string `json:"title"`
|
|
|
|
|
Description string `json:"description"`
|
|
|
|
|
Image string `json:"image"`
|
2025-08-16 20:31:06 -05:00
|
|
|
}
|
2025-08-16 19:18:31 -05:00
|
|
|
|
|
|
|
|
// handleLinkCard fetches basic OpenGraph/Twitter card metadata (best-effort) and returns a small card.
|
|
|
|
|
func (s *Server) handleLinkCard(w http.ResponseWriter, r *http.Request) {
|
2025-08-16 21:38:49 -05:00
|
|
|
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)
|
|
|
|
|
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)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// handleLinkSummary returns a brief AI summary for a given URL, cached for 24h.
|
|
|
|
|
func (s *Server) handleLinkSummary(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
|
|
|
|
|
}
|
|
|
|
|
if s.Summarizer == nil {
|
|
|
|
|
w.WriteHeader(http.StatusServiceUnavailable)
|
|
|
|
|
_, _ = w.Write([]byte("summarizer not configured"))
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
if s.summaryCache == nil { s.summaryCache = make(map[string]string) }
|
|
|
|
|
if s.summaryCacheExp == nil { s.summaryCacheExp = make(map[string]time.Time) }
|
|
|
|
|
if exp, ok := s.summaryCacheExp[raw]; ok && time.Now().Before(exp) {
|
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
|
|
|
_ = json.NewEncoder(w).Encode(map[string]any{"summary": s.summaryCache[raw]})
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
msgs := []store.Message{{Channel: "#links", Author: "link", Body: raw, Time: time.Now().UTC()}}
|
|
|
|
|
tout := s.SummarizerTimeout
|
|
|
|
|
if tout <= 0 { tout = 5 * time.Minute }
|
|
|
|
|
if tout > 2*time.Minute { tout = 2 * time.Minute }
|
|
|
|
|
ctx, cancel := context.WithTimeout(r.Context(), tout)
|
|
|
|
|
defer cancel()
|
|
|
|
|
sum, err := s.Summarizer.Summarize(ctx, "#links", msgs, 0)
|
|
|
|
|
if err != nil {
|
|
|
|
|
w.WriteHeader(http.StatusBadGateway)
|
|
|
|
|
_, _ = w.Write([]byte("summarizer error"))
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
if sum == "" { sum = "(no summary)" }
|
|
|
|
|
s.summaryCache[raw] = sum
|
|
|
|
|
s.summaryCacheExp[raw] = time.Now().Add(24 * time.Hour)
|
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
|
|
|
_ = json.NewEncoder(w).Encode(map[string]any{"summary": sum})
|
2025-08-16 19:18:31 -05:00
|
|
|
}
|
feat: initial Beta 1 release
- soju raw connector with event playback and CHATHISTORY fallback
- SQLite store with msgid de-dup and retention job
- Mentions + Pushover + tuning; structured JSON logs
- Summaries: concise, link-following, multi-line grouping
- HTTP: /healthz, /ready, /tail, /trigger, /metrics
- Docker: distroless, healthcheck, version metadata
- Docs: README, CHANGELOG, compose
2025-08-15 18:06:28 -05:00
|
|
|
|
|
|
|
|
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)
|
|
|
|
|
}
|
|
|
|
|
|
2025-08-16 15:24:50 -05:00
|
|
|
// --- Web UI handlers ---
|
|
|
|
|
|
2025-08-16 21:38:49 -05:00
|
|
|
func (s *Server) handleUIDash(w http.ResponseWriter, r *http.Request) {
|
2025-08-16 15:24:50 -05:00
|
|
|
if r.URL.Path != "/" {
|
|
|
|
|
w.WriteHeader(http.StatusNotFound)
|
|
|
|
|
return
|
|
|
|
|
}
|
2025-08-16 16:09:19 -05:00
|
|
|
// 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
|
|
|
|
|
}
|
|
|
|
|
}
|
2025-08-16 21:38:49 -05:00
|
|
|
s.render(w, "dashboard.tmpl", map[string]any{})
|
2025-08-16 19:18:31 -05:00
|
|
|
}
|
|
|
|
|
|
2025-08-16 21:38:49 -05:00
|
|
|
func (s *Server) handleUISummarizer(w http.ResponseWriter, r *http.Request) {
|
2025-08-16 19:18:31 -05:00
|
|
|
if s.AuthToken != "" {
|
2025-08-16 20:23:12 -05:00
|
|
|
if c, err := r.Cookie("auth_token"); err != nil || c.Value != s.AuthToken {
|
|
|
|
|
http.Redirect(w, r, "/login", http.StatusFound)
|
2025-08-16 19:18:31 -05:00
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
}
|
2025-08-16 21:38:49 -05:00
|
|
|
s.render(w, "summarizer.tmpl", map[string]any{})
|
2025-08-16 15:24:50 -05:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (s *Server) handleInfo(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
|
|
|
resp := map[string]any{
|
2025-08-16 21:38:49 -05:00
|
|
|
"version": s.Version,
|
|
|
|
|
"commit": s.Commit,
|
|
|
|
|
"builtAt": s.BuiltAt,
|
2025-08-16 15:24:50 -05:00
|
|
|
"startedAt": s.StartedAt.Format(time.RFC3339),
|
2025-08-16 21:38:49 -05:00
|
|
|
"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
|
|
|
|
|
}(),
|
2025-08-16 15:24:50 -05:00
|
|
|
}
|
|
|
|
|
_ = json.NewEncoder(w).Encode(resp)
|
|
|
|
|
}
|
|
|
|
|
|
2025-08-16 19:18:31 -05:00
|
|
|
// SSE stream of new messages for a channel
|
|
|
|
|
func (s *Server) handleStream(w http.ResponseWriter, r *http.Request) {
|
2025-08-16 21:38:49 -05:00
|
|
|
if s.AuthToken != "" && !checkAuth(r, s.AuthToken) {
|
|
|
|
|
w.WriteHeader(http.StatusUnauthorized)
|
|
|
|
|
_, _ = w.Write([]byte("unauthorized"))
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
ch := strings.TrimSpace(r.URL.Query().Get("channel"))
|
|
|
|
|
if ch == "" {
|
|
|
|
|
w.WriteHeader(http.StatusBadRequest)
|
|
|
|
|
_, _ = w.Write([]byte("missing channel"))
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
key := strings.ToLower(ch) // Normalize channel name to lowercase
|
|
|
|
|
|
|
|
|
|
w.Header().Set("Content-Type", "text/event-stream")
|
|
|
|
|
w.Header().Set("Cache-Control", "no-cache")
|
|
|
|
|
w.Header().Set("Connection", "keep-alive")
|
|
|
|
|
flusher, ok := w.(http.Flusher)
|
|
|
|
|
if !ok {
|
|
|
|
|
w.WriteHeader(http.StatusInternalServerError)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// register subscriber
|
|
|
|
|
if s.subs == nil {
|
|
|
|
|
s.subs = make(map[string][]chan store.Message)
|
|
|
|
|
}
|
|
|
|
|
sub := make(chan store.Message, 64)
|
|
|
|
|
s.subsMu.Lock()
|
|
|
|
|
s.subs[key] = append(s.subs[key], sub)
|
|
|
|
|
s.subsMu.Unlock()
|
|
|
|
|
defer func() {
|
|
|
|
|
s.subsMu.Lock()
|
|
|
|
|
subs := s.subs[key]
|
|
|
|
|
for i := range subs {
|
|
|
|
|
if subs[i] == sub {
|
|
|
|
|
s.subs[key] = append(subs[:i], subs[i+1:]...)
|
|
|
|
|
break
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
s.subsMu.Unlock()
|
|
|
|
|
close(sub)
|
|
|
|
|
}()
|
|
|
|
|
|
|
|
|
|
hb := time.NewTicker(15 * time.Second) // SSE heartbeat
|
|
|
|
|
defer hb.Stop()
|
|
|
|
|
ctx := r.Context()
|
|
|
|
|
for {
|
|
|
|
|
select {
|
|
|
|
|
case <-ctx.Done():
|
|
|
|
|
return
|
|
|
|
|
case m := <-sub:
|
|
|
|
|
b, _ := json.Marshal(map[string]any{
|
|
|
|
|
"time": m.Time.UTC().Format(time.RFC3339),
|
|
|
|
|
"author": m.Author,
|
|
|
|
|
"body": m.Body,
|
|
|
|
|
"channel": m.Channel,
|
|
|
|
|
})
|
|
|
|
|
_, _ = fmt.Fprintf(w, "data: %s\n\n", b)
|
|
|
|
|
flusher.Flush()
|
|
|
|
|
case <-hb.C:
|
|
|
|
|
_, _ = fmt.Fprintf(w, ": ping\n\n")
|
|
|
|
|
flusher.Flush()
|
|
|
|
|
}
|
|
|
|
|
}
|
2025-08-16 19:18:31 -05:00
|
|
|
}
|
|
|
|
|
|
2025-08-16 15:24:50 -05:00
|
|
|
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
|
|
|
|
|
}
|
2025-08-16 15:32:57 -05:00
|
|
|
if len(chs) == 0 && len(s.KnownChannels) > 0 {
|
|
|
|
|
chs = append(chs, s.KnownChannels...)
|
|
|
|
|
}
|
2025-08-16 15:24:50 -05:00
|
|
|
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")
|
2025-08-16 21:38:49 -05:00
|
|
|
if channel == "" {
|
|
|
|
|
w.WriteHeader(http.StatusBadRequest)
|
|
|
|
|
_, _ = w.Write([]byte("missing channel"))
|
|
|
|
|
return
|
|
|
|
|
}
|
2025-08-16 15:24:50 -05:00
|
|
|
limit := getIntQuery(r, "limit", 100)
|
|
|
|
|
msgs, err := s.Store.ListRecentMessages(r.Context(), channel, limit)
|
2025-08-16 21:38:49 -05:00
|
|
|
if err != nil {
|
|
|
|
|
w.WriteHeader(http.StatusInternalServerError)
|
|
|
|
|
_, _ = w.Write([]byte("store error"))
|
|
|
|
|
return
|
|
|
|
|
}
|
2025-08-16 15:24:50 -05:00
|
|
|
w.Header().Set("Content-Type", "application/json")
|
2025-08-16 21:38:49 -05:00
|
|
|
type outMsg struct {
|
|
|
|
|
Time string `json:"time"`
|
|
|
|
|
Author string `json:"author"`
|
|
|
|
|
Body string `json:"body"`
|
|
|
|
|
Channel string `json:"channel"`
|
|
|
|
|
}
|
2025-08-16 15:24:50 -05:00
|
|
|
arr := make([]outMsg, 0, len(msgs))
|
2025-08-16 21:38:49 -05:00
|
|
|
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})
|
|
|
|
|
}
|
2025-08-16 15:24:50 -05:00
|
|
|
_ = 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")
|
2025-08-16 21:38:49 -05:00
|
|
|
if channel == "" {
|
|
|
|
|
w.WriteHeader(http.StatusBadRequest)
|
|
|
|
|
_, _ = w.Write([]byte("missing channel"))
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
win := r.URL.Query().Get("window")
|
|
|
|
|
if win == "" {
|
|
|
|
|
win = "6h"
|
|
|
|
|
}
|
2025-08-16 15:24:50 -05:00
|
|
|
push := r.URL.Query().Get("push") == "1"
|
|
|
|
|
dur, err := time.ParseDuration(win)
|
2025-08-16 21:38:49 -05:00
|
|
|
if err != nil {
|
|
|
|
|
w.WriteHeader(http.StatusBadRequest)
|
|
|
|
|
_, _ = w.Write([]byte("bad window"))
|
|
|
|
|
return
|
|
|
|
|
}
|
2025-08-16 15:24:50 -05:00
|
|
|
msgs, err := s.Store.ListMessagesSince(r.Context(), channel, time.Now().Add(-dur))
|
2025-08-16 21:38:49 -05:00
|
|
|
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()
|
2025-08-16 15:24:50 -05:00
|
|
|
sum, err := s.Summarizer.Summarize(ctx, channel, msgs, dur)
|
2025-08-16 21:38:49 -05:00
|
|
|
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)
|
|
|
|
|
}
|
|
|
|
|
}
|
2025-08-16 15:24:50 -05:00
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
|
|
|
_ = json.NewEncoder(w).Encode(map[string]any{"summary": sum})
|
|
|
|
|
}
|
|
|
|
|
|
2025-08-16 19:18:31 -05:00
|
|
|
// history paging
|
|
|
|
|
func (s *Server) handleHistory(w http.ResponseWriter, r *http.Request) {
|
2025-08-16 21:38:49 -05:00
|
|
|
if s.AuthToken != "" && !checkAuth(r, s.AuthToken) {
|
|
|
|
|
w.WriteHeader(http.StatusUnauthorized)
|
|
|
|
|
_, _ = w.Write([]byte("unauthorized"))
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
channel := r.URL.Query().Get("channel")
|
|
|
|
|
beforeStr := r.URL.Query().Get("before")
|
|
|
|
|
if channel == "" || beforeStr == "" {
|
|
|
|
|
w.WriteHeader(http.StatusBadRequest)
|
|
|
|
|
_, _ = w.Write([]byte("missing params"))
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
t, err := time.Parse(time.RFC3339, beforeStr)
|
|
|
|
|
if err != nil {
|
|
|
|
|
w.WriteHeader(http.StatusBadRequest)
|
|
|
|
|
_, _ = w.Write([]byte("bad before"))
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
limit := getIntQuery(r, "limit", 50)
|
|
|
|
|
msgs, err := s.Store.ListMessagesBefore(r.Context(), channel, t, 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 _, m := range msgs {
|
|
|
|
|
arr = append(arr, outMsg{Time: m.Time.UTC().Format(time.RFC3339), Author: m.Author, Body: m.Body, Channel: m.Channel})
|
|
|
|
|
}
|
|
|
|
|
_ = json.NewEncoder(w).Encode(arr)
|
2025-08-16 19:18:31 -05:00
|
|
|
}
|
|
|
|
|
|
feat: initial Beta 1 release
- soju raw connector with event playback and CHATHISTORY fallback
- SQLite store with msgid de-dup and retention job
- Mentions + Pushover + tuning; structured JSON logs
- Summaries: concise, link-following, multi-line grouping
- HTTP: /healthz, /ready, /tail, /trigger, /metrics
- Docker: distroless, healthcheck, version metadata
- Docs: README, CHANGELOG, compose
2025-08-15 18:06:28 -05:00
|
|
|
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
|
|
|
|
|
}
|
2025-08-16 16:09:19 -05:00
|
|
|
// Cookie-based
|
|
|
|
|
if c, err := r.Cookie("auth_token"); err == nil && c.Value == token {
|
|
|
|
|
return true
|
|
|
|
|
}
|
feat: initial Beta 1 release
- soju raw connector with event playback and CHATHISTORY fallback
- SQLite store with msgid de-dup and retention job
- Mentions + Pushover + tuning; structured JSON logs
- Summaries: concise, link-following, multi-line grouping
- HTTP: /healthz, /ready, /tail, /trigger, /metrics
- Docker: distroless, healthcheck, version metadata
- Docs: README, CHANGELOG, compose
2025-08-15 18:06:28 -05:00
|
|
|
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
|
|
|
|
|
}
|
2025-08-16 16:09:19 -05:00
|
|
|
|
|
|
|
|
// --- 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) {
|
2025-08-16 21:38:49 -05:00
|
|
|
if err := r.ParseForm(); err != nil {
|
|
|
|
|
w.WriteHeader(http.StatusBadRequest)
|
|
|
|
|
_, _ = w.Write([]byte("bad request"))
|
|
|
|
|
return
|
|
|
|
|
}
|
2025-08-16 16:09:19 -05:00
|
|
|
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")
|
2025-08-16 21:38:49 -05:00
|
|
|
http.SetCookie(w, &http.Cookie{Name: "auth_token", Value: tok, Path: "/", MaxAge: maxAge, HttpOnly: true, Secure: secure, SameSite: http.SameSiteLaxMode})
|
2025-08-16 16:09:19 -05:00
|
|
|
w.Header().Set("Location", "/")
|
|
|
|
|
w.WriteHeader(http.StatusFound)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) {
|
2025-08-16 21:38:49 -05:00
|
|
|
http.SetCookie(w, &http.Cookie{Name: "auth_token", Value: "", Path: "/", MaxAge: -1})
|
2025-08-16 16:09:19 -05:00
|
|
|
http.Redirect(w, r, "/login", http.StatusFound)
|
|
|
|
|
}
|
2025-08-16 21:38:49 -05:00
|
|
|
|
|
|
|
|
// Broadcast sends a message to all SSE subscribers for a given channel.
|
|
|
|
|
func (s *Server) Broadcast(channel string, m store.Message) {
|
|
|
|
|
key := strings.ToLower(strings.TrimSpace(channel))
|
|
|
|
|
if key == "" {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
s.subsMu.RLock()
|
|
|
|
|
subs := s.subs[key]
|
|
|
|
|
s.subsMu.RUnlock()
|
|
|
|
|
for _, sub := range subs {
|
|
|
|
|
select {
|
|
|
|
|
case sub <- m:
|
|
|
|
|
// ok
|
|
|
|
|
default:
|
|
|
|
|
if s.Logger != nil {
|
|
|
|
|
s.Logger.Warn("sse subscriber buffer full, dropping message", "channel", key)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|