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

2.5 KB · 117 lines · Go Raw History
  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}