Pete's side of the Adventure news feed. Receives structured game-event
facts from gogobee, templates them in Pete's warm-reporter voice, and
publishes to a new /adventure section + live Matrix posts.
- adventure.go: bearer ingest + fact-guard + 13 event templates;
/adventure/{guid} permalink (story.html); per-event SVG emblems at
/adventure/art/{type}.svg (card image + og:image); NoPush suppresses
the live Matrix post (cold-start backfill).
- adventure_digest.go: daily BULLETIN roundup at DigestHour (UTC);
unposted-in-48h = bulletins; marks them digested; per-day ?digest= URL
avoids canonical dedup.
- config AdventureConfig (enabled/ingest_token/channel/digest_hour);
web.New takes the seam + a priority poster; started in main.
- adventure theme colors; thumbURL passes through local emblem paths;
adventure pages are noindex (player-named; gap #5).
Claude-Session: https://claude.ai/code/session_017mEwUmmS7aQTP2NQXj6rUa
225 lines
9.2 KiB
Go
225 lines
9.2 KiB
Go
// Package web serves Pete's read-only news interface.
|
|
package web
|
|
|
|
import (
|
|
"context"
|
|
"embed"
|
|
"errors"
|
|
"html/template"
|
|
"io/fs"
|
|
"log/slog"
|
|
"net/http"
|
|
"sync"
|
|
"time"
|
|
|
|
"pete/internal/config"
|
|
)
|
|
|
|
//go:embed templates/*.html
|
|
var templateFS embed.FS
|
|
|
|
//go:embed static
|
|
var staticFS embed.FS
|
|
|
|
// Channel describes one section of the site.
|
|
type Channel struct {
|
|
Slug string // "gaming"
|
|
Title string // "Gaming"
|
|
Theme string // tailwind theme key: "gaming" | "tech" | "politics"
|
|
Emoji string
|
|
Blurb string
|
|
}
|
|
|
|
// channels in display order. Add a new entry here to add a section.
|
|
var channels = []Channel{
|
|
{Slug: "gaming", Title: "Gaming", Theme: "gaming", Emoji: "🎮", Blurb: "Releases, platforms, and the people making the games."},
|
|
{Slug: "tech", Title: "Tech", Theme: "tech", Emoji: "💻", Blurb: "Industry, products, and the wires that hold it all together."},
|
|
{Slug: "politics", Title: "Politics", Theme: "politics", Emoji: "🏛️", Blurb: "Policy, power, and the news of the day."},
|
|
{Slug: "eu", Title: "EU", Theme: "eu", Emoji: "🇪🇺", Blurb: "Portugal and the wider European beat. Read-only — these stories don't post to Matrix."},
|
|
{Slug: "music", Title: "Music", Theme: "music", Emoji: "🎵", Blurb: "Records, scenes, and the artists shaping the sound."},
|
|
{Slug: "anime", Title: "Anime", Theme: "anime", Emoji: "🌸", Blurb: "Series, studios, and the wider world of anime and manga."},
|
|
{Slug: "foss", Title: "FOSS", Theme: "foss", Emoji: "🐧", Blurb: "Kernel, distros, and the wider free and open source software world."},
|
|
{Slug: "kids", Title: "Kids", Theme: "kids", Emoji: "🧒", Blurb: "World news written for younger readers — short, clear, and curious."},
|
|
{Slug: "finance", Title: "Finance", Theme: "finance", Emoji: "💰", Blurb: "Markets, money, and the business of the world. Read-only — these stories don't post to Matrix."},
|
|
{Slug: "lego", Title: "LEGO", Theme: "lego", Emoji: "🧱", Blurb: "Sets, releases, and the wider brick-building world."},
|
|
{Slug: "adventure", Title: "Adventure", Theme: "adventure", Emoji: "⚔️", Blurb: "Dispatches from the realm — deaths, first-clears, arrivals, and rivalries. Reporting from the field, this is Pete."},
|
|
}
|
|
|
|
// SourceInfo is the trimmed view of a configured feed for the settings panel.
|
|
type SourceInfo struct {
|
|
Name string `json:"name"`
|
|
Channel string `json:"channel"`
|
|
}
|
|
|
|
// Server is the HTTP server for Pete's web UI.
|
|
type Server struct {
|
|
cfg config.WebConfig
|
|
sources []SourceInfo
|
|
postingEnabled bool // false = web-only mode; hides Matrix-posting UI
|
|
srv *http.Server
|
|
tpls map[string]*template.Template // keyed by page name (e.g. "index", "channel")
|
|
auth *Authenticator // nil when sign-in is disabled or unavailable
|
|
tts *ttsService // nil when server-side read-aloud is disabled
|
|
adminSubs map[string]bool // OIDC subjects allowed to view /status
|
|
pushHTTP *http.Client // SSRF-guarded client for Web Push delivery; built lazily by pushClient()
|
|
adv config.AdventureConfig // gogobee adventure-news seam
|
|
advPost PriorityPoster // posts priority adventure beats to Matrix; nil = web-only
|
|
|
|
// Daily-rotated salt for the privacy-preserving unique-visitor estimate.
|
|
// Guarded by metricsMu; never persisted (see metrics.go).
|
|
metricsMu sync.Mutex
|
|
saltDay int64
|
|
salt [16]byte
|
|
}
|
|
|
|
// New builds the server. Templates are parsed once at startup. Each page
|
|
// gets its own template set sharing layout.html + _card.html, which avoids
|
|
// `main` define collisions between pages.
|
|
func New(cfg config.WebConfig, sources []config.SourceConfig, postingEnabled bool, adv config.AdventureConfig, advPost PriorityPoster) (*Server, error) {
|
|
pages := []string{"index", "channel", "weather", "bookmarks", "for-you", "status", "story"}
|
|
tpls := make(map[string]*template.Template, len(pages))
|
|
for _, p := range pages {
|
|
t, err := template.New("").Funcs(funcs).ParseFS(templateFS,
|
|
"templates/layout.html",
|
|
"templates/_card.html",
|
|
"templates/"+p+".html",
|
|
)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
tpls[p] = t
|
|
}
|
|
infos := make([]SourceInfo, 0, len(sources))
|
|
for _, sc := range sources {
|
|
if !sc.Enabled || sc.Name == "" {
|
|
continue
|
|
}
|
|
infos = append(infos, SourceInfo{Name: sc.Name, Channel: sc.DirectRoute})
|
|
}
|
|
adminSubs := make(map[string]bool, len(cfg.AdminSubs))
|
|
for _, sub := range cfg.AdminSubs {
|
|
if sub != "" {
|
|
adminSubs[sub] = true
|
|
}
|
|
}
|
|
s := &Server{cfg: cfg, sources: infos, postingEnabled: postingEnabled, tpls: tpls, adminSubs: adminSubs, adv: adv, advPost: advPost}
|
|
|
|
// Optional OIDC sign-in (Authentik). Discovery is a network call; if the
|
|
// provider is unreachable at boot we log and serve anonymously rather than
|
|
// refusing to start the whole site.
|
|
if cfg.Auth.Enabled {
|
|
dctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
|
auth, err := newAuthenticator(dctx, cfg.Auth)
|
|
cancel()
|
|
if err != nil {
|
|
slog.Error("web: OIDC auth init failed; sign-in disabled", "err", err)
|
|
} else {
|
|
s.auth = auth
|
|
slog.Info("web: OIDC sign-in enabled", "issuer", cfg.Auth.Issuer)
|
|
}
|
|
}
|
|
|
|
// Optional server-side neural read-aloud (Piper). Signed-in only, so it is
|
|
// only useful alongside auth; newTTS logs and disables itself on any misconfig.
|
|
if s.auth != nil {
|
|
tts, err := newTTS(cfg.TTS)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
s.tts = tts
|
|
} else if cfg.TTS.Enabled {
|
|
slog.Warn("web: TTS enabled but auth is off; read-aloud is signed-in only, so it stays disabled")
|
|
}
|
|
|
|
mux := http.NewServeMux()
|
|
|
|
staticSub, err := fs.Sub(staticFS, "static")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
mux.Handle("GET /static/", http.StripPrefix("/static/", http.FileServer(http.FS(staticSub))))
|
|
mux.HandleFunc("GET /img/{name}", s.handleImg)
|
|
|
|
mux.HandleFunc("GET /manifest.webmanifest", s.handleManifest)
|
|
mux.HandleFunc("GET /sw.js", s.handleServiceWorker)
|
|
|
|
mux.HandleFunc("GET /{$}", s.handleIndex)
|
|
mux.HandleFunc("GET /weather", s.handleWeatherDemo)
|
|
mux.HandleFunc("GET /api/search", s.handleSearch)
|
|
mux.HandleFunc("GET /api/article", s.handleArticle)
|
|
mux.HandleFunc("GET /api/related", s.handleRelated)
|
|
mux.HandleFunc("GET /feed.xml", func(w http.ResponseWriter, r *http.Request) { s.handleFeedXML(w, r, "") })
|
|
mux.HandleFunc("GET /feed.json", func(w http.ResponseWriter, r *http.Request) { s.handleFeedJSON(w, r, "") })
|
|
for _, ch := range channels {
|
|
ch := ch
|
|
mux.HandleFunc("GET /"+ch.Slug, func(w http.ResponseWriter, r *http.Request) {
|
|
s.handleChannel(w, r, ch)
|
|
})
|
|
mux.HandleFunc("GET /"+ch.Slug+"/feed.xml", func(w http.ResponseWriter, r *http.Request) { s.handleFeedXML(w, r, ch.Slug) })
|
|
mux.HandleFunc("GET /"+ch.Slug+"/feed.json", func(w http.ResponseWriter, r *http.Request) { s.handleFeedJSON(w, r, ch.Slug) })
|
|
}
|
|
// Public source-health page. The handler renders a trimmed reader view for
|
|
// everyone and the full diagnostic view only for admins, so it lives outside
|
|
// the auth block.
|
|
mux.HandleFunc("GET /status", s.handleStatus)
|
|
|
|
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, _ *http.Request) {
|
|
w.WriteHeader(http.StatusOK)
|
|
_, _ = w.Write([]byte("ok"))
|
|
})
|
|
|
|
// gogobee adventure-news ingest. Bearer-authed (not OIDC), so it lives
|
|
// outside the sign-in block. Self-gates on adv.Enabled (404 when off).
|
|
mux.HandleFunc("POST /api/ingest/adventure", s.handleAdventureIngest)
|
|
|
|
// Per-dispatch permalink (the article_url every ingested story points at).
|
|
// Public GET; self-gates on adv.Enabled. Distinct from GET /adventure (the
|
|
// channel listing, registered in the channels loop above).
|
|
mux.HandleFunc("GET /adventure/{guid}", s.handleAdventureStory)
|
|
|
|
// Themed per-event emblem (card + og:image). A distinct path depth from
|
|
// /adventure/{guid}, so the two patterns never overlap.
|
|
mux.HandleFunc("GET /adventure/art/{type}", s.handleAdventureArt)
|
|
|
|
if s.auth != nil {
|
|
mux.HandleFunc("GET /auth/login", s.auth.handleLogin)
|
|
mux.HandleFunc("GET /auth/callback", s.auth.handleCallback)
|
|
mux.HandleFunc("GET /auth/logout", s.auth.handleLogout)
|
|
mux.HandleFunc("GET /api/preferences", s.handlePrefs)
|
|
mux.HandleFunc("PUT /api/preferences", s.handlePrefs)
|
|
mux.HandleFunc("POST /api/read", s.handleRead)
|
|
mux.HandleFunc("POST /api/bookmark", s.handleBookmark)
|
|
mux.HandleFunc("GET /api/state", s.handleState)
|
|
mux.HandleFunc("GET /bookmarks", s.handleBookmarks)
|
|
mux.HandleFunc("GET /for-you", s.handleForYou)
|
|
if s.cfg.Push.Enabled {
|
|
mux.HandleFunc("POST /api/push/subscribe", s.handlePushSubscribe)
|
|
mux.HandleFunc("POST /api/push/unsubscribe", s.handlePushUnsubscribe)
|
|
}
|
|
if s.tts != nil {
|
|
mux.HandleFunc("POST /api/tts", s.handleTTS)
|
|
}
|
|
}
|
|
|
|
s.srv = &http.Server{
|
|
Addr: cfg.ListenAddr,
|
|
Handler: mux,
|
|
ReadHeaderTimeout: 5 * time.Second,
|
|
}
|
|
return s, nil
|
|
}
|
|
|
|
// Start runs the HTTP server and blocks until ctx is canceled.
|
|
func (s *Server) Start(ctx context.Context) {
|
|
go func() {
|
|
<-ctx.Done()
|
|
shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
defer cancel()
|
|
_ = s.srv.Shutdown(shutdownCtx)
|
|
}()
|
|
slog.Info("web server listening", "addr", s.cfg.ListenAddr)
|
|
if err := s.srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
|
slog.Error("web server crashed", "err", err)
|
|
}
|
|
}
|