diff --git a/.env.example b/.env.example index 924de57..9f5bfbd 100644 --- a/.env.example +++ b/.env.example @@ -70,3 +70,15 @@ GITEA_REPO=tickets # Label name applied to planned-outage tickets (default: planned-outage). # The label is created automatically if it does not exist. GITEA_LABEL=planned-outage + +# ------------------------------------------------------------------ +# Twitch integration (live badge + stream page) +# ------------------------------------------------------------------ + +# Twitch application credentials. +# Create an app at: https://dev.twitch.tv/console +TWITCH_CLIENT_ID=your-client-id-here +TWITCH_CLIENT_SECRET=your-client-secret-here + +# Your Twitch channel name (lowercase). +TWITCH_CHANNEL=your-twitch-channel-here diff --git a/cmd/server/main.go b/cmd/server/main.go index bc66d13..d716326 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -45,6 +45,9 @@ func main() { checker.Start(dataDir, checkInterval) log.Printf("service checker running every %s", checkInterval) + // Start Twitch live status poller + h.StartTwitch(0) + mux := http.NewServeMux() // Static files @@ -70,6 +73,7 @@ func main() { mux.HandleFunc("GET /resume", h.Resume) mux.HandleFunc("POST /newsletter", h.NewsletterPost) mux.HandleFunc("GET /changelog", h.Changelog) + mux.HandleFunc("GET /stream", h.Stream) mux.HandleFunc("GET /sitemap.xml", h.Sitemap) // Admin routes (auth handled per-handler) diff --git a/data/status.json b/data/status.json index 603d853..b4ad29c 100644 --- a/data/status.json +++ b/data/status.json @@ -1,5 +1,5 @@ { - "last_checked": "2026-04-11T14:36:31.836073877Z", + "last_checked": "2026-04-12T22:44:56.369949582Z", "services": [ { "name": "Web (httpd)", diff --git a/data/uptime.json b/data/uptime.json index a73d929..8493329 100644 --- a/data/uptime.json +++ b/data/uptime.json @@ -142,5 +142,21 @@ "VPN (WireGuard)": "up", "Web (httpd)": "up" } + }, + { + "time": "2026-04-12T22:00:00Z", + "statuses": { + "DNS (nsd)": "up", + "DNS (unbound)": "up", + "Email (OpenSMTPD)": "up", + "Game Servers": "up", + "Gitea": "up", + "Grafana": "up", + "Jellyfin": "up", + "Matrix (Conduit)": "up", + "Monitoring (Prometheus)": "up", + "VPN (WireGuard)": "up", + "Web (httpd)": "up" + } } ] \ No newline at end of file diff --git a/internal/handler/handler.go b/internal/handler/handler.go index 955651d..982868e 100644 --- a/internal/handler/handler.go +++ b/internal/handler/handler.go @@ -16,6 +16,7 @@ import ( "ridgwaysystems.org/website/internal/newsletter" "ridgwaysystems.org/website/internal/ratelimit" "ridgwaysystems.org/website/internal/status" + "ridgwaysystems.org/website/internal/twitch" ) // Handler holds shared dependencies for all HTTP handlers. @@ -32,6 +33,7 @@ type Handler struct { giteaOwner string giteaRepo string giteaLabel string + twitch *twitch.Checker } // New creates a Handler. dataDir is the path to the data/ directory. @@ -51,12 +53,27 @@ func New(store *blog.Store, news *newsletter.Store, dataDir string) *Handler { if url := os.Getenv("GITEA_URL"); url != "" { h.gitea = gitea.New(url, os.Getenv("GITEA_TOKEN")) } + if id, secret, ch := os.Getenv("TWITCH_CLIENT_ID"), os.Getenv("TWITCH_CLIENT_SECRET"), os.Getenv("TWITCH_CHANNEL"); id != "" && secret != "" && ch != "" { + h.twitch = twitch.New(id, secret, ch) + } if !h.devMode { h.templates = mustLoadTemplates() } return h } +// StartTwitch begins background polling for stream live status. +// interval is how often to check; if zero a default of 2 minutes is used. +func (h *Handler) StartTwitch(interval time.Duration) { + if h.twitch == nil { + return + } + if interval <= 0 { + interval = 2 * time.Minute + } + h.twitch.Start(interval) +} + func getenv(key, def string) string { if v := os.Getenv(key); v != "" { return v @@ -107,6 +124,7 @@ func mustLoadTemplates() map[string]*template.Template { {"admin-changelog", "templates/admin/changelog.html"}, {"admin-changelog-editor", "templates/admin/changelog-editor.html"}, {"admin-outages", "templates/admin/outages.html"}, + {"stream", "templates/stream.html"}, } for _, p := range pages { @@ -122,8 +140,10 @@ func mustLoadTemplates() map[string]*template.Template { // baseEnvelope wraps page-specific data with shared layout data for the base template. type baseEnvelope struct { - Banner *siteBanner - Inner any + Banner *siteBanner + Inner any + TwitchLive bool + TwitchChannel string } // siteBanner holds the data for the site-wide status banner. @@ -176,8 +196,13 @@ func (h *Handler) render(w http.ResponseWriter, name string, data any) { http.Error(w, "template not found: "+name, http.StatusInternalServerError) return } + env := baseEnvelope{Banner: h.computeBanner(), Inner: data} + if h.twitch != nil { + env.TwitchLive = h.twitch.IsLive() + env.TwitchChannel = h.twitch.Channel() + } w.Header().Set("Content-Type", "text/html; charset=utf-8") - if err := t.ExecuteTemplate(w, "base", baseEnvelope{Banner: h.computeBanner(), Inner: data}); err != nil { + if err := t.ExecuteTemplate(w, "base", env); err != nil { log.Printf("render %s: %v", name, err) } } @@ -199,7 +224,12 @@ func (h *Handler) renderErr(w http.ResponseWriter, code int, msg string) { code, http.StatusText(code), msg) return } - if err := t.ExecuteTemplate(w, "base", baseEnvelope{Banner: h.computeBanner(), Inner: data}); err != nil { + env := baseEnvelope{Banner: h.computeBanner(), Inner: data} + if h.twitch != nil { + env.TwitchLive = h.twitch.IsLive() + env.TwitchChannel = h.twitch.Channel() + } + if err := t.ExecuteTemplate(w, "base", env); err != nil { log.Printf("renderErr %d: %v", code, err) } } diff --git a/internal/handler/middleware.go b/internal/handler/middleware.go index 46e35ff..bcbafab 100644 --- a/internal/handler/middleware.go +++ b/internal/handler/middleware.go @@ -43,8 +43,12 @@ func SecurityHeadersMiddleware(next http.Handler) http.Handler { if strings.HasPrefix(r.URL.Path, "/admin") { scriptSrc = "'self'" } - w.Header().Set("Content-Security-Policy", - "default-src 'self'; script-src "+scriptSrc+"; style-src 'self'; img-src 'self' data:; font-src 'self'; frame-ancestors 'none'") + frameSrc := "'none'" + if r.URL.Path == "/stream" { + frameSrc = "https://player.twitch.tv" + } + csp := "default-src 'self'; script-src " + scriptSrc + "; style-src 'self'; img-src 'self' data:; font-src 'self'; frame-src " + frameSrc + "; frame-ancestors 'none'" + w.Header().Set("Content-Security-Policy", csp) w.Header().Set("X-Frame-Options", "DENY") w.Header().Set("X-Content-Type-Options", "nosniff") w.Header().Set("Referrer-Policy", "strict-origin-when-cross-origin") diff --git a/internal/handler/public.go b/internal/handler/public.go index c389a28..886caf9 100644 --- a/internal/handler/public.go +++ b/internal/handler/public.go @@ -4,6 +4,7 @@ import ( "encoding/xml" "fmt" "log" + "net" "net/http" "path/filepath" "strconv" @@ -19,6 +20,13 @@ import ( "ridgwaysystems.org/website/internal/uptime" ) +// streamData is passed to the stream template. +type streamData struct { + Channel string + Live bool + Parent string // hostname for Twitch embed parent= param +} + const postsPerPage = 10 // indexData is passed to the index template. @@ -159,6 +167,22 @@ func (h *Handler) Infrastructure(w http.ResponseWriter, r *http.Request) { h.render(w, "infrastructure", nil) } +func (h *Handler) Stream(w http.ResponseWriter, r *http.Request) { + if h.twitch == nil { + h.renderErr(w, http.StatusNotFound, "Stream page not configured.") + return + } + host := r.Host + if h, _, err := net.SplitHostPort(host); err == nil { + host = h + } + h.render(w, "stream", streamData{ + Channel: h.twitch.Channel(), + Live: h.twitch.IsLive(), + Parent: host, + }) +} + // serviceHistory bundles per-service uptime data for the status template. type serviceHistory struct { Blocks []uptime.DayBlock diff --git a/internal/twitch/twitch.go b/internal/twitch/twitch.go new file mode 100644 index 0000000..5005e4d --- /dev/null +++ b/internal/twitch/twitch.go @@ -0,0 +1,148 @@ +// Package twitch polls the Twitch Helix API to track whether a channel is live. +package twitch + +import ( + "encoding/json" + "fmt" + "log" + "net/http" + "net/url" + "sync" + "sync/atomic" + "time" +) + +// Checker polls Twitch and exposes whether the channel is currently live. +type Checker struct { + clientID string + clientSecret string + channel string + live atomic.Bool + token string + tokenExpiry time.Time + mu sync.Mutex + http *http.Client +} + +// New creates a Checker for the given channel. +func New(clientID, clientSecret, channel string) *Checker { + return &Checker{ + clientID: clientID, + clientSecret: clientSecret, + channel: channel, + http: &http.Client{Timeout: 10 * time.Second}, + } +} + +// Start begins background polling at the given interval. +func (c *Checker) Start(interval time.Duration) { + go func() { + for { + if err := c.check(); err != nil { + log.Printf("twitch: %v", err) + } + time.Sleep(interval) + } + }() +} + +// IsLive reports whether the channel is currently streaming. +func (c *Checker) IsLive() bool { + return c.live.Load() +} + +// Channel returns the configured channel name. +func (c *Checker) Channel() string { + return c.channel +} + +func (c *Checker) check() error { + token, err := c.accessToken() + if err != nil { + return fmt.Errorf("access token: %w", err) + } + + req, err := http.NewRequest("GET", + "https://api.twitch.tv/helix/streams?user_login="+url.QueryEscape(c.channel), nil) + if err != nil { + return err + } + req.Header.Set("Client-Id", c.clientID) + req.Header.Set("Authorization", "Bearer "+token) + + resp, err := c.http.Do(req) + if err != nil { + return fmt.Errorf("streams request: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode == http.StatusUnauthorized { + // Token may have been revoked; clear it and retry next tick. + c.mu.Lock() + c.token = "" + c.mu.Unlock() + return fmt.Errorf("streams: 401 unauthorized, will refresh token") + } + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("streams: %s", resp.Status) + } + + var body struct { + Data []struct { + Type string `json:"type"` + } `json:"data"` + } + if err := json.NewDecoder(resp.Body).Decode(&body); err != nil { + return fmt.Errorf("decode: %w", err) + } + + wasLive := c.live.Load() + isLive := len(body.Data) > 0 && body.Data[0].Type == "live" + c.live.Store(isLive) + + if isLive != wasLive { + if isLive { + log.Printf("twitch: %s went live", c.channel) + } else { + log.Printf("twitch: %s went offline", c.channel) + } + } + return nil +} + +// accessToken returns a cached app access token, refreshing if expired. +func (c *Checker) accessToken() (string, error) { + c.mu.Lock() + defer c.mu.Unlock() + + if c.token != "" && time.Now().Before(c.tokenExpiry) { + return c.token, nil + } + + resp, err := c.http.PostForm("https://id.twitch.tv/oauth2/token", url.Values{ + "client_id": {c.clientID}, + "client_secret": {c.clientSecret}, + "grant_type": {"client_credentials"}, + }) + if err != nil { + return "", err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("token: %s", resp.Status) + } + + var body struct { + AccessToken string `json:"access_token"` + ExpiresIn int `json:"expires_in"` + } + if err := json.NewDecoder(resp.Body).Decode(&body); err != nil { + return "", err + } + + c.token = body.AccessToken + // Refresh 5 minutes before actual expiry. + c.tokenExpiry = time.Now().Add(time.Duration(body.ExpiresIn)*time.Second - 5*time.Minute) + return c.token, nil +} diff --git a/static/css/style.css b/static/css/style.css index 1d1da93..16a2531 100644 --- a/static/css/style.css +++ b/static/css/style.css @@ -1170,6 +1170,49 @@ blockquote { .nav-hire { color: var(--accent) !important; } .nav-hire:hover { color: var(--accent-dim) !important; } +.nav-live { + color: #e53935 !important; + font-weight: 600; + animation: live-pulse 2s ease-in-out infinite; +} +.nav-live:hover { opacity: 0.8; text-decoration: none !important; } + +@keyframes live-pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.55; } +} + +/* === Stream Page === */ + +.stream-player { + height: 75vh; + margin-top: 1.5rem; + margin-left: calc(50% - 50vw + 1.25rem); + margin-right: calc(50% - 50vw + 1.25rem); + border-radius: 6px; + overflow: hidden; + border: 1px solid var(--border); +} + +.live-badge { + display: inline-block; + background: #e53935; + color: #fff; + font-size: 0.78rem; + font-weight: 600; + padding: 0.15em 0.5em; + border-radius: 3px; + margin-right: 0.3rem; +} + +@media (max-width: 800px) { + .stream-wrap { + grid-template-columns: 1fr; + height: auto; + } + .stream-player { height: 56vw; } +} + .hire-page { max-width: var(--max-w); } .hire-intro { diff --git a/templates/base.html b/templates/base.html index 6a1d9bf..860fd12 100644 --- a/templates/base.html +++ b/templates/base.html @@ -33,6 +33,7 @@
● live Streaming now on Twitch.
+ {{else}} +Not currently live. Follow on Twitch to get notified.
+ {{end}} +