orchard
mirrorEvery 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
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}