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 "context"
5 "encoding/json"
6 "fmt"
7 "log/slog"
8 "net/http"
9 "sync"
10 "time"
11)
12
13const (
14 latestRefreshInterval = time.Hour
15 latestFetchTimeout = 10 * time.Second
16)
17
18// LatestPost is the blog's /latest.json, hand written on that side so this does
19// not have to track its Post type.
20type LatestPost struct {
21 Title string `json:"title"`
22 Description string `json:"description"`
23 URL string `json:"url"`
24 Date string `json:"date"`
25}
26
27// LatestCache holds the most recent successful fetch and is safe for
28// concurrent use.
29type LatestCache struct {
30 mu sync.RWMutex
31 post LatestPost
32 ok bool
33 sources []string
34 client *http.Client
35}
36
37func NewLatestCache(sources []string) *LatestCache {
38 return &LatestCache{
39 sources: sources,
40 client: &http.Client{Timeout: latestFetchTimeout},
41 }
42}
43
44func (c *LatestCache) Get() (LatestPost, bool) {
45 c.mu.RLock()
46 defer c.mu.RUnlock()
47 return c.post, c.ok
48}
49
50// Start returns straight away, fetches once, then refreshes on a ticker until
51// ctx is cancelled, so the site never waits on the blog to begin serving.
52func (c *LatestCache) Start(ctx context.Context) {
53 go func() {
54 c.refresh(ctx)
55
56 ticker := time.NewTicker(latestRefreshInterval)
57 defer ticker.Stop()
58
59 for {
60 select {
61 case <-ctx.Done():
62 return
63 case <-ticker.C:
64 c.refresh(ctx)
65 }
66 }
67 }()
68}
69
70func (c *LatestCache) refresh(ctx context.Context) {
71 // First source that answers wins: the sibling container in the image, the
72 // public site in a local checkout.
73 var post LatestPost
74 var err error
75 for _, src := range c.sources {
76 post, err = c.fetch(ctx, src)
77 if err == nil {
78 break
79 }
80 slog.Info(fmt.Sprintf("latest post: %s: %v", src, err))
81 }
82 if err != nil {
83 // Keep whatever was there, so a failure does not blank the card.
84 return
85 }
86
87 c.mu.Lock()
88 defer c.mu.Unlock()
89 c.post = post
90 c.ok = post.Title != "" && post.URL != ""
91}
92
93func (c *LatestCache) fetch(ctx context.Context, url string) (LatestPost, error) {
94 req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
95 if err != nil {
96 return LatestPost{}, err
97 }
98 req.Header.Set("Accept", "application/json")
99 req.Header.Set("User-Agent", "isaacbythewood.com")
100
101 resp, err := c.client.Do(req)
102 if err != nil {
103 return LatestPost{}, err
104 }
105 defer resp.Body.Close()
106
107 if resp.StatusCode != http.StatusOK {
108 return LatestPost{}, fmt.Errorf("status %d", resp.StatusCode)
109 }
110
111 var post LatestPost
112 if err := json.NewDecoder(resp.Body).Decode(&post); err != nil {
113 return LatestPost{}, err
114 }
115 return post, nil
116}