Adventure news: fix posting gates, digest counts, and section gating
- Don't post adventure beats when posting.enabled=false (PostNow ignores the flag) - Keep the adventure channel out of the round-robin rotation so it can't steal bulletins from the digest - no_push now retires a fact against the digest, not just the live post - Default a missing occurred_at to now instead of the Unix epoch - Keep protocol-relative image URLs on the guarded thumbnailer - Make digest_hour=0 (midnight UTC) settable - Quote the true window total in the digest, not the truncated slice - Hide the Adventure section entirely when it's disabled
This commit is contained in:
@@ -34,10 +34,23 @@ type AdventureConfig struct {
|
|||||||
// PRIORITY adventure beats post to live. If it isn't a configured Matrix
|
// PRIORITY adventure beats post to live. If it isn't a configured Matrix
|
||||||
// channel, adventure runs website-only (stories still appear on /adventure).
|
// channel, adventure runs website-only (stories still appear on /adventure).
|
||||||
Channel string `toml:"channel"`
|
Channel string `toml:"channel"`
|
||||||
// DigestHour is the UTC hour [0,23] the daily bulletin digest posts. Default 17.
|
// DigestHour is the UTC hour [0,23] the daily bulletin digest posts. Default
|
||||||
DigestHour int `toml:"digest_hour"`
|
// 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).
|
// WebConfig controls the read-only HTTP interface (news.parodia.dev style).
|
||||||
type WebConfig struct {
|
type WebConfig struct {
|
||||||
Enabled bool `toml:"enabled"`
|
Enabled bool `toml:"enabled"`
|
||||||
@@ -250,7 +263,7 @@ func (c *Config) validate() error {
|
|||||||
if c.Adventure.IngestToken == "" {
|
if c.Adventure.IngestToken == "" {
|
||||||
return fmt.Errorf("adventure.ingest_token is required when adventure is enabled (the bearer secret gogobee presents)")
|
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")
|
return fmt.Errorf("adventure.digest_hour must be 0-23")
|
||||||
}
|
}
|
||||||
if c.Adventure.Channel != "" {
|
if c.Adventure.Channel != "" {
|
||||||
@@ -332,9 +345,6 @@ func (c *Config) applyDefaults() {
|
|||||||
if c.Web.Push.MinStories == 0 {
|
if c.Web.Push.MinStories == 0 {
|
||||||
c.Web.Push.MinStories = 3
|
c.Web.Push.MinStories = 3
|
||||||
}
|
}
|
||||||
if c.Adventure.DigestHour == 0 {
|
|
||||||
c.Adventure.DigestHour = 17
|
|
||||||
}
|
|
||||||
for i := range c.Sources {
|
for i := range c.Sources {
|
||||||
if c.Sources[i].PollIntervalMinutes == 0 {
|
if c.Sources[i].PollIntervalMinutes == 0 {
|
||||||
c.Sources[i].PollIntervalMinutes = 20
|
c.Sources[i].PollIntervalMinutes = 20
|
||||||
|
|||||||
@@ -256,29 +256,37 @@ func GetNewestPostableStoryByChannel(channel string) (*Story, error) {
|
|||||||
// that have never been posted to Matrix (no post_log row). PRIORITY beats post
|
// 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
|
// 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.
|
// daily digest collects. Oldest first so the digest reads chronologically.
|
||||||
func UnpostedAdventureSince(since int64, limit int) ([]Story, error) {
|
//
|
||||||
rows, err := Get().Query(
|
// At most `limit` rows come back, but total is how many match in all — the digest
|
||||||
`SELECT guid, headline, lede, article_url, seen_at
|
// quotes it to readers, so it must count the window rather than the returned page.
|
||||||
FROM stories
|
func UnpostedAdventureSince(since int64, limit int) (stories []Story, total int, err error) {
|
||||||
WHERE classified = 1
|
const where = `WHERE classified = 1
|
||||||
AND channel = 'adventure'
|
AND channel = 'adventure'
|
||||||
AND seen_at >= ?
|
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
|
ORDER BY seen_at ASC
|
||||||
LIMIT ?`, since, limit)
|
LIMIT ?`, since, limit)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, 0, err
|
||||||
}
|
}
|
||||||
defer rows.Close()
|
defer rows.Close()
|
||||||
var out []Story
|
var out []Story
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var s Story
|
var s Story
|
||||||
if err := rows.Scan(&s.GUID, &s.Headline, &s.Lede, &s.ArticleURL, &s.SeenAt); err != nil {
|
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)
|
out = append(out, s)
|
||||||
}
|
}
|
||||||
return out, rows.Err()
|
return out, total, rows.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
// MarkAdventureDigested records each bulletin guid as posted (in a shared digest
|
// MarkAdventureDigested records each bulletin guid as posted (in a shared digest
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/url"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -56,6 +57,11 @@ type PriorityPoster func(AdvPost)
|
|||||||
|
|
||||||
const advSource = "Pete"
|
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
|
// handleAdventureIngest receives a game-event fact from gogobee, templates it
|
||||||
// into a deterministic story, publishes it to the /adventure section, and posts
|
// into a deterministic story, publishes it to the /adventure section, and posts
|
||||||
// PRIORITY beats live to Matrix. Bearer-authed; idempotent on the fact GUID.
|
// 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
|
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)
|
articleURL := s.advPermalink(f.GUID)
|
||||||
imageURL := advArtURL(f.EventType)
|
imageURL := advArtURL(f.EventType)
|
||||||
if err := storage.InsertStory(&storage.Story{
|
if err := storage.InsertStory(&storage.Story{
|
||||||
@@ -111,8 +125,8 @@ func (s *Server) handleAdventureIngest(w http.ResponseWriter, r *http.Request) {
|
|||||||
Source: advSource,
|
Source: advSource,
|
||||||
Channel: "adventure",
|
Channel: "adventure",
|
||||||
Classified: true,
|
Classified: true,
|
||||||
SeenAt: f.OccurredAt,
|
SeenAt: occurredAt,
|
||||||
PublishedAt: f.OccurredAt,
|
PublishedAt: occurredAt,
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
slog.Error("adventure ingest: insert failed", "guid", f.GUID, "err", err)
|
slog.Error("adventure ingest: insert failed", "guid", f.GUID, "err", err)
|
||||||
http.Error(w, "insert failed", http.StatusInternalServerError)
|
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)
|
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
|
// PRIORITY beats post live to Matrix; BULLETIN beats wait for the daily
|
||||||
// digest. Website section always gets the row above regardless of tier.
|
// digest. Website section always gets the row above regardless of tier.
|
||||||
// NoPush (cold-start backfill) suppresses the live post so a launch doesn't
|
if f.Tier == "priority" && s.advPost != nil && s.adv.Channel != "" {
|
||||||
// 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 != "" {
|
|
||||||
// No ImageURL: the emblem is an SVG (Matrix clients often block SVG
|
// No ImageURL: the emblem is an SVG (Matrix clients often block SVG
|
||||||
// media), and the link's og:image carries the preview instead.
|
// media), and the link's og:image carries the preview instead.
|
||||||
s.advPost(AdvPost{
|
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
|
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
|
// advPermalink builds the per-story Pete permalink used as article_url (the card
|
||||||
// link + Matrix link). Absolute when BaseURL is configured (required for the
|
// link + Matrix link). The guid is path-escaped: it's ingest-supplied, and a
|
||||||
// Matrix link to survive safeHref); relative otherwise (fine on-site).
|
// stray "/" or "?" would otherwise produce a link that routes somewhere else.
|
||||||
func (s *Server) advPermalink(guid string) string {
|
func (s *Server) advPermalink(guid string) string {
|
||||||
if base := strings.TrimRight(s.cfg.BaseURL, "/"); base != "" {
|
return s.siteURL("/adventure/" + url.PathEscape(guid))
|
||||||
return base + "/adventure/" + guid
|
|
||||||
}
|
|
||||||
return "/adventure/" + guid
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// factGuard verifies every player-name field we might render is present in the
|
// factGuard verifies every player-name field we might render is present in the
|
||||||
|
|||||||
@@ -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
|
// 24h. Sleeping to a wall-clock hour (not a fixed ticker from boot) keeps the
|
||||||
// digest at a predictable time of day across restarts.
|
// digest at a predictable time of day across restarts.
|
||||||
func (s *Server) runAdventureDigest(ctx context.Context) {
|
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 {
|
for {
|
||||||
wait := durUntilNextHour(time.Now().UTC(), s.adv.DigestHour)
|
wait := durUntilNextHour(time.Now().UTC(), hour)
|
||||||
select {
|
select {
|
||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
return
|
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.
|
// a dormant realm should stay quiet, not ship an empty digest.
|
||||||
func (s *Server) postDailyDigest(now time.Time) {
|
func (s *Server) postDailyDigest(now time.Time) {
|
||||||
since := now.Add(-digestWindow).Unix()
|
since := now.Add(-digestWindow).Unix()
|
||||||
bulletins, err := storage.UnpostedAdventureSince(since, digestCap+1)
|
bulletins, total, err := storage.UnpostedAdventureSince(since, digestCap)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("adventure digest: query failed", "err", err)
|
slog.Error("adventure digest: query failed", "err", err)
|
||||||
return
|
return
|
||||||
@@ -80,16 +81,13 @@ func (s *Server) postDailyDigest(now time.Time) {
|
|||||||
if len(bulletins) == 0 {
|
if len(bulletins) == 0 {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
truncated := false
|
if total > len(bulletins) {
|
||||||
if len(bulletins) > digestCap {
|
slog.Warn("adventure digest: window exceeded cap, truncating", "cap", digestCap, "total", total)
|
||||||
bulletins = bulletins[:digestCap]
|
|
||||||
truncated = true
|
|
||||||
slog.Warn("adventure digest: window exceeded cap, truncating", "cap", digestCap)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
date := now.Format("2006-01-02")
|
date := now.Format("2006-01-02")
|
||||||
eventID := "adv-digest:" + date
|
eventID := "adv-digest:" + date
|
||||||
headline, lede := buildDigest(bulletins, truncated)
|
headline, lede := buildDigest(bulletins, total)
|
||||||
|
|
||||||
s.advPost(AdvPost{
|
s.advPost(AdvPost{
|
||||||
GUID: eventID,
|
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.
|
// 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
|
// total is how many bulletins the window actually holds, which is larger than
|
||||||
// introduced here.
|
// len(bulletins) when the cap truncated the fetch — the counts quoted to readers
|
||||||
func buildDigest(bulletins []storage.Story, truncated bool) (headline, lede string) {
|
// have to describe the realm, not the slice. Headlines come from stored,
|
||||||
n := len(bulletins)
|
// fact-guarded rows, so no player-controlled text is introduced here.
|
||||||
if n == 1 {
|
func buildDigest(bulletins []storage.Story, total int) (headline, lede string) {
|
||||||
|
if total == 1 {
|
||||||
return "Today in the realm: one dispatch.",
|
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))
|
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
|
shown := bulletins
|
||||||
if len(shown) > digestPreview {
|
if len(shown) > digestPreview {
|
||||||
@@ -129,8 +128,8 @@ func buildDigest(bulletins []storage.Story, truncated bool) (headline, lede stri
|
|||||||
}
|
}
|
||||||
list := strings.Join(parts, " ")
|
list := strings.Join(parts, " ")
|
||||||
more := ""
|
more := ""
|
||||||
if n > len(shown) || truncated {
|
if total > len(shown) {
|
||||||
more = fmt.Sprintf(" …and %d more.", n-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)
|
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
|
// canonical-URL dedup treats each day's digest as distinct (it strips only
|
||||||
// tracking params, keeping ?digest=).
|
// tracking params, keeping ?digest=).
|
||||||
func (s *Server) digestURL(date string) string {
|
func (s *Server) digestURL(date string) string {
|
||||||
base := strings.TrimRight(s.cfg.BaseURL, "/")
|
return s.siteURL("/adventure?digest=" + date)
|
||||||
return base + "/adventure?digest=" + date
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -160,7 +160,7 @@ func (s *Server) base(r *http.Request) pageData {
|
|||||||
}
|
}
|
||||||
d := pageData{
|
d := pageData{
|
||||||
SiteTitle: s.cfg.SiteTitle,
|
SiteTitle: s.cfg.SiteTitle,
|
||||||
Channels: channels,
|
Channels: s.channels,
|
||||||
Weather: currentWeather(time.Now()),
|
Weather: currentWeather(time.Now()),
|
||||||
AllSources: jsForScript(srcJSON),
|
AllSources: jsForScript(srcJSON),
|
||||||
AuthEnabled: s.auth != nil,
|
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()
|
dayStart := time.Now().Add(-24 * time.Hour).Unix()
|
||||||
stats := make([]channelStat, 0, len(channels))
|
stats := make([]channelStat, 0, len(s.channels))
|
||||||
for _, ch := range channels {
|
for _, ch := range s.channels {
|
||||||
total, _ := storage.CountClassifiedByChannel(ch.Slug)
|
total, _ := storage.CountClassifiedByChannel(ch.Slug)
|
||||||
lastTs := storage.GetLastPostTime(ch.Slug)
|
lastTs := storage.GetLastPostTime(ch.Slug)
|
||||||
stat := channelStat{
|
stat := channelStat{
|
||||||
|
|||||||
@@ -31,6 +31,10 @@ type Channel struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// channels in display order. Add a new entry here to add a section.
|
// 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{
|
var channels = []Channel{
|
||||||
{Slug: "gaming", Title: "Gaming", Theme: "gaming", Emoji: "🎮", Blurb: "Releases, platforms, and the people making the games."},
|
{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: "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()
|
pushHTTP *http.Client // SSRF-guarded client for Web Push delivery; built lazily by pushClient()
|
||||||
adv config.AdventureConfig // gogobee adventure-news seam
|
adv config.AdventureConfig // gogobee adventure-news seam
|
||||||
advPost PriorityPoster // posts priority adventure beats to Matrix; nil = web-only
|
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.
|
// Daily-rotated salt for the privacy-preserving unique-visitor estimate.
|
||||||
// Guarded by metricsMu; never persisted (see metrics.go).
|
// 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
|
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
|
// Optional OIDC sign-in (Authentik). Discovery is a network call; if the
|
||||||
// provider is unreachable at boot we log and serve anonymously rather than
|
// 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 /api/related", s.handleRelated)
|
||||||
mux.HandleFunc("GET /feed.xml", func(w http.ResponseWriter, r *http.Request) { s.handleFeedXML(w, r, "") })
|
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, "") })
|
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
|
ch := ch
|
||||||
mux.HandleFunc("GET /"+ch.Slug, func(w http.ResponseWriter, r *http.Request) {
|
mux.HandleFunc("GET /"+ch.Slug, func(w http.ResponseWriter, r *http.Request) {
|
||||||
s.handleChannel(w, r, ch)
|
s.handleChannel(w, r, ch)
|
||||||
|
|||||||
@@ -69,8 +69,10 @@ func thumbURL(src string) string {
|
|||||||
}
|
}
|
||||||
// Root-relative sources are our own local assets (e.g. the adventure
|
// Root-relative sources are our own local assets (e.g. the adventure
|
||||||
// emblems) — serve them directly; the thumbnailer only handles remote
|
// emblems) — serve them directly; the thumbnailer only handles remote
|
||||||
// http(s) images and would 404 a local path.
|
// http(s) images and would 404 a local path. "//host/x.jpg" is NOT local:
|
||||||
if strings.HasPrefix(src, "/") {
|
// 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 src
|
||||||
}
|
}
|
||||||
return "/img/" + thumbKey(src) + ".avif?u=" + url.QueryEscape(src)
|
return "/img/" + thumbKey(src) + ".avif?u=" + url.QueryEscape(src)
|
||||||
|
|||||||
15
main.go
15
main.go
@@ -248,6 +248,13 @@ func main() {
|
|||||||
if postingEnabled && roundRobinMode {
|
if postingEnabled && roundRobinMode {
|
||||||
channelNames := make([]string, 0, len(cfg.Matrix.Channels))
|
channelNames := make([]string, 0, len(cfg.Matrix.Channels))
|
||||||
for name := range 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)
|
channelNames = append(channelNames, name)
|
||||||
}
|
}
|
||||||
rr := scheduler.New(channelNames, cfg.Posting.RoundRobin.IntervalHours, queue)
|
rr := scheduler.New(channelNames, cfg.Posting.RoundRobin.IntervalHours, queue)
|
||||||
@@ -260,7 +267,12 @@ func main() {
|
|||||||
|
|
||||||
// Adapter: the web ingest handler posts priority adventure beats live to
|
// Adapter: the web ingest handler posts priority adventure beats live to
|
||||||
// Matrix through the same queue everything else uses (bypasses pacing).
|
// Matrix through the same queue everything else uses (bypasses pacing).
|
||||||
advPost := func(p web.AdvPost) {
|
// 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{
|
queue.PostNow(poster.QueueItem{
|
||||||
GUID: p.GUID,
|
GUID: p.GUID,
|
||||||
Headline: p.Headline,
|
Headline: p.Headline,
|
||||||
@@ -271,6 +283,7 @@ func main() {
|
|||||||
Channel: p.Channel,
|
Channel: p.Channel,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Start the read-only web UI alongside the Matrix bot.
|
// Start the read-only web UI alongside the Matrix bot.
|
||||||
if cfg.Web.Enabled {
|
if cfg.Web.Enabled {
|
||||||
|
|||||||
Reference in New Issue
Block a user