// Package web serves Pete's read-only news interface. package web import ( "context" "embed" "errors" "html/template" "io/fs" "log/slog" "net/http" "strings" "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. // // This is the full catalogue, used to resolve a story's channel slug to its // display metadata. It is NOT the list of live sections: an entry can be gated // off (adventure), so route registration and the nav read s.channels instead. 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 channels []Channel // live sections: the catalogue minus anything gated off (adventure) // 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 a layout, which avoids `main` define collisions // between pages. // // There are two layouts, and they are not the same site. The news pages hang off // layout.html: Pete's face, the channel nav, search, the reader, the weather // canvas. The casino hangs off games_layout.html, which shares the *design* — // the palette vars, the fonts, the fat rounded cards — and none of the // furniture. games.parodia.dev is its own place, not a news page with a felt on // it, so it doesn't inherit a single control it has no use for. func New(cfg config.WebConfig, sources []config.SourceConfig, postingEnabled bool, adv config.AdventureConfig, advPost PriorityPoster) (*Server, error) { sets := []struct { layout string shared []string pages []string }{ {"layout", []string{"_card"}, []string{"index", "channel", "weather", "bookmarks", "for-you", "status", "story"}}, {"games_layout", []string{"_chipbar"}, []string{"games", "games_door", "blackjack", "hangman", "solitaire", "trivia", "uno", "holdem"}}, } tpls := make(map[string]*template.Template) for _, set := range sets { for _, p := range set.pages { files := []string{"templates/" + set.layout + ".html"} for _, s := range set.shared { files = append(files, "templates/"+s+".html") } files = append(files, "templates/"+p+".html") t, err := template.New("").Funcs(funcs).ParseFS(templateFS, files...) 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 } } // The adventure section only exists when it's enabled: with the seam off, // there's no nav tab, no /adventure page and no /adventure feed, rather than // an empty section nobody can fill. live := make([]Channel, 0, len(channels)) for _, ch := range channels { if ch.Slug == "adventure" && !adv.Enabled { continue } live = append(live, ch) } s := &Server{cfg: cfg, sources: infos, postingEnabled: postingEnabled, tpls: tpls, adminSubs: adminSubs, adv: adv, advPost: advPost, channels: live} // 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 s.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) // The live board. Ingest is bearer-authed like the fact seam; the read side // is public because it renders on a public page anyway. mux.HandleFunc("POST /api/ingest/roster", s.handleRosterIngest) mux.HandleFunc("GET /api/roster", s.handleRosterAPI) // 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) // The euro/chip border. gogobee polls pending, claims a row, moves the euros, // and pushes the verdict back. Bearer-authed on the same ingest token as the // adventure seam, so like it, these sit outside the sign-in block — the caller // is a machine on the tailnet, not a browser. mux.HandleFunc("GET /api/games/escrow/pending", s.handleEscrowPending) mux.HandleFunc("POST /api/games/escrow/claim", s.handleEscrowClaim) mux.HandleFunc("POST /api/games/escrow/settled", s.handleEscrowSettled) // The casino. Signed-in only — there is money in it — so these hang off the // auth block, and gamesReady() also insists on a Matrix server name: without // one, no player can be named to gogobee's ledger and the tables stay shut. if s.gamesReady() { s.casinoRoutes(mux) } 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: s.hostRouter(mux), ReadHeaderTimeout: 5 * time.Second, } return s, nil } // hostRouter puts the casino at the root of its own hostname without giving it // its own mux. games.parodia.dev and news.parodia.dev are the same process on // the same port — Caddy sends both here — so a request that arrives on the games // host has /games prefixed onto its path, and "/" lands on the lobby. // // Shared plumbing (the API, sign-in, static files, health) is left alone: it is // the same plumbing whichever door you came in by, and the login round-trip in // particular has to keep working on both hosts. func (s *Server) hostRouter(mux http.Handler) http.Handler { host := strings.ToLower(strings.TrimSpace(s.cfg.Games.Host)) if host == "" || !s.gamesReady() { return mux } return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if !strings.EqualFold(hostOnly(r.Host), host) || isSharedPath(r.URL.Path) { mux.ServeHTTP(w, r) return } // Rewrite a copy: the request's URL is shared with anything that logged it. r2 := r.Clone(r.Context()) r2.URL.Path = "/games" + strings.TrimSuffix(r.URL.Path, "/") mux.ServeHTTP(w, r2) }) } // isSharedPath marks the paths that mean the same thing on every host and must // not be shuffled under /games. func isSharedPath(p string) bool { for _, prefix := range []string{"/api/", "/auth/", "/static/", "/img/", "/games"} { if strings.HasPrefix(p, prefix) { return true } } return p == "/healthz" || p == "/sw.js" || p == "/manifest.webmanifest" } // hostOnly strips the port from a Host header. func hostOnly(host string) string { if i := strings.IndexByte(host, ':'); i >= 0 { return host[:i] } return host } // 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) } }