Each roster name becomes /adventure/who/{token}. Anyone sees the public sheet —
stats and equipped gear, decoded from the detail_json gogobee now hangs on each
board entry — with a live JSON re-poll so an open tab tracks HP and room as they
move. The signed-in owner sees the same page enriched with their private
inventory, vault, house, and pets, unlocked by an ownership join in the new
player_self_detail table (localpart owns token) — Pete never reverses the
anonymous token to decide it. buyerLocalpart is extracted so the storefront and
the ownership check lowercase the session name the same way.
360 lines
15 KiB
Go
360 lines
15 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"
|
|
"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
|
|
|
|
// The shared-table machinery. hub fans SSE frames out to the phones at a felt;
|
|
// tableLocks is the striped optimisation over the DB's version column (see
|
|
// games_table.go). Both are nil-safe to construct always: they cost nothing
|
|
// until a table is opened.
|
|
hub *gamesHub
|
|
tableLocks *stripedLocks
|
|
tableGames []tableGame // the multiplayer engines, dispatched through games()
|
|
}
|
|
|
|
// 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", "who"}},
|
|
{"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, hub: newGamesHub(), tableLocks: newStripedLocks(), tableGames: []tableGame{holdemTable{}, unoTable{}}}
|
|
|
|
// 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)
|
|
|
|
// The private, owner-only self-detail (inventory/house/pets). Bearer-authed
|
|
// ingest like the roster; there is no public read — it is only ever served
|
|
// back to its owner, inline on the detail page below.
|
|
mux.HandleFunc("POST /api/ingest/detail", s.handleDetailIngest)
|
|
|
|
// One adventurer's detail page and its live re-poll. Both public (stats +
|
|
// gear); the page adds the owner's private extras when the signed-in viewer
|
|
// owns it. Three path segments, so it never overlaps /adventure/{guid} (two)
|
|
// or /adventure/art/{type}.
|
|
mux.HandleFunc("GET /adventure/who/{token}", s.handleAdventureWho)
|
|
mux.HandleFunc("GET /api/adventure/who/{token}", s.handleAdventureWhoAPI)
|
|
|
|
// 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 mischief storefront's game-box wire: gogobee polls pending orders and
|
|
// pushes a verdict. Bearer-authed on the same ingest token, same reason as the
|
|
// escrow seam — the caller is a machine on the tailnet. The buyer-facing half
|
|
// hangs off the auth block below.
|
|
mux.HandleFunc("GET /api/mischief/pending", s.handleMischiefPending)
|
|
mux.HandleFunc("POST /api/mischief/claim", s.handleMischiefClaim)
|
|
|
|
// 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)
|
|
|
|
// The mischief storefront, buyer side. Signed-in only — an order names a
|
|
// buyer to gogobee's ledger — so like the casino it hangs off the auth
|
|
// block. Only registered when the adventure seam is on; otherwise there is
|
|
// no board to order a hit from.
|
|
if s.adv.Enabled {
|
|
mux.HandleFunc("GET /api/mischief/catalog", s.handleMischiefCatalog)
|
|
mux.HandleFunc("POST /api/mischief/order", s.handleMischiefOrder)
|
|
mux.HandleFunc("GET /api/mischief/orders", s.handleMischiefOrders)
|
|
}
|
|
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)
|
|
}
|
|
}
|