og.go 4.3 K raw
1
package main
2
3
import (
4
	"bytes"
5
	"context"
6
	"errors"
7
	"net/http"
8
	"sort"
9
	"strings"
10
	"sync"
11
	"time"
12
	"unicode/utf8"
13
14
	"github.com/fogleman/gg"
15
	"golang.org/x/image/font"
16
	"golang.org/x/image/font/opentype"
17
)
18
19
// errRenderBusy signals that the render semaphore was saturated and the request
20
// gave up rather than piling on. Mapped to HTTP 503 by the OG handler.
21
var errRenderBusy = errors.New("render busy")
22
23
const (
24
	ogWidth  = 1200
25
	ogHeight = 630
26
	ogMargin = 80.0
27
)
28
29
var (
30
	fontOnce              sync.Once
31
	fontRegular, fontBold *opentype.Font
32
	fontErr               error
33
)
34
35
// loadFonts parses the embedded CommitMono OTFs once. They use CFF outlines,
36
// which the freetype loader gg ships with can't read, so we parse them with
37
// x/image/font/opentype (sfnt) instead.
38
func loadFonts() {
39
	fontOnce.Do(func() {
40
		rb, err := appFS.ReadFile("static/fonts/CommitMono-400-Regular.otf")
41
		if err != nil {
42
			fontErr = err
43
			return
44
		}
45
		bb, err := appFS.ReadFile("static/fonts/CommitMono-700-Regular.otf")
46
		if err != nil {
47
			fontErr = err
48
			return
49
		}
50
		if fontRegular, err = opentype.Parse(rb); err != nil {
51
			fontErr = err
52
			return
53
		}
54
		if fontBold, err = opentype.Parse(bb); err != nil {
55
			fontErr = err
56
			return
57
		}
58
	})
59
}
60
61
// newFace builds a font.Face at the given size. Faces are created per call
62
// because opentype faces are not safe for concurrent use.
63
func newFace(bold bool, size float64) (font.Face, error) {
64
	loadFonts()
65
	if fontErr != nil {
66
		return nil, fontErr
67
	}
68
	src := fontRegular
69
	if bold {
70
		src = fontBold
71
	}
72
	return opentype.NewFace(src, &opentype.FaceOptions{Size: size, DPI: 72, Hinting: font.HintingFull})
73
}
74
75
func (a *App) ogImageHandler(w http.ResponseWriter, r *http.Request) {
76
	urls := feedURLsFromRequest(r)
77
	key := ogCacheKey(urls)
78
79
	ctx, cancel := context.WithTimeout(r.Context(), 8*time.Second)
80
	defer cancel()
81
82
	// getOrRender serves a cached PNG or, on a miss, runs the render once even
83
	// under concurrent requests for the same key.
84
	png, err := a.Images.getOrRender(key, func() ([]byte, error) {
85
		title, desc := "Feeds", "Experience RSS feeds"
86
		if len(urls) > 0 {
87
			items, titles := previewURLs(ctx, urls, 0, a.Cache, a.Log)
88
			title, desc = feedMeta(urls, titles, len(items))
89
		}
90
91
		// Bound concurrent renders to cap CPU and peak memory (each render
92
		// allocates a ~3MB bitmap). Overloaded requests bail rather than pile up.
93
		select {
94
		case a.renderSem <- struct{}{}:
95
			defer func() { <-a.renderSem }()
96
		case <-ctx.Done():
97
			return nil, errRenderBusy
98
		}
99
		return renderOGImage(title, desc)
100
	})
101
	switch {
102
	case err == errRenderBusy:
103
		http.Error(w, "render busy", http.StatusServiceUnavailable)
104
		return
105
	case err != nil:
106
		a.Log.Error("og render failed", "err", err)
107
		http.Error(w, "og render error", http.StatusInternalServerError)
108
		return
109
	}
110
	writePNG(w, png)
111
}
112
113
// ogCacheKey normalizes a URL set into a stable cache key (order-independent).
114
func ogCacheKey(urls []string) string {
115
	sorted := append([]string(nil), urls...)
116
	sort.Strings(sorted)
117
	return strings.Join(sorted, "\n")
118
}
119
120
func writePNG(w http.ResponseWriter, png []byte) {
121
	w.Header().Set("Content-Type", "image/png")
122
	w.Header().Set("Cache-Control", "public, max-age=300")
123
	_, _ = w.Write(png)
124
}
125
126
// renderOGImage draws a 1200x630 social card with the feed title and a subtitle.
127
func renderOGImage(title, desc string) ([]byte, error) {
128
	dc := gg.NewContext(ogWidth, ogHeight)
129
	dc.SetHexColor("#ffffff")
130
	dc.Clear()
131
132
	// "FEEDS" wordmark.
133
	wordmark, err := newFace(true, 34)
134
	if err != nil {
135
		return nil, err
136
	}
137
	dc.SetFontFace(wordmark)
138
	dc.SetHexColor("#1a1a1a")
139
	dc.DrawString("FEEDS", ogMargin, 120)
140
141
	// Title, wrapped, truncated so it can't run into the subtitle.
142
	titleFace, err := newFace(true, 68)
143
	if err != nil {
144
		return nil, err
145
	}
146
	dc.SetFontFace(titleFace)
147
	dc.SetHexColor("#1a1a1a")
148
	dc.DrawStringWrapped(truncate(title, 90), ogMargin, 200, 0, 0, ogWidth-2*ogMargin, 1.35, gg.AlignLeft)
149
150
	// Subtitle pinned near the bottom.
151
	subFace, err := newFace(false, 34)
152
	if err != nil {
153
		return nil, err
154
	}
155
	dc.SetFontFace(subFace)
156
	dc.SetHexColor("#6b6b6b")
157
	dc.DrawString(desc, ogMargin, ogHeight-ogMargin)
158
159
	var buf bytes.Buffer
160
	if err := dc.EncodePNG(&buf); err != nil {
161
		return nil, err
162
	}
163
	return buf.Bytes(), nil
164
}
165
166
func truncate(s string, maxRunes int) string {
167
	if utf8.RuneCountInString(s) <= maxRunes {
168
		return s
169
	}
170
	runes := []rune(s)
171
	return string(runes[:maxRunes]) + "…"
172
}