Files
Pete/internal/web/handlers.go
T
prosolis b19ab5eff0 adventure: tell a player what happened while they were away
The site could only reach somebody who was already looking at it. Push
existed and adventure used none of it, so the one communal event in the
game -- the Siege -- was invisible to anyone not sitting in Matrix, and a
player whose adventurer died found out whenever they next opened a tab.

Four opt-in categories, every one of them off until asked for: the Siege
(realm-wide, begins and ends), your expedition ending, your adventurer
wandering off, and a contract landing on you. Turning on news
notifications is not consent to be told about the game, so nothing here
enrolls anybody automatically.

No new wire. Every trigger is a dispatch already landing in
adventure_events, so this is Pete-side only and gogobee is untouched.

Two things it needed from storage. push_subscriptions now keeps the
Matrix localpart alongside the OIDC subject, because every ownership
join in the schema is keyed on the localpart and the sender runs on a
ticker with no session to read one from -- without it there is no way to
answer "whose adventurer is this". And the alerts carry their own
watermark, kept apart from the digest's: the two run on different clocks
and one column would let each consume the other's backlog.

The ownership join is re-read on every pass rather than trusted from the
subscription row, so an opt-out or a removal closes the channel at once.
It fails closed in both directions, and an unresolved owner can never
fall through to a broadcast -- a game alert naming somebody's adventurer,
delivered to the wrong phone, is a privacy leak dressed as a feature.

An existing subscription carries watermark 0, which read literally means
"has never been told anything" and would page every subscriber for the
whole history of the realm on the first tick after deploy. Those rows are
stamped to now and start from the next dispatch.

Verified against a running Pete with a real push service, real P-256
client keys and real encryption: the right person is notified, the wrong
one is not, a second pass is silent, and dropping the player from the
board takes the channel with it.

Claude-Session: https://claude.ai/code/session_012bxpQQJDjC1mTtLN3VVtBQ
2026-07-24 18:22:34 -07:00

732 lines
22 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package web
import (
"encoding/json"
"fmt"
"html/template"
"log/slog"
"net/http"
"strconv"
"strings"
"time"
"pete/internal/storage"
)
const pageSize = 24
// StoryView is the trimmed-down record used in templates.
type StoryView struct {
ID int64
GUID string
Headline string
Lede string
ImageURL string
ArticleURL string
Source string
SeenAt time.Time
Posted bool
Paywalled bool // source is gated and no archive workaround succeeded
Channel string // channel slug; also the theme key
ReadMins int // estimated reading time in minutes; 0 = unknown (no chip)
Views int // all-time reader-mode opens; 0 = none yet (no badge)
Accent string // adventure only: the event family's colour, "" for everything else
Ceremony bool // adventure only: this is a realm-first and gets the ribbon
}
func toView(s storage.Story) StoryView {
return StoryView{
ID: s.ID,
GUID: s.GUID,
Headline: s.Headline,
Lede: s.Lede,
ImageURL: s.ImageURL,
ArticleURL: s.ArticleURL,
Source: s.Source,
SeenAt: time.Unix(s.SeenAt, 0),
Posted: s.Posted,
Paywalled: s.Paywalled,
Channel: s.Channel,
}
}
// decorate fills the ReadMins and Views fields on one or more groups of story
// cards using two cheap batch queries over the whole id set. Called just before
// render so every listing (home rails, channel pages, bookmarks, for-you) shows
// a reading-time chip and a read count without each list query having to carry
// those columns. Best-effort: a metrics hiccup just leaves the badges off.
func decorate(groups ...[]StoryView) {
var ids []int64
seen := make(map[int64]bool)
for _, g := range groups {
for _, v := range g {
if v.ID > 0 && !seen[v.ID] {
seen[v.ID] = true
ids = append(ids, v.ID)
}
}
}
if len(ids) == 0 {
return
}
lengths := storage.StoryContentLengths(ids)
views := storage.StoryViewTotals(ids)
for _, g := range groups {
for i := range g {
g[i].ReadMins = readMinutes(lengths[g[i].ID])
g[i].Views = views[g[i].ID]
}
}
decorateAdventure(groups...)
}
// decorateAdventure tints adventure cards by what they are: the event family's
// accent on the border, and the realm-first ribbon on a first-ever.
//
// The information was always there — gogobee computes the rarity of a find and
// whether a clear is the realm's first, and both were being spent on a sentence
// and then thrown away. A feed where a legendary hoard and a routine repeat look
// identical is throwing away the game's own sense of occasion.
//
// One batched read over the guids of the adventure cards only, skipped entirely
// on a page with none — which is most pages. Best-effort like the rest of
// decorate: a miss leaves the default border, not a broken card.
func decorateAdventure(groups ...[]StoryView) {
var guids []string
seen := make(map[string]bool)
for _, g := range groups {
for _, v := range g {
if v.Channel == "adventure" && v.GUID != "" && !seen[v.GUID] {
seen[v.GUID] = true
guids = append(guids, v.GUID)
}
}
}
if len(guids) == 0 {
return
}
facets := storage.AdventureEventFacets(guids)
for _, g := range groups {
for i := range g {
ev, ok := facets[g[i].GUID]
if !ok {
continue
}
g[i].Accent, g[i].Ceremony = advCardAccent(ev.EventType, ev.Tier, ev.Outcome)
}
}
}
// readMinutes turns a character count into a rounded minutes-to-read estimate,
// assuming ~200 wpm and ~6 characters per word (5-letter words plus a space).
// Any non-empty body reads as at least "1 min".
func readMinutes(chars int) int {
if chars <= 0 {
return 0
}
mins := (chars + 600) / 1200 // round to nearest minute (200 wpm * 6 chars)
if mins < 1 {
mins = 1
}
return mins
}
type pageData struct {
SiteTitle string
Channels []Channel
Active string // slug of active channel, "" for landing
Weather Weather
Phase string // when set, forces day/dawn/dusk/night and disables the clock-driven phase JS
AllSources template.JS // JSON array of {name, channel} for the settings panel
AuthEnabled bool // sign-in is available
User *SessionUser // nil for anonymous visitors
UserPrefs template.JS // signed-in user's stored prefs blob (JSON), or "null"
Path string // current request path, for post-login return
PostingEnabled bool // false = web-only mode; hide Matrix-posting UI
IsAdmin bool // signed-in user is on the admin allowlist (shows /status link)
PushEnabled bool // Web Push is configured (shows the notifications toggle to signed-in users)
PushPublicKey string // VAPID public key handed to the client to subscribe
AdvEnabled bool // the adventure section is configured (shows its alert categories in settings)
TTS template.JS // JSON {enabled, default, voices:[{id,label}]} for read-aloud, or "null"
NoIndex bool // emit <meta name="robots" content="noindex"> — used by the adventure section
OGImage string // absolute og:image URL for link unfurls (adventure emblem); "" = none
}
type channelPage struct {
pageData
Channel Channel
Stories []StoryView
Page int
HasPrev bool
HasNext bool
PrevURL string
NextURL string
Total int
// Roster is the live adventurer board, populated on the adventure section
// only and only on page 1 — it is present tense, and page 2 of an archive is
// not where anyone looks for what's happening right now.
Roster []RosterView
RosterStale bool
ShowRoster bool
// Siege is the war room, summarised into a strip at the top of the section.
// Same page-1-only rule as the roster and for the same reason. It renders
// even with nothing camped, because the link to the history is the other half
// of what makes a live Siege feel like it counts.
Siege SiegeView
}
type indexPage struct {
pageData
JustPosted []StoryView
Stats []channelStat
Latest []StoryView
ForYou []StoryView // personalized rail; empty for anon / no-history users
Trending []StoryView // most-read this week; empty until reads accumulate
}
type channelStat struct {
Channel Channel
LastPostedAt time.Time // zero if never posted
HasLastPosted bool
PostsToday int
Total int
}
// jsForScript marks already-valid JSON as safe to embed in an inline <script>,
// after neutralizing the byte sequences that could break out of that context
// (</script>, HTML comments, and the JS line separators U+2028/U+2029). Without
// this, a stored prefs blob containing "</script>" could inject markup.
func jsForScript(b []byte) template.JS {
r := strings.NewReplacer(
"<", "\\u003c",
">", "\\u003e",
"&", "\\u0026",
"", "\\u2028",
"", "\\u2029",
)
return template.JS(r.Replace(string(b)))
}
func (s *Server) base(r *http.Request) pageData {
srcJSON, err := json.Marshal(s.sources)
if err != nil {
srcJSON = []byte("[]")
}
d := pageData{
SiteTitle: s.cfg.SiteTitle,
Channels: s.channels,
Weather: currentWeather(time.Now()),
AllSources: jsForScript(srcJSON),
AuthEnabled: s.auth != nil,
UserPrefs: template.JS("null"),
Path: r.URL.Path,
PostingEnabled: s.postingEnabled,
PushEnabled: s.auth != nil && s.cfg.Push.Enabled,
PushPublicKey: s.cfg.Push.VAPIDPublicKey,
AdvEnabled: s.adv.Enabled,
TTS: template.JS("null"),
}
if s.tts != nil {
d.TTS = s.tts.clientConfig()
}
if s.auth != nil {
if u := s.auth.userFromRequest(r); u != nil {
d.User = u
d.IsAdmin = s.adminSubs[u.Sub]
if blob, err := storage.GetUserPrefs(u.Sub); err == nil && blob != "" {
d.UserPrefs = jsForScript([]byte(blob))
}
}
}
return d
}
func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) {
s.track(r, "home")
const (
justPostedLimit = 6
latestLimit = 16
trendingLimit = 8
)
postedRows, err := storage.ListRecentlyPosted(justPostedLimit)
if err != nil {
slog.Error("web: list recently posted failed", "err", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
justPosted := make([]StoryView, 0, len(postedRows))
for _, r := range postedRows {
justPosted = append(justPosted, toView(r))
}
latestRows, err := storage.ListAllClassified(latestLimit, 0)
if err != nil {
slog.Error("web: list all classified failed", "err", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
latest := make([]StoryView, 0, len(latestRows))
for _, r := range latestRows {
latest = append(latest, toView(r))
}
dayStart := time.Now().Add(-24 * time.Hour).Unix()
stats := make([]channelStat, 0, len(s.channels))
for _, ch := range s.channels {
total, _ := storage.CountClassifiedByChannel(ch.Slug)
lastTs := storage.GetLastPostTime(ch.Slug)
stat := channelStat{
Channel: ch,
PostsToday: storage.CountPostsInWindow(ch.Slug, dayStart),
Total: total,
}
if lastTs > 0 {
stat.LastPostedAt = time.Unix(lastTs, 0)
stat.HasLastPosted = true
}
stats = append(stats, stat)
}
// Popular this week: most-read stories over the trailing 7 UTC days. Empty
// until reads accumulate, so the section simply doesn't render on a fresh DB.
var trending []StoryView
if trendRows, err := storage.TrendingStories(trendingLimit, storage.UnixDay()-6); err != nil {
slog.Error("web: trending query failed", "err", err)
} else {
for _, row := range trendRows {
trending = append(trending, toView(row))
}
}
data := indexPage{
pageData: s.base(r),
JustPosted: justPosted,
Stats: stats,
Latest: latest,
Trending: trending,
}
// For signed-in users with some read/bookmark history, lead with a small
// personalized rail. ForYou returns nothing for anon / no-history users, so
// the section simply doesn't render for them.
if s.auth != nil {
if u := s.auth.userFromRequest(r); u != nil {
const forYouLimit = 8
if fy, err := storage.ForYou(u.Sub, forYouLimit); err != nil {
slog.Error("web: for-you rail failed", "sub", u.Sub, "err", err)
} else {
for _, row := range fy {
data.ForYou = append(data.ForYou, toView(row))
}
}
}
}
decorate(data.Trending, data.ForYou, data.JustPosted, data.Latest)
s.render(w, "index", data)
}
type forYouPage struct {
pageData
Stories []StoryView
}
// handleForYou renders the dedicated personalized feed. Anonymous visitors are
// sent to sign-in (the route is only registered when auth is on).
func (s *Server) handleForYou(w http.ResponseWriter, r *http.Request) {
u := s.auth.userFromRequest(r)
if u == nil {
http.Redirect(w, r, "/auth/login?next=/for-you", http.StatusSeeOther)
return
}
s.track(r, "for-you")
const limit = 30
rows, err := storage.ForYou(u.Sub, limit)
if err != nil {
slog.Error("web: for-you page failed", "sub", u.Sub, "err", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
views := make([]StoryView, 0, len(rows))
for _, row := range rows {
views = append(views, toView(row))
}
decorate(views)
base := s.base(r)
base.Active = "for-you"
s.render(w, "for-you", forYouPage{pageData: base, Stories: views})
}
func (s *Server) handleChannel(w http.ResponseWriter, r *http.Request, ch Channel) {
s.track(r, ch.Slug)
page := 1
if p := r.URL.Query().Get("page"); p != "" {
if n, err := strconv.Atoi(p); err == nil && n > 0 {
page = n
}
}
offset := (page - 1) * pageSize
rows, err := storage.ListClassifiedByChannel(ch.Slug, pageSize+1, offset)
if err != nil {
slog.Error("web: list failed", "channel", ch.Slug, "err", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
hasNext := len(rows) > pageSize
if hasNext {
rows = rows[:pageSize]
}
views := make([]StoryView, 0, len(rows))
for _, r := range rows {
views = append(views, toView(r))
}
total, _ := storage.CountClassifiedByChannel(ch.Slug)
decorate(views)
base := s.base(r)
base.Active = ch.Slug
// The adventure section names player characters; keep it out of search
// indexes to bound the cached-forever exposure (see plan gap #5).
base.NoIndex = ch.Slug == "adventure"
data := channelPage{
pageData: base,
Channel: ch,
Stories: views,
Page: page,
HasPrev: page > 1,
HasNext: hasNext,
PrevURL: fmt.Sprintf("/%s?page=%d", ch.Slug, page-1),
NextURL: fmt.Sprintf("/%s?page=%d", ch.Slug, page+1),
Total: total,
}
if ch.Slug == "adventure" && page == 1 {
data.Roster, data.RosterStale, _ = s.roster()
data.ShowRoster = true
data.Siege = s.siege()
}
s.render(w, "channel", data)
}
type bookmarksPage struct {
pageData
Stories []StoryView
Page int
HasPrev bool
HasNext bool
PrevURL string
NextURL string
Total int
}
// handleBookmarks lists the signed-in user's bookmarked stories. Anonymous
// visitors are sent to sign-in (the route is only registered when auth is on).
func (s *Server) handleBookmarks(w http.ResponseWriter, r *http.Request) {
u := s.auth.userFromRequest(r)
if u == nil {
http.Redirect(w, r, "/auth/login?next=/bookmarks", http.StatusSeeOther)
return
}
s.track(r, "bookmarks")
page := 1
if p := r.URL.Query().Get("page"); p != "" {
if n, err := strconv.Atoi(p); err == nil && n > 0 {
page = n
}
}
offset := (page - 1) * pageSize
rows, err := storage.ListBookmarks(u.Sub, pageSize+1, offset)
if err != nil {
slog.Error("web: list bookmarks failed", "sub", u.Sub, "err", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
hasNext := len(rows) > pageSize
if hasNext {
rows = rows[:pageSize]
}
views := make([]StoryView, 0, len(rows))
for _, row := range rows {
views = append(views, toView(row))
}
decorate(views)
total, _ := storage.CountBookmarks(u.Sub)
base := s.base(r)
base.Active = "bookmarks"
data := bookmarksPage{
pageData: base,
Stories: views,
Page: page,
HasPrev: page > 1,
HasNext: hasNext,
PrevURL: fmt.Sprintf("/bookmarks?page=%d", page-1),
NextURL: fmt.Sprintf("/bookmarks?page=%d", page+1),
Total: total,
}
s.render(w, "bookmarks", data)
}
var (
demoVariants = []string{"clear", "clouds", "rain", "storm", "hail", "snow", "fog", "haze", "wind", "aurora", "petals", "jacaranda", "motes", "leaves"}
demoIntensities = []string{"light", "medium", "heavy"}
demoPhases = []string{"day", "dawn", "dusk", "night"}
)
type weatherDemoPage struct {
pageData
Variants []string
Intensities []string
Phases []string
Variant string
Intensity string
PhaseSel string
}
func pickOr(v string, allowed []string, fallback string) string {
for _, a := range allowed {
if v == a {
return v
}
}
return fallback
}
func seasonForVariant(v string) string {
switch v {
case "rain":
return "winter"
case "petals", "jacaranda":
return "spring"
case "motes", "haze":
return "summer"
case "leaves", "wind":
return "autumn"
case "hail":
return "winter"
}
return ""
}
func (s *Server) handleWeatherDemo(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query()
variant := pickOr(q.Get("variant"), demoVariants, "jacaranda")
intensity := pickOr(q.Get("intensity"), demoIntensities, "heavy")
phase := pickOr(q.Get("phase"), demoPhases, "day")
base := s.base(r)
base.Weather = Weather{Season: seasonForVariant(variant), Variant: variant, Intensity: intensity}
base.Phase = phase
s.render(w, "weather", weatherDemoPage{
pageData: base,
Variants: demoVariants,
Intensities: demoIntensities,
Phases: demoPhases,
Variant: variant,
Intensity: intensity,
PhaseSel: phase,
})
}
type searchResult struct {
Headline string `json:"headline"`
Lede string `json:"lede,omitempty"`
ArticleURL string `json:"article_url"`
ThumbURL string `json:"thumb_url,omitempty"`
Source string `json:"source"`
Channel string `json:"channel"`
ChannelTitle string `json:"channel_title"`
ChannelTheme string `json:"channel_theme"`
ChannelEmoji string `json:"channel_emoji"`
TimeAgo string `json:"time_ago"`
Posted bool `json:"posted"`
}
func (s *Server) handleSearch(w http.ResponseWriter, r *http.Request) {
q := strings.TrimSpace(r.URL.Query().Get("q"))
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.Header().Set("Cache-Control", "no-store")
if q == "" {
_, _ = w.Write([]byte(`{"results":[]}`))
return
}
const searchLimit = 12
rows, err := storage.SearchStories(q, searchLimit)
if err != nil {
slog.Error("web: search failed", "q", q, "err", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
_ = json.NewEncoder(w).Encode(map[string]any{"results": toSearchResults(rows)})
}
// toSearchResults maps stories to the JSON shape shared by /api/search and
// /api/related, resolving each story's channel to its display metadata.
func toSearchResults(rows []storage.Story) []searchResult {
channelByName := make(map[string]Channel, len(channels))
for _, ch := range channels {
channelByName[ch.Slug] = ch
}
results := make([]searchResult, 0, len(rows))
for _, row := range rows {
ch, ok := channelByName[row.Channel]
if !ok {
ch = Channel{Slug: row.Channel, Title: row.Channel, Theme: row.Channel}
}
results = append(results, searchResult{
Headline: row.Headline,
Lede: row.Lede,
ArticleURL: row.ArticleURL,
ThumbURL: thumbURL(row.ImageURL),
Source: row.Source,
Channel: ch.Slug,
ChannelTitle: ch.Title,
ChannelTheme: ch.Theme,
ChannelEmoji: ch.Emoji,
TimeAgo: shortTimeAgo(time.Unix(row.SeenAt, 0)),
Posted: row.Posted,
})
}
return results
}
// handleRelated returns stories textually similar to a given story, for the
// "You might also like" rail in reader mode. It is public (reader mode works
// for anonymous visitors too); for signed-in users it drops already-read
// stories so recommendations stay fresh.
func (s *Server) handleRelated(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.Header().Set("Cache-Control", "no-store")
id, err := strconv.ParseInt(strings.TrimSpace(r.URL.Query().Get("id")), 10, 64)
if err != nil || id <= 0 {
http.Error(w, `{"error":"bad id"}`, http.StatusBadRequest)
return
}
const relatedLimit = 6
// Over-fetch so dropping already-read stories doesn't starve the rail.
rows, err := storage.RelatedStories(id, relatedLimit*2)
if err != nil {
slog.Error("web: related failed", "id", id, "err", err)
http.Error(w, `{"error":"internal error"}`, http.StatusInternalServerError)
return
}
if s.auth != nil && len(rows) > 0 {
if u := s.auth.userFromRequest(r); u != nil {
ids := make([]int64, 0, len(rows))
for _, row := range rows {
ids = append(ids, row.ID)
}
if read, _, err := storage.UserStoryState(u.Sub, ids); err == nil {
kept := rows[:0]
for _, row := range rows {
if !read[row.ID] {
kept = append(kept, row)
}
}
rows = kept
}
}
}
if len(rows) > relatedLimit {
rows = rows[:relatedLimit]
}
_ = json.NewEncoder(w).Encode(map[string]any{"results": toSearchResults(rows)})
}
// handleArticle serves the stored full text of a single story for reader mode.
// The client already has headline/image/source/time from the card's data
// attributes, so this returns just the body text (and the lede as a fallback
// for stories ingested before content was captured).
func (s *Server) handleArticle(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.Header().Set("Cache-Control", "no-store")
id, err := strconv.ParseInt(strings.TrimSpace(r.URL.Query().Get("id")), 10, 64)
if err != nil || id <= 0 {
http.Error(w, `{"error":"bad id"}`, http.StatusBadRequest)
return
}
content, lede, found, err := storage.GetStoryReaderText(id)
if err != nil {
slog.Error("web: article read failed", "id", id, "err", err)
http.Error(w, `{"error":"internal error"}`, http.StatusInternalServerError)
return
}
if !found {
http.Error(w, `{"error":"not found"}`, http.StatusNotFound)
return
}
// Opening a story in reader mode is our per-story read signal. found==true
// means the id passed the same visibility filter the listings use, so this
// can't be driven to inflate counts for hidden/unclassified stories. Fired
// in the background so serving the body never waits on the write.
go storage.RecordStoryView(id)
_ = json.NewEncoder(w).Encode(map[string]any{"content": content, "lede": lede})
}
func shortTimeAgo(t time.Time) string {
d := time.Since(t)
switch {
case d < time.Minute:
return "just now"
case d < time.Hour:
return fmt.Sprintf("%dm ago", int(d.Minutes()))
case d < 24*time.Hour:
return fmt.Sprintf("%dh ago", int(d.Hours()))
case d < 7*24*time.Hour:
return fmt.Sprintf("%dd ago", int(d.Hours())/24)
default:
return t.Format("Jan 2")
}
}
func (s *Server) render(w http.ResponseWriter, page string, data any) {
tpl, ok := s.tpls[page]
if !ok {
http.Error(w, "unknown page", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := tpl.ExecuteTemplate(w, "layout", data); err != nil {
slog.Error("web: template render failed", "page", page, "err", err)
}
}
var funcs = template.FuncMap{
"thumb": thumbURL,
"channel": func(slug string) Channel {
for _, ch := range channels {
if ch.Slug == slug {
return ch
}
}
return Channel{Slug: slug, Title: slug, Theme: slug}
},
"dict": func(pairs ...any) map[string]any {
m := make(map[string]any, len(pairs)/2)
for i := 0; i+1 < len(pairs); i += 2 {
key, ok := pairs[i].(string)
if !ok {
continue
}
m[key] = pairs[i+1]
}
return m
},
"timeAgo": func(t time.Time) string {
d := time.Since(t)
switch {
case d < time.Minute:
return "just now"
case d < time.Hour:
return fmt.Sprintf("%dm ago", int(d.Minutes()))
case d < 24*time.Hour:
return fmt.Sprintf("%dh ago", int(d.Hours()))
case d < 7*24*time.Hour:
return fmt.Sprintf("%dd ago", int(d.Hours())/24)
default:
return t.Format("Jan 2")
}
},
}