repos
/ orchard main

orchard

mirror

Every site I host, in one repo, along with the Cloudflare Tunnel and Caddy that front them. It's all Go, Vite, and SQLite, and it runs on a desktop at home with nothing listening on an inbound port.

blogbuncaddycloudflare-tunneldockergogolanghomelabhtml-templatemonorepoself-hostedseosqlitestatic-sitetypstuptime-monitoringviteweb-analytics

4.5 KB · 192 lines · Go Raw History
  1package main
  2
  3import (
  4	"bytes"
  5	"context"
  6	"encoding/json"
  7	"fmt"
  8	"log/slog"
  9	"net/http"
 10	"strings"
 11	"sync"
 12	"time"
 13)
 14
 15// Unauthenticated GitHub allows 60 requests an hour per IP, so thirteen cards on an
 16// hourly ticker fits inside it with no token to store.
 17
 18const (
 19	commitRefreshInterval = time.Hour
 20	commitFetchTimeout    = 10 * time.Second
 21)
 22
 23// CommitTarget is the feed behind one card. Path is empty for a whole
 24// repository, and set to a subdirectory for the sites in orchard, which all
 25// share one repo and would otherwise show the same commit eleven times.
 26type CommitTarget struct {
 27	Key  string
 28	Repo string
 29	Path string
 30}
 31
 32type Commit struct {
 33	SHA     string `json:"sha"`
 34	Message string `json:"message"`
 35	Date    string `json:"date"`
 36	Author  string `json:"author"`
 37}
 38
 39// CommitCache holds the most recent successful fetch per target and is safe for
 40// concurrent use.
 41type CommitCache struct {
 42	mu      sync.RWMutex
 43	commits map[string]Commit
 44	client  *http.Client
 45}
 46
 47func NewCommitCache() *CommitCache {
 48	return &CommitCache{
 49		commits: make(map[string]Commit),
 50		client:  &http.Client{Timeout: commitFetchTimeout},
 51	}
 52}
 53
 54func (c *CommitCache) Get(key string) (Commit, bool) {
 55	c.mu.RLock()
 56	defer c.mu.RUnlock()
 57	commit, ok := c.commits[key]
 58	return commit, ok
 59}
 60
 61// JSON renders a commit the way the card displays it. SetEscapeHTML(false),
 62// which MarshalIndent cannot do, because html/template escapes already and the
 63// default renders an arrow in a message as a literal "\u003e" in the <pre>.
 64func (c *CommitCache) JSON(key string) string {
 65	commit, ok := c.Get(key)
 66	if !ok {
 67		return ""
 68	}
 69
 70	var buf bytes.Buffer
 71	enc := json.NewEncoder(&buf)
 72	enc.SetEscapeHTML(false)
 73	enc.SetIndent("", "  ")
 74	if err := enc.Encode(commit); err != nil {
 75		return ""
 76	}
 77	// Encode appends a newline that MarshalIndent does not.
 78	return strings.TrimRight(buf.String(), "\n")
 79}
 80
 81// Start returns straight away, fetches once, then refreshes on a ticker until
 82// ctx is cancelled, so the site never waits on GitHub to begin serving.
 83func (c *CommitCache) Start(ctx context.Context, targets []CommitTarget) {
 84	go func() {
 85		c.refresh(ctx, targets)
 86
 87		ticker := time.NewTicker(commitRefreshInterval)
 88		defer ticker.Stop()
 89
 90		for {
 91			select {
 92			case <-ctx.Done():
 93				return
 94			case <-ticker.C:
 95				c.refresh(ctx, targets)
 96			}
 97		}
 98	}()
 99}
100
101func (c *CommitCache) refresh(ctx context.Context, targets []CommitTarget) {
102	var wg sync.WaitGroup
103	results := make([]struct {
104		key    string
105		commit Commit
106		ok     bool
107	}, len(targets))
108
109	for i, target := range targets {
110		wg.Add(1)
111		go func() {
112			defer wg.Done()
113			commit, err := c.fetch(ctx, target)
114			if err != nil {
115				slog.Info(fmt.Sprintf("github: %s: %v", target.Key, err))
116				return
117			}
118			results[i].key = target.Key
119			results[i].commit = commit
120			results[i].ok = true
121		}()
122	}
123	wg.Wait()
124
125	// A failed repo keeps its previous value, so a 502 does not blank a card.
126	c.mu.Lock()
127	defer c.mu.Unlock()
128	for _, r := range results {
129		if r.ok {
130			c.commits[r.key] = r.commit
131		}
132	}
133}
134
135func (c *CommitCache) fetch(ctx context.Context, target CommitTarget) (Commit, error) {
136	url := fmt.Sprintf("https://api.github.com/repos/%s/%s/commits?per_page=1", githubUser, target.Repo)
137	if target.Path != "" {
138		url += "&path=" + target.Path
139	}
140
141	req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
142	if err != nil {
143		return Commit{}, err
144	}
145	req.Header.Set("Accept", "application/vnd.github+json")
146	req.Header.Set("User-Agent", "isaacbythewood.com")
147
148	resp, err := c.client.Do(req)
149	if err != nil {
150		return Commit{}, err
151	}
152	defer resp.Body.Close()
153
154	if resp.StatusCode != http.StatusOK {
155		return Commit{}, fmt.Errorf("status %d", resp.StatusCode)
156	}
157
158	var payload []struct {
159		SHA    string `json:"sha"`
160		Commit struct {
161			Message string `json:"message"`
162			Author  struct {
163				Name string `json:"name"`
164				Date string `json:"date"`
165			} `json:"author"`
166		} `json:"commit"`
167	}
168	if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil {
169		return Commit{}, err
170	}
171	if len(payload) == 0 {
172		return Commit{}, fmt.Errorf("no commits")
173	}
174
175	head := payload[0]
176	sha := head.SHA
177	if len(sha) > 7 {
178		sha = sha[:7]
179	}
180
181	// Subject line only. The card is a few lines of JSON in a <pre>, and a
182	// commit body would render as one escaped string and blow it out of shape.
183	message, _, _ := strings.Cut(head.Commit.Message, "\n")
184
185	return Commit{
186		SHA:     sha,
187		Message: strings.TrimSpace(message),
188		Date:    head.Commit.Author.Date,
189		Author:  head.Commit.Author.Name,
190	}, nil
191}