From b1feff3bbf39c59d8164386943e4eb85ea1f184e Mon Sep 17 00:00:00 2001 From: blakeridgway Date: Mon, 13 Apr 2026 03:51:22 -0500 Subject: [PATCH] add a page for tickets from gitea (#2) Co-authored-by: Blake Ridgway Reviewed-on: https://git.ridgwaysystems.org/RidgwaySystems/rs_website/pulls/2 --- .env.example | 31 ++++++ cmd/server/main.go | 4 + data/status.json | 4 +- data/uptime.json | 32 ++++++ internal/gitea/gitea.go | 191 +++++++++++++++++++++++++++++++++ internal/handler/admin.go | 77 +++++++++++++ internal/handler/handler.go | 50 ++++++++- internal/handler/middleware.go | 8 +- internal/handler/public.go | 44 +++++++- internal/twitch/twitch.go | 148 +++++++++++++++++++++++++ static/css/style.css | 102 ++++++++++++++++++ templates/admin/dashboard.html | 1 + templates/admin/outages.html | 63 +++++++++++ templates/base.html | 2 + templates/status.html | 15 +++ templates/stream.html | 22 ++++ 16 files changed, 782 insertions(+), 12 deletions(-) create mode 100644 internal/gitea/gitea.go create mode 100644 internal/twitch/twitch.go create mode 100644 templates/admin/outages.html create mode 100644 templates/stream.html diff --git a/.env.example b/.env.example index 7d6119b..9f5bfbd 100644 --- a/.env.example +++ b/.env.example @@ -51,3 +51,34 @@ CONTACT_EMAIL=hire@ridgwaysystems.org # How often (in minutes) to HTTP-check services with a check_url in status.json. # Default: 5 CHECK_INTERVAL=5 + +# ------------------------------------------------------------------ +# Gitea integration (planned outage tagger) +# ------------------------------------------------------------------ + +# Base URL of your Gitea instance (no trailing slash). +GITEA_URL=https://git.ridgwaysystems.org + +# Gitea personal access token with "issues" write scope. +# Generate at: /user/settings/applications +GITEA_TOKEN=your-gitea-token-here + +# Owner (org or user) and repo to pull tickets from. +GITEA_OWNER=ridgway-infra +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 998e31d..b4ad29c 100644 --- a/data/status.json +++ b/data/status.json @@ -1,5 +1,5 @@ { - "last_checked": "2026-03-27T13:17:04.05294829Z", + "last_checked": "2026-04-12T22:44:56.369949582Z", "services": [ { "name": "Web (httpd)", @@ -12,7 +12,7 @@ "description": "git.ridgwaysystems.org", "url": "https://git.ridgwaysystems.org", "check_url": "https://git.ridgwaysystems.org", - "status": "down" + "status": "up" }, { "name": "DNS (unbound)", diff --git a/data/uptime.json b/data/uptime.json index 8a71d6e..8493329 100644 --- a/data/uptime.json +++ b/data/uptime.json @@ -126,5 +126,37 @@ "VPN (WireGuard)": "up", "Web (httpd)": "up" } + }, + { + "time": "2026-04-11T14: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" + } + }, + { + "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/gitea/gitea.go b/internal/gitea/gitea.go new file mode 100644 index 0000000..950f1db --- /dev/null +++ b/internal/gitea/gitea.go @@ -0,0 +1,191 @@ +// Package gitea provides a minimal Gitea API client for label management. +package gitea + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" + "time" +) + +// Client is a minimal Gitea API client. +type Client struct { + BaseURL string + Token string + http *http.Client +} + +// New creates a new Gitea client. +func New(baseURL, token string) *Client { + return &Client{ + BaseURL: baseURL, + Token: token, + http: &http.Client{Timeout: 10 * time.Second}, + } +} + +// Issue represents a Gitea issue. +type Issue struct { + Number int `json:"number"` + Title string `json:"title"` + HTMLURL string `json:"html_url"` + Labels []Label `json:"labels"` +} + +// HasLabel reports whether the issue carries a label with the given name. +func (i Issue) HasLabel(name string) bool { + for _, l := range i.Labels { + if l.Name == name { + return true + } + } + return false +} + +// LabelID returns the ID of the first label matching name, or 0. +func (i Issue) LabelID(name string) int64 { + for _, l := range i.Labels { + if l.Name == name { + return l.ID + } + } + return 0 +} + +// Label represents a Gitea label. +type Label struct { + ID int64 `json:"id"` + Name string `json:"name"` + Color string `json:"color"` +} + +func (c *Client) newRequest(method, path string, body []byte) (*http.Request, error) { + var req *http.Request + var err error + if body != nil { + req, err = http.NewRequest(method, c.BaseURL+path, bytes.NewReader(body)) + } else { + req, err = http.NewRequest(method, c.BaseURL+path, nil) + } + if err != nil { + return nil, err + } + req.Header.Set("Authorization", "token "+c.Token) + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + return req, nil +} + +// ListOpenIssues returns open issues for the given owner/repo (up to 50). +func (c *Client) ListOpenIssues(owner, repo string) ([]Issue, error) { + return c.listIssues(owner, repo, "") +} + +// ListIssuesByLabel returns open issues that carry the named label (up to 50). +func (c *Client) ListIssuesByLabel(owner, repo, label string) ([]Issue, error) { + return c.listIssues(owner, repo, label) +} + +func (c *Client) listIssues(owner, repo, label string) ([]Issue, error) { + path := fmt.Sprintf("/api/v1/repos/%s/%s/issues?type=issues&state=open&limit=50", owner, repo) + if label != "" { + path += "&labels=" + label + } + req, err := c.newRequest("GET", path, nil) + if err != nil { + return nil, err + } + resp, err := c.http.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("gitea: list issues: %s", resp.Status) + } + var issues []Issue + if err := json.NewDecoder(resp.Body).Decode(&issues); err != nil { + return nil, err + } + return issues, nil +} + +// GetOrCreateLabel finds a repo label by name, creating it if absent. +func (c *Client) GetOrCreateLabel(owner, repo, name, color string) (*Label, error) { + path := fmt.Sprintf("/api/v1/repos/%s/%s/labels", owner, repo) + req, err := c.newRequest("GET", path, nil) + if err != nil { + return nil, err + } + resp, err := c.http.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + var labels []Label + json.NewDecoder(resp.Body).Decode(&labels) //nolint:errcheck + for _, l := range labels { + if l.Name == name { + return &l, nil + } + } + + // Create the label. + body, _ := json.Marshal(map[string]string{"name": name, "color": color}) + req2, err := c.newRequest("POST", path, body) + if err != nil { + return nil, err + } + resp2, err := c.http.Do(req2) + if err != nil { + return nil, err + } + defer resp2.Body.Close() + if resp2.StatusCode >= 300 { + return nil, fmt.Errorf("gitea: create label: %s", resp2.Status) + } + var label Label + if err := json.NewDecoder(resp2.Body).Decode(&label); err != nil { + return nil, err + } + return &label, nil +} + +// AddLabel adds a label to an issue by label ID. +func (c *Client) AddLabel(owner, repo string, issueNum int, labelID int64) error { + path := fmt.Sprintf("/api/v1/repos/%s/%s/issues/%d/labels", owner, repo, issueNum) + body, _ := json.Marshal(map[string][]int64{"labels": {labelID}}) + req, err := c.newRequest("POST", path, body) + if err != nil { + return err + } + resp, err := c.http.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode >= 300 { + return fmt.Errorf("gitea: add label: %s", resp.Status) + } + return nil +} + +// RemoveLabel removes a label from an issue by label ID. +func (c *Client) RemoveLabel(owner, repo string, issueNum int, labelID int64) error { + path := fmt.Sprintf("/api/v1/repos/%s/%s/issues/%d/labels/%d", owner, repo, issueNum, labelID) + req, err := c.newRequest("DELETE", path, nil) + if err != nil { + return err + } + resp, err := c.http.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode >= 300 && resp.StatusCode != http.StatusNoContent { + return fmt.Errorf("gitea: remove label: %s", resp.Status) + } + return nil +} diff --git a/internal/handler/admin.go b/internal/handler/admin.go index a2a69ac..445683f 100644 --- a/internal/handler/admin.go +++ b/internal/handler/admin.go @@ -7,11 +7,13 @@ import ( "net/http" "os" "path/filepath" + "strconv" "strings" "time" "ridgwaysystems.org/website/internal/blog" "ridgwaysystems.org/website/internal/changelog" + "ridgwaysystems.org/website/internal/gitea" "ridgwaysystems.org/website/internal/newsletter" "ridgwaysystems.org/website/internal/status" ) @@ -91,6 +93,13 @@ func (h *Handler) AdminRouter(w http.ResponseWriter, r *http.Request) { h.requireAuth(h.adminNewsletter)(w, r) } + case path == "/admin/outages": + if r.Method == http.MethodPost { + h.requireAuth(h.adminOutagesPost)(w, r) + } else { + h.requireAuth(h.adminOutagesGet)(w, r) + } + default: h.renderErr(w, http.StatusNotFound, "Admin page not found.") } @@ -549,6 +558,74 @@ func (h *Handler) adminChangelogDelete(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, "/admin/changelog?flash=Entry+deleted", http.StatusSeeOther) } +// --- Outage tagger --- + +type adminOutagesData struct { + Issues []gitea.Issue + Label string + Flash string + Error string +} + +func (h *Handler) adminOutagesGet(w http.ResponseWriter, r *http.Request) { + if h.gitea == nil || h.giteaOwner == "" || h.giteaRepo == "" { + h.render(w, "admin-outages", adminOutagesData{Error: "Gitea not configured. Set GITEA_URL, GITEA_TOKEN, GITEA_OWNER, and GITEA_REPO."}) + return + } + issues, err := h.gitea.ListOpenIssues(h.giteaOwner, h.giteaRepo) + if err != nil { + h.render(w, "admin-outages", adminOutagesData{Error: "Could not load issues: " + err.Error()}) + return + } + h.render(w, "admin-outages", adminOutagesData{ + Issues: issues, + Label: h.giteaLabel, + Flash: r.URL.Query().Get("flash"), + }) +} + +func (h *Handler) adminOutagesPost(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + h.renderErr(w, http.StatusBadRequest, "Bad form data.") + return + } + if h.gitea == nil || h.giteaOwner == "" || h.giteaRepo == "" { + h.renderErr(w, http.StatusServiceUnavailable, "Gitea not configured.") + return + } + + action := r.FormValue("action") // "add" or "remove" + issueNum, err := strconv.Atoi(r.FormValue("issue")) + if err != nil || issueNum <= 0 { + h.renderErr(w, http.StatusBadRequest, "Invalid issue number.") + return + } + + label, err := h.gitea.GetOrCreateLabel(h.giteaOwner, h.giteaRepo, h.giteaLabel, "#e11d48") + if err != nil { + h.renderErr(w, http.StatusInternalServerError, "Label error: "+err.Error()) + return + } + + switch action { + case "add": + if err := h.gitea.AddLabel(h.giteaOwner, h.giteaRepo, issueNum, label.ID); err != nil { + h.renderErr(w, http.StatusInternalServerError, "Could not add label: "+err.Error()) + return + } + case "remove": + if err := h.gitea.RemoveLabel(h.giteaOwner, h.giteaRepo, issueNum, label.ID); err != nil { + h.renderErr(w, http.StatusInternalServerError, "Could not remove label: "+err.Error()) + return + } + default: + h.renderErr(w, http.StatusBadRequest, "Unknown action.") + return + } + + http.Redirect(w, r, "/admin/outages?flash=Updated+#"+strconv.Itoa(issueNum), http.StatusSeeOther) +} + // sanitizeSlug ensures a slug is filesystem-safe. func sanitizeSlug(s string) string { s = strings.ToLower(strings.TrimSpace(s)) diff --git a/internal/handler/handler.go b/internal/handler/handler.go index cbbd231..982868e 100644 --- a/internal/handler/handler.go +++ b/internal/handler/handler.go @@ -12,9 +12,11 @@ import ( "time" "ridgwaysystems.org/website/internal/blog" + "ridgwaysystems.org/website/internal/gitea" "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. @@ -27,6 +29,11 @@ type Handler struct { contactEmail string devMode bool postLimit *ratelimit.Limiter // rate-limits contact + newsletter POSTs + gitea *gitea.Client + giteaOwner string + giteaRepo string + giteaLabel string + twitch *twitch.Checker } // New creates a Handler. dataDir is the path to the data/ directory. @@ -39,6 +46,15 @@ func New(store *blog.Store, news *newsletter.Store, dataDir string) *Handler { contactEmail: getenv("CONTACT_EMAIL", "hire@ridgwaysystems.org"), devMode: os.Getenv("DEV") == "1", postLimit: ratelimit.New(10*time.Minute, 5), + giteaOwner: getenv("GITEA_OWNER", ""), + giteaRepo: getenv("GITEA_REPO", ""), + giteaLabel: getenv("GITEA_LABEL", "planned-outage"), + } + 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() @@ -46,6 +62,18 @@ func New(store *blog.Store, news *newsletter.Store, dataDir string) *Handler { 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 @@ -95,6 +123,8 @@ func mustLoadTemplates() map[string]*template.Template { {"admin-newsletter", "templates/admin/newsletter.html"}, {"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 { @@ -110,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. @@ -164,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) } } @@ -187,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 73b10a1..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" @@ -13,11 +14,19 @@ import ( "ridgwaysystems.org/website/internal/blog" "ridgwaysystems.org/website/internal/changelog" "ridgwaysystems.org/website/internal/feed" + "ridgwaysystems.org/website/internal/gitea" "ridgwaysystems.org/website/internal/mailer" "ridgwaysystems.org/website/internal/status" "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. @@ -158,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 @@ -166,9 +191,10 @@ type serviceHistory struct { // statusData is passed to the status template. type statusData struct { - Page *status.Page - LastChecked string - History map[string]serviceHistory // keyed by service name + Page *status.Page + LastChecked string + History map[string]serviceHistory // keyed by service name + PlannedOutages []gitea.Issue } func (h *Handler) Status(w http.ResponseWriter, r *http.Request) { @@ -188,7 +214,17 @@ func (h *Handler) Status(w http.ResponseWriter, r *http.Request) { UptimePct: uptime.UptimePct(uptimePath, svc.Name), } } - h.render(w, "status", statusData{Page: p, LastChecked: lastChecked, History: history}) + // Fetch planned outages from Gitea — best-effort, failure is non-fatal. + var plannedOutages []gitea.Issue + if h.gitea != nil && h.giteaOwner != "" && h.giteaRepo != "" { + if issues, err := h.gitea.ListIssuesByLabel(h.giteaOwner, h.giteaRepo, h.giteaLabel); err == nil { + plannedOutages = issues + } else { + log.Printf("status: gitea planned outages: %v", err) + } + } + + h.render(w, "status", statusData{Page: p, LastChecked: lastChecked, History: history, PlannedOutages: plannedOutages}) } // changelogData is passed to the changelog template. 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 8c73a3e..16a2531 100644 --- a/static/css/style.css +++ b/static/css/style.css @@ -616,6 +616,65 @@ blockquote { color: var(--text-muted); } +/* === Planned Maintenance === */ + +.planned-outages { + margin-bottom: 2rem; +} + +.planned-outages h2 { + font-size: 1rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--text-muted); + margin-bottom: 0.75rem; +} + +.outage-list { + list-style: none; + margin: 0; + padding: 0; + border: 1px solid var(--border); + border-radius: 6px; + overflow: hidden; +} + +.outage-item { + display: flex; + align-items: center; + gap: 0.6rem; + padding: 0.65rem 1rem; + border-bottom: 1px solid var(--border); + font-size: 0.9rem; +} + +.outage-item:last-child { border-bottom: none; } + +.outage-indicator { + display: inline-block; + width: 8px; + height: 8px; + border-radius: 50%; + background: #e0a000; + flex-shrink: 0; +} + +.outage-link { + flex: 1; + color: var(--text); + text-decoration: none; +} + +.outage-link:hover { text-decoration: underline; } + +.outage-num { + font-family: var(--font-mono); + font-size: 0.78rem; + color: var(--text-muted); + flex-shrink: 0; +} + /* === Infrastructure Page === */ .infra-section { margin-bottom: 3rem; } @@ -1111,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/admin/dashboard.html b/templates/admin/dashboard.html index 2d170c0..21a3282 100644 --- a/templates/admin/dashboard.html +++ b/templates/admin/dashboard.html @@ -10,6 +10,7 @@ Uploads Newsletter Changelog + Outages View Site
diff --git a/templates/admin/outages.html b/templates/admin/outages.html new file mode 100644 index 0000000..2c36087 --- /dev/null +++ b/templates/admin/outages.html @@ -0,0 +1,63 @@ +{{define "title"}}Planned Outages — Admin{{end}} + +{{define "content"}} +
+
+

Planned Outages

+
+ Back + Refresh +
+
+ + {{if .Flash}}

{{.Flash}}

{{end}} + {{if .Error}}

{{.Error}}

{{end}} + + {{if .Issues}} +

Tag open tickets with {{.Label}} to mark them as planned outages.

+ + + + + + + + + + + {{range .Issues}} + {{$tagged := .HasLabel $.Label}} + + + + + + + {{end}} + +
#TitleLabelsAction
+ #{{.Number}} + {{.Title}} + {{range .Labels}} + {{.Name}} + {{end}} + + {{if $tagged}} + + + + + + {{else}} +
+ + + +
+ {{end}} +
+ {{else if not .Error}} +

No open issues found.

+ {{end}} +
+{{end}} 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 @@
  • status
  • about
  • hire me
  • + {{if .TwitchLive}}
  • ● live
  • {{end}} @@ -54,6 +55,7 @@ changelogRSSgitea — + streamhire me

    diff --git a/templates/status.html b/templates/status.html index 29083b8..d43cb8b 100644 --- a/templates/status.html +++ b/templates/status.html @@ -9,6 +9,21 @@ {{end}} +{{if .PlannedOutages}} +
    +

    Planned Maintenance

    +
      + {{range .PlannedOutages}} +
    • + + {{.Title}} + #{{.Number}} +
    • + {{end}} +
    +
    +{{end}} + {{if .Page.Services}}
      {{range .Page.Services}} diff --git a/templates/stream.html b/templates/stream.html new file mode 100644 index 0000000..f1defdc --- /dev/null +++ b/templates/stream.html @@ -0,0 +1,22 @@ +{{define "title"}}Stream — Ridgway Systems{{end}} +{{define "meta-desc"}}Watch the Ridgway Systems live stream.{{end}} + +{{define "content"}} + + +
      + +
      +{{end}}