diff --git a/internal/config/config.go b/internal/config/config.go index ab56435..0b7349a 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -34,10 +34,23 @@ type AdventureConfig struct { // PRIORITY adventure beats post to live. If it isn't a configured Matrix // channel, adventure runs website-only (stories still appear on /adventure). Channel string `toml:"channel"` - // DigestHour is the UTC hour [0,23] the daily bulletin digest posts. Default 17. - DigestHour int `toml:"digest_hour"` + // DigestHour is the UTC hour [0,23] the daily bulletin digest posts. Default + // 17. A pointer so digest_hour = 0 (midnight UTC) is distinguishable from + // "unset" and doesn't get silently rewritten to the default. + DigestHour *int `toml:"digest_hour"` } +// DigestHourOrDefault is the UTC hour the daily digest posts, resolving the +// unset case. Safe on a zero-value AdventureConfig. +func (a AdventureConfig) DigestHourOrDefault() int { + if a.DigestHour == nil { + return defaultDigestHour + } + return *a.DigestHour +} + +const defaultDigestHour = 17 + // WebConfig controls the read-only HTTP interface (news.parodia.dev style). type WebConfig struct { Enabled bool `toml:"enabled"` @@ -250,7 +263,7 @@ func (c *Config) validate() error { if c.Adventure.IngestToken == "" { return fmt.Errorf("adventure.ingest_token is required when adventure is enabled (the bearer secret gogobee presents)") } - if c.Adventure.DigestHour < 0 || c.Adventure.DigestHour > 23 { + if h := c.Adventure.DigestHourOrDefault(); h < 0 || h > 23 { return fmt.Errorf("adventure.digest_hour must be 0-23") } if c.Adventure.Channel != "" { @@ -332,9 +345,6 @@ func (c *Config) applyDefaults() { if c.Web.Push.MinStories == 0 { c.Web.Push.MinStories = 3 } - if c.Adventure.DigestHour == 0 { - c.Adventure.DigestHour = 17 - } for i := range c.Sources { if c.Sources[i].PollIntervalMinutes == 0 { c.Sources[i].PollIntervalMinutes = 20 diff --git a/internal/storage/queries.go b/internal/storage/queries.go index fbf3533..999b822 100644 --- a/internal/storage/queries.go +++ b/internal/storage/queries.go @@ -256,29 +256,37 @@ func GetNewestPostableStoryByChannel(channel string) (*Story, error) { // that have never been posted to Matrix (no post_log row). PRIORITY beats post // live and so carry a post_log row; the ones left are exactly the BULLETINs the // daily digest collects. Oldest first so the digest reads chronologically. -func UnpostedAdventureSince(since int64, limit int) ([]Story, error) { - rows, err := Get().Query( - `SELECT guid, headline, lede, article_url, seen_at - FROM stories - WHERE classified = 1 +// +// At most `limit` rows come back, but total is how many match in all — the digest +// quotes it to readers, so it must count the window rather than the returned page. +func UnpostedAdventureSince(since int64, limit int) (stories []Story, total int, err error) { + const where = `WHERE classified = 1 AND channel = 'adventure' AND seen_at >= ? - AND guid NOT IN (SELECT guid FROM post_log) + AND guid NOT IN (SELECT guid FROM post_log)` + + if err := Get().QueryRow(`SELECT COUNT(*) FROM stories `+where, since).Scan(&total); err != nil { + return nil, 0, err + } + + rows, err := Get().Query( + `SELECT guid, headline, lede, article_url, seen_at + FROM stories `+where+` ORDER BY seen_at ASC LIMIT ?`, since, limit) if err != nil { - return nil, err + return nil, 0, err } defer rows.Close() var out []Story for rows.Next() { var s Story if err := rows.Scan(&s.GUID, &s.Headline, &s.Lede, &s.ArticleURL, &s.SeenAt); err != nil { - return nil, err + return nil, 0, err } out = append(out, s) } - return out, rows.Err() + return out, total, rows.Err() } // MarkAdventureDigested records each bulletin guid as posted (in a shared digest diff --git a/internal/web/adventure.go b/internal/web/adventure.go index b8821df..eb0aa3b 100644 --- a/internal/web/adventure.go +++ b/internal/web/adventure.go @@ -8,6 +8,7 @@ import ( "io" "log/slog" "net/http" + "net/url" "strings" "time" @@ -56,6 +57,11 @@ type PriorityPoster func(AdvPost) const advSource = "Pete" +// advBackfillEvent is the synthetic post_log event id used to retire a no_push +// (cold-start backfill) dispatch against the daily digest. It never went to +// Matrix; the row exists only so the digest skips it. +const advBackfillEvent = "adv-backfill" + // handleAdventureIngest receives a game-event fact from gogobee, templates it // into a deterministic story, publishes it to the /adventure section, and posts // PRIORITY beats live to Matrix. Bearer-authed; idempotent on the fact GUID. @@ -100,6 +106,14 @@ func (s *Server) handleAdventureIngest(w http.ResponseWriter, r *http.Request) { return } + // A fact with no occurred_at would otherwise be stored at the Unix epoch: + // dated 1970 on the permalink, pinned to the bottom of the section, and + // outside every digest window. Treat "missing" as "now". + occurredAt := f.OccurredAt + if occurredAt <= 0 { + occurredAt = time.Now().Unix() + } + articleURL := s.advPermalink(f.GUID) imageURL := advArtURL(f.EventType) if err := storage.InsertStory(&storage.Story{ @@ -111,8 +125,8 @@ func (s *Server) handleAdventureIngest(w http.ResponseWriter, r *http.Request) { Source: advSource, Channel: "adventure", Classified: true, - SeenAt: f.OccurredAt, - PublishedAt: f.OccurredAt, + SeenAt: occurredAt, + PublishedAt: occurredAt, }); err != nil { slog.Error("adventure ingest: insert failed", "guid", f.GUID, "err", err) http.Error(w, "insert failed", http.StatusInternalServerError) @@ -120,12 +134,21 @@ func (s *Server) handleAdventureIngest(w http.ResponseWriter, r *http.Request) { } slog.Info("adventure ingest: published", "guid", f.GUID, "event_type", f.EventType, "tier", f.Tier) + // NoPush (cold-start backfill) means "never goes to Matrix". Suppressing only + // the live post isn't enough: the digest collects adventure rows that carry no + // post_log entry, so a backfilled bulletin would still be swept into the next + // roundup — the back-catalogue dump NoPush exists to prevent. Retire the guid + // against the digest up front instead. + if f.NoPush { + storage.MarkAdventureDigested([]string{f.GUID}, advBackfillEvent) + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("ok")) + return + } + // PRIORITY beats post live to Matrix; BULLETIN beats wait for the daily // digest. Website section always gets the row above regardless of tier. - // NoPush (cold-start backfill) suppresses the live post so a launch doesn't - // dump the whole back-catalogue into the channel — the stories still land on - // the website with their backdated timestamps. - if f.Tier == "priority" && !f.NoPush && s.advPost != nil && s.adv.Channel != "" { + if f.Tier == "priority" && s.advPost != nil && s.adv.Channel != "" { // No ImageURL: the emblem is an SVG (Matrix clients often block SVG // media), and the link's og:image carries the preview instead. s.advPost(AdvPost{ @@ -277,14 +300,18 @@ func (s *Server) bearerOK(r *http.Request) bool { return subtle.ConstantTimeCompare([]byte(got), []byte(s.adv.IngestToken)) == 1 } +// siteURL makes a root-relative path absolute against BaseURL. Links that go out +// to Matrix need the absolute form to survive safeHref; when BaseURL isn't +// configured the relative form is all we have, and it's still fine on-site. +func (s *Server) siteURL(path string) string { + return strings.TrimRight(s.cfg.BaseURL, "/") + path +} + // advPermalink builds the per-story Pete permalink used as article_url (the card -// link + Matrix link). Absolute when BaseURL is configured (required for the -// Matrix link to survive safeHref); relative otherwise (fine on-site). +// link + Matrix link). The guid is path-escaped: it's ingest-supplied, and a +// stray "/" or "?" would otherwise produce a link that routes somewhere else. func (s *Server) advPermalink(guid string) string { - if base := strings.TrimRight(s.cfg.BaseURL, "/"); base != "" { - return base + "/adventure/" + guid - } - return "/adventure/" + guid + return s.siteURL("/adventure/" + url.PathEscape(guid)) } // factGuard verifies every player-name field we might render is present in the diff --git a/internal/web/adventure_digest.go b/internal/web/adventure_digest.go index b433d5e..8352b75 100644 --- a/internal/web/adventure_digest.go +++ b/internal/web/adventure_digest.go @@ -44,9 +44,10 @@ func (s *Server) StartAdventureDigest(ctx context.Context) { // 24h. Sleeping to a wall-clock hour (not a fixed ticker from boot) keeps the // digest at a predictable time of day across restarts. func (s *Server) runAdventureDigest(ctx context.Context) { - slog.Info("web: adventure digest scheduler started", "digest_hour_utc", s.adv.DigestHour, "channel", s.adv.Channel) + hour := s.adv.DigestHourOrDefault() + slog.Info("web: adventure digest scheduler started", "digest_hour_utc", hour, "channel", s.adv.Channel) for { - wait := durUntilNextHour(time.Now().UTC(), s.adv.DigestHour) + wait := durUntilNextHour(time.Now().UTC(), hour) select { case <-ctx.Done(): return @@ -72,7 +73,7 @@ func durUntilNextHour(now time.Time, hour int) time.Duration { // a dormant realm should stay quiet, not ship an empty digest. func (s *Server) postDailyDigest(now time.Time) { since := now.Add(-digestWindow).Unix() - bulletins, err := storage.UnpostedAdventureSince(since, digestCap+1) + bulletins, total, err := storage.UnpostedAdventureSince(since, digestCap) if err != nil { slog.Error("adventure digest: query failed", "err", err) return @@ -80,16 +81,13 @@ func (s *Server) postDailyDigest(now time.Time) { if len(bulletins) == 0 { return } - truncated := false - if len(bulletins) > digestCap { - bulletins = bulletins[:digestCap] - truncated = true - slog.Warn("adventure digest: window exceeded cap, truncating", "cap", digestCap) + if total > len(bulletins) { + slog.Warn("adventure digest: window exceeded cap, truncating", "cap", digestCap, "total", total) } date := now.Format("2006-01-02") eventID := "adv-digest:" + date - headline, lede := buildDigest(bulletins, truncated) + headline, lede := buildDigest(bulletins, total) s.advPost(AdvPost{ GUID: eventID, @@ -109,15 +107,16 @@ func (s *Server) postDailyDigest(now time.Time) { } // buildDigest renders the roundup headline + lede in Pete's warm reporter voice. -// Headlines come from stored, fact-guarded rows, so no player-controlled text is -// introduced here. -func buildDigest(bulletins []storage.Story, truncated bool) (headline, lede string) { - n := len(bulletins) - if n == 1 { +// total is how many bulletins the window actually holds, which is larger than +// len(bulletins) when the cap truncated the fetch — the counts quoted to readers +// have to describe the realm, not the slice. Headlines come from stored, +// fact-guarded rows, so no player-controlled text is introduced here. +func buildDigest(bulletins []storage.Story, total int) (headline, lede string) { + if total == 1 { return "Today in the realm: one dispatch.", fmt.Sprintf("Quiet day out there, but one worth noting — %s Full story on the board.", trimHeadline(bulletins[0].Headline)) } - headline = fmt.Sprintf("Today in the realm: %d dispatches.", n) + headline = fmt.Sprintf("Today in the realm: %d dispatches.", total) shown := bulletins if len(shown) > digestPreview { @@ -129,8 +128,8 @@ func buildDigest(bulletins []storage.Story, truncated bool) (headline, lede stri } list := strings.Join(parts, " ") more := "" - if n > len(shown) || truncated { - more = fmt.Sprintf(" …and %d more.", n-len(shown)) + if total > len(shown) { + more = fmt.Sprintf(" …and %d more.", total-len(shown)) } return headline, fmt.Sprintf("Here's what came across the wire today. %s%s The full board's on the site.", list, more) } @@ -152,6 +151,5 @@ func trimHeadline(h string) string { // canonical-URL dedup treats each day's digest as distinct (it strips only // tracking params, keeping ?digest=). func (s *Server) digestURL(date string) string { - base := strings.TrimRight(s.cfg.BaseURL, "/") - return base + "/adventure?digest=" + date + return s.siteURL("/adventure?digest=" + date) } diff --git a/internal/web/handlers.go b/internal/web/handlers.go index cb807a6..fcaba46 100644 --- a/internal/web/handlers.go +++ b/internal/web/handlers.go @@ -160,7 +160,7 @@ func (s *Server) base(r *http.Request) pageData { } d := pageData{ SiteTitle: s.cfg.SiteTitle, - Channels: channels, + Channels: s.channels, Weather: currentWeather(time.Now()), AllSources: jsForScript(srcJSON), AuthEnabled: s.auth != nil, @@ -217,8 +217,8 @@ func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) { } dayStart := time.Now().Add(-24 * time.Hour).Unix() - stats := make([]channelStat, 0, len(channels)) - for _, ch := range channels { + stats := make([]channelStat, 0, len(s.channels)) + for _, ch := range s.channels { total, _ := storage.CountClassifiedByChannel(ch.Slug) lastTs := storage.GetLastPostTime(ch.Slug) stat := channelStat{ diff --git a/internal/web/server.go b/internal/web/server.go index 44fe17b..0ef0fc1 100644 --- a/internal/web/server.go +++ b/internal/web/server.go @@ -31,6 +31,10 @@ type Channel struct { } // 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."}, @@ -64,6 +68,7 @@ type Server struct { 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). @@ -102,7 +107,18 @@ func New(cfg config.WebConfig, sources []config.SourceConfig, postingEnabled boo adminSubs[sub] = true } } - s := &Server{cfg: cfg, sources: infos, postingEnabled: postingEnabled, tpls: tpls, adminSubs: adminSubs, adv: adv, advPost: advPost} + // 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 @@ -150,7 +166,7 @@ func New(cfg config.WebConfig, sources []config.SourceConfig, postingEnabled boo 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 { + for _, ch := range s.channels { ch := ch mux.HandleFunc("GET /"+ch.Slug, func(w http.ResponseWriter, r *http.Request) { s.handleChannel(w, r, ch) diff --git a/internal/web/thumbs.go b/internal/web/thumbs.go index 8cf27e9..60dade3 100644 --- a/internal/web/thumbs.go +++ b/internal/web/thumbs.go @@ -69,8 +69,10 @@ func thumbURL(src string) string { } // Root-relative sources are our own local assets (e.g. the adventure // emblems) — serve them directly; the thumbnailer only handles remote - // http(s) images and would 404 a local path. - if strings.HasPrefix(src, "/") { + // http(s) images and would 404 a local path. "//host/x.jpg" is NOT local: + // it's a protocol-relative remote image, and it has to keep going through + // the guarded thumbnailer like any other third-party URL. + if strings.HasPrefix(src, "/") && !strings.HasPrefix(src, "//") { return src } return "/img/" + thumbKey(src) + ".avif?u=" + url.QueryEscape(src) diff --git a/main.go b/main.go index 946f78b..7202836 100644 --- a/main.go +++ b/main.go @@ -248,6 +248,13 @@ func main() { if postingEnabled && roundRobinMode { channelNames := make([]string, 0, len(cfg.Matrix.Channels)) for name := range cfg.Matrix.Channels { + // The adventure channel drives its own posting: priority beats go + // live at ingest, bulletins wait for the daily digest. Leaving it in + // the rotation would let the scheduler post bulletins one-by-one + // (they're "classified, not yet posted") and steal them from the digest. + if cfg.Adventure.Enabled && name == cfg.Adventure.Channel { + continue + } channelNames = append(channelNames, name) } rr := scheduler.New(channelNames, cfg.Posting.RoundRobin.IntervalHours, queue) @@ -260,16 +267,22 @@ func main() { // Adapter: the web ingest handler posts priority adventure beats live to // Matrix through the same queue everything else uses (bypasses pacing). - advPost := func(p web.AdvPost) { - queue.PostNow(poster.QueueItem{ - GUID: p.GUID, - Headline: p.Headline, - Lede: p.Lede, - ImageURL: p.ImageURL, - ArticleURL: p.ArticleURL, - Source: p.Source, - Channel: p.Channel, - }) + // PostNow doesn't consult posting.enabled, so gate here: with posting off, + // adventure stays website-only like every other channel (nil poster also + // keeps the digest loop from starting). + var advPost web.PriorityPoster + if postingEnabled { + advPost = func(p web.AdvPost) { + queue.PostNow(poster.QueueItem{ + GUID: p.GUID, + Headline: p.Headline, + Lede: p.Lede, + ImageURL: p.ImageURL, + ArticleURL: p.ArticleURL, + Source: p.Source, + Channel: p.Channel, + }) + } } // Start the read-only web UI alongside the Matrix bot.