Add dynamic OG image generation for rich iMessage/social previews

Generates a branded 1200x630 PNG per event with title, date/time/location
using Go's image package and a custom bitmap font. Supports BBQ_BASE_URL
env var for absolute URLs required by link preview crawlers.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-16 15:50:24 -04:00
parent ef3aa3e9c3
commit 6a70135a5d
4 changed files with 241 additions and 5 deletions
+2
View File
@@ -124,6 +124,7 @@ type EventPageData struct {
Rsvps []db.Rsvp Rsvps []db.Rsvp
TotalGoing int64 TotalGoing int64
IsAdmin bool IsAdmin bool
BaseURL string
} }
func (s *Server) loadEventPage(r *http.Request, slug string, isAdmin bool) (*EventPageData, error) { func (s *Server) loadEventPage(r *http.Request, slug string, isAdmin bool) (*EventPageData, error) {
@@ -181,6 +182,7 @@ func (s *Server) loadEventPage(r *http.Request, slug string, isAdmin bool) (*Eve
Rsvps: rsvps, Rsvps: rsvps,
TotalGoing: totalGoing, TotalGoing: totalGoing,
IsAdmin: isAdmin, IsAdmin: isAdmin,
BaseURL: s.baseURL,
}, nil }, nil
} }
+8 -2
View File
@@ -34,16 +34,18 @@ var pageTmpl map[string]*template.Template
type Server struct { type Server struct {
q *db.Queries q *db.Queries
db *sql.DB db *sql.DB
baseURL string
// SSE: map of event slug -> set of channels // SSE: map of event slug -> set of channels
mu sync.Mutex mu sync.Mutex
clients map[string]map[chan struct{}]struct{} clients map[string]map[chan struct{}]struct{}
} }
func NewServer(database *sql.DB) *Server { func NewServer(database *sql.DB, baseURL string) *Server {
return &Server{ return &Server{
q: db.New(database), q: db.New(database),
db: database, db: database,
baseURL: baseURL,
clients: make(map[string]map[chan struct{}]struct{}), clients: make(map[string]map[chan struct{}]struct{}),
} }
} }
@@ -90,6 +92,7 @@ func main() {
if v := os.Getenv("PORT"); v != "" { if v := os.Getenv("PORT"); v != "" {
port = v port = v
} }
baseURL := os.Getenv("BBQ_BASE_URL") // e.g. https://bbq.torrtle.co
database, err := sql.Open("sqlite3", dbPath+"?_journal_mode=WAL&_foreign_keys=on") database, err := sql.Open("sqlite3", dbPath+"?_journal_mode=WAL&_foreign_keys=on")
if err != nil { if err != nil {
@@ -128,7 +131,7 @@ func main() {
template.New("").Funcs(funcMap).ParseFS(templateFS, "templates/slots.html"), template.New("").Funcs(funcMap).ParseFS(templateFS, "templates/slots.html"),
) )
srv := NewServer(database) srv := NewServer(database, baseURL)
r := chi.NewRouter() r := chi.NewRouter()
r.Use(middleware.Logger) r.Use(middleware.Logger)
@@ -149,6 +152,9 @@ func main() {
r.Post("/e/{slug}/rsvp", srv.handleRsvp) r.Post("/e/{slug}/rsvp", srv.handleRsvp)
r.Delete("/e/{slug}/rsvp/{rsvpID}", srv.handleUnrsvp) r.Delete("/e/{slug}/rsvp/{rsvpID}", srv.handleUnrsvp)
// OG image
r.Get("/e/{slug}/og.png", srv.handleOGImage)
// SSE // SSE
r.Get("/e/{slug}/sse", srv.handleSSE) r.Get("/e/{slug}/sse", srv.handleSSE)
+224
View File
@@ -0,0 +1,224 @@
package main
import (
"bytes"
"image"
"image/color"
"image/draw"
"image/png"
"net/http"
"github.com/go-chi/chi/v5"
)
var (
ogCream = color.RGBA{0xf5, 0xf0, 0xe8, 0xff}
ogInk = color.RGBA{0x1a, 0x1a, 0x1a, 0xff}
ogYellow = color.RGBA{0xf5, 0xe6, 0x42, 0xff}
ogSage = color.RGBA{0xa8, 0xc5, 0xa0, 0xff}
ogPeach = color.RGBA{0xf2, 0xa4, 0x82, 0xff}
ogWhite = color.RGBA{0xff, 0xff, 0xff, 0xff}
)
func fillRect(img *image.RGBA, x1, y1, x2, y2 int, c color.RGBA) {
draw.Draw(img, image.Rect(x1, y1, x2, y2), &image.Uniform{c}, image.Point{}, draw.Src)
}
// 5x7 pixel font for uppercase letters, digits, and a few symbols
var glyphs = map[rune][7]uint8{
'A': {0b01110, 0b10001, 0b10001, 0b11111, 0b10001, 0b10001, 0b10001},
'B': {0b11110, 0b10001, 0b10001, 0b11110, 0b10001, 0b10001, 0b11110},
'C': {0b01110, 0b10001, 0b10000, 0b10000, 0b10000, 0b10001, 0b01110},
'D': {0b11110, 0b10001, 0b10001, 0b10001, 0b10001, 0b10001, 0b11110},
'E': {0b11111, 0b10000, 0b10000, 0b11110, 0b10000, 0b10000, 0b11111},
'F': {0b11111, 0b10000, 0b10000, 0b11110, 0b10000, 0b10000, 0b10000},
'G': {0b01110, 0b10001, 0b10000, 0b10111, 0b10001, 0b10001, 0b01110},
'H': {0b10001, 0b10001, 0b10001, 0b11111, 0b10001, 0b10001, 0b10001},
'I': {0b11111, 0b00100, 0b00100, 0b00100, 0b00100, 0b00100, 0b11111},
'J': {0b00111, 0b00010, 0b00010, 0b00010, 0b00010, 0b10010, 0b01100},
'K': {0b10001, 0b10010, 0b10100, 0b11000, 0b10100, 0b10010, 0b10001},
'L': {0b10000, 0b10000, 0b10000, 0b10000, 0b10000, 0b10000, 0b11111},
'M': {0b10001, 0b11011, 0b10101, 0b10101, 0b10001, 0b10001, 0b10001},
'N': {0b10001, 0b11001, 0b10101, 0b10011, 0b10001, 0b10001, 0b10001},
'O': {0b01110, 0b10001, 0b10001, 0b10001, 0b10001, 0b10001, 0b01110},
'P': {0b11110, 0b10001, 0b10001, 0b11110, 0b10000, 0b10000, 0b10000},
'Q': {0b01110, 0b10001, 0b10001, 0b10001, 0b10101, 0b10010, 0b01101},
'R': {0b11110, 0b10001, 0b10001, 0b11110, 0b10100, 0b10010, 0b10001},
'S': {0b01110, 0b10001, 0b10000, 0b01110, 0b00001, 0b10001, 0b01110},
'T': {0b11111, 0b00100, 0b00100, 0b00100, 0b00100, 0b00100, 0b00100},
'U': {0b10001, 0b10001, 0b10001, 0b10001, 0b10001, 0b10001, 0b01110},
'V': {0b10001, 0b10001, 0b10001, 0b10001, 0b10001, 0b01010, 0b00100},
'W': {0b10001, 0b10001, 0b10001, 0b10101, 0b10101, 0b11011, 0b10001},
'X': {0b10001, 0b10001, 0b01010, 0b00100, 0b01010, 0b10001, 0b10001},
'Y': {0b10001, 0b10001, 0b01010, 0b00100, 0b00100, 0b00100, 0b00100},
'Z': {0b11111, 0b00001, 0b00010, 0b00100, 0b01000, 0b10000, 0b11111},
'0': {0b01110, 0b10001, 0b10011, 0b10101, 0b11001, 0b10001, 0b01110},
'1': {0b00100, 0b01100, 0b00100, 0b00100, 0b00100, 0b00100, 0b11111},
'2': {0b01110, 0b10001, 0b00001, 0b00110, 0b01000, 0b10000, 0b11111},
'3': {0b01110, 0b10001, 0b00001, 0b00110, 0b00001, 0b10001, 0b01110},
'4': {0b00010, 0b00110, 0b01010, 0b10010, 0b11111, 0b00010, 0b00010},
'5': {0b11111, 0b10000, 0b11110, 0b00001, 0b00001, 0b10001, 0b01110},
'6': {0b01110, 0b10000, 0b10000, 0b11110, 0b10001, 0b10001, 0b01110},
'7': {0b11111, 0b00001, 0b00010, 0b00100, 0b01000, 0b01000, 0b01000},
'8': {0b01110, 0b10001, 0b10001, 0b01110, 0b10001, 0b10001, 0b01110},
'9': {0b01110, 0b10001, 0b10001, 0b01111, 0b00001, 0b00001, 0b01110},
' ': {0b00000, 0b00000, 0b00000, 0b00000, 0b00000, 0b00000, 0b00000},
'\'': {0b00100, 0b00100, 0b01000, 0b00000, 0b00000, 0b00000, 0b00000},
'.': {0b00000, 0b00000, 0b00000, 0b00000, 0b00000, 0b00000, 0b00100},
',': {0b00000, 0b00000, 0b00000, 0b00000, 0b00000, 0b00100, 0b01000},
'!': {0b00100, 0b00100, 0b00100, 0b00100, 0b00100, 0b00000, 0b00100},
'?': {0b01110, 0b10001, 0b00001, 0b00110, 0b00100, 0b00000, 0b00100},
'-': {0b00000, 0b00000, 0b00000, 0b11111, 0b00000, 0b00000, 0b00000},
'#': {0b01010, 0b01010, 0b11111, 0b01010, 0b11111, 0b01010, 0b01010},
'/': {0b00001, 0b00010, 0b00010, 0b00100, 0b01000, 0b01000, 0b10000},
':': {0b00000, 0b00100, 0b00000, 0b00000, 0b00000, 0b00100, 0b00000},
'@': {0b01110, 0b10001, 0b10111, 0b10101, 0b10110, 0b10000, 0b01110},
'+': {0b00000, 0b00100, 0b00100, 0b11111, 0b00100, 0b00100, 0b00000},
'&': {0b01100, 0b10010, 0b10100, 0b01000, 0b10101, 0b10010, 0b01101},
}
func drawText(img *image.RGBA, text string, x, y, scale int, c color.RGBA) int {
cx := x
for _, ch := range text {
if ch >= 'a' && ch <= 'z' {
ch = ch - 'a' + 'A'
}
glyph, ok := glyphs[ch]
if !ok {
cx += 4 * scale // skip unknown chars
continue
}
for row := 0; row < 7; row++ {
for col := 0; col < 5; col++ {
if glyph[row]&(1<<(4-col)) != 0 {
fillRect(img, cx+col*scale, y+row*scale, cx+(col+1)*scale, y+(row+1)*scale, c)
}
}
}
cx += 6 * scale // 5 pixels + 1 spacing
}
return cx - x
}
func textWidth(text string, scale int) int {
n := 0
for range text {
n++
}
if n == 0 {
return 0
}
return n*6*scale - scale
}
func (s *Server) handleOGImage(w http.ResponseWriter, r *http.Request) {
slug := chi.URLParam(r, "slug")
event, err := s.q.GetEventBySlug(r.Context(), slug)
if err != nil {
http.NotFound(w, r)
return
}
imgW, imgH := 1200, 630
img := image.NewRGBA(image.Rect(0, 0, imgW, imgH))
// Background
fillRect(img, 0, 0, imgW, imgH, ogCream)
// Yellow header bar
fillRect(img, 0, 0, imgW, 80, ogYellow)
fillRect(img, 0, 78, imgW, 83, ogInk)
// Logo in header: dark box with emoji-colored inner
fillRect(img, 40, 16, 88, 64, ogInk)
fillRect(img, 44, 20, 84, 60, ogYellow)
drawText(img, "BBQ", 100, 26, 4, ogInk)
// Center card
cardX, cardY := 120, 140
cardW, cardH := imgW-240, 400
// Shadow
fillRect(img, cardX+8, cardY+8, cardX+cardW+8, cardY+cardH+8, ogInk)
// Border
fillRect(img, cardX-3, cardY-3, cardX+cardW+3, cardY+cardH+3, ogInk)
// Card
fillRect(img, cardX, cardY, cardX+cardW, cardY+cardH, ogWhite)
// Tag pill
tagText := "POTLUCK"
tagW := textWidth(tagText, 3) + 24
fillRect(img, cardX+30-2, cardY+28-2, cardX+30+tagW+2, cardY+28+28+2, ogInk)
fillRect(img, cardX+30, cardY+28, cardX+30+tagW, cardY+28+28, ogSage)
drawText(img, tagText, cardX+42, cardY+32, 3, ogInk)
// Event title - scale to fit
title := event.Title
scale := 7
for scale > 3 {
if textWidth(title, scale) <= cardW-80 {
break
}
scale--
}
// Truncate if still too wide
for len(title) > 0 && textWidth(title, scale) > cardW-80 {
title = title[:len(title)-1]
}
titleY := cardY + 90
drawText(img, title, cardX+38, titleY, scale, ogInk)
// Date/time/location line
meta := ""
if event.Date != "" {
meta = event.Date
}
if event.Time != "" {
if meta != "" {
meta += " "
}
meta += event.Time
}
if event.Location != "" {
if meta != "" {
meta += " - "
}
meta += event.Location
}
if meta != "" {
metaScale := 3
for len(meta) > 0 && textWidth(meta, metaScale) > cardW-80 {
meta = meta[:len(meta)-1]
}
metaY := titleY + 7*scale + 24
drawText(img, meta, cardX+38, metaY, metaScale, color.RGBA{0x66, 0x66, 0x66, 0xff})
}
// Decorative slot-like bars at the bottom of the card
barY := cardY + cardH - 100
bars := []struct {
w int
c color.RGBA
}{
{320, ogYellow},
{240, ogSage},
{280, ogPeach},
{200, ogYellow},
}
for i, b := range bars {
y := barY + i*22
fillRect(img, cardX+38, y, cardX+38+b.w, y+14, b.c)
fillRect(img, cardX+38, y, cardX+38+b.w, y+14, b.c)
}
// Bottom accent stripe
fillRect(img, 0, imgH-14, imgW, imgH-10, ogInk)
fillRect(img, 0, imgH-10, imgW, imgH, ogYellow)
var buf bytes.Buffer
png.Encode(&buf, img)
w.Header().Set("Content-Type", "image/png")
w.Header().Set("Cache-Control", "public, max-age=3600")
w.Write(buf.Bytes())
}
+5 -1
View File
@@ -5,10 +5,14 @@
{{define "meta"}} {{define "meta"}}
<meta property="og:title" content="{{.Event.Title}}"> <meta property="og:title" content="{{.Event.Title}}">
<meta property="og:description" content="{{if .Event.Date}}{{.Event.Date}}{{end}}{{if .Event.Time}} at {{.Event.Time}}{{end}}{{if .Event.Location}} — {{.Event.Location}}{{end}} · {{.TotalGoing}} going"> <meta property="og:description" content="{{if .Event.Date}}{{.Event.Date}}{{end}}{{if .Event.Time}} at {{.Event.Time}}{{end}}{{if .Event.Location}} — {{.Event.Location}}{{end}} · {{.TotalGoing}} going">
<meta property="og:image" content="{{.BaseURL}}/e/{{.Event.Slug}}/og.png">
<meta property="og:image:width" content="1200">
<meta property="og:image:height" content="630">
<meta property="og:type" content="website"> <meta property="og:type" content="website">
<meta name="twitter:card" content="summary"> <meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="{{.Event.Title}}"> <meta name="twitter:title" content="{{.Event.Title}}">
<meta name="twitter:description" content="{{if .Event.Date}}{{.Event.Date}}{{end}}{{if .Event.Time}} at {{.Event.Time}}{{end}}{{if .Event.Location}} — {{.Event.Location}}{{end}} · {{.TotalGoing}} going"> <meta name="twitter:description" content="{{if .Event.Date}}{{.Event.Date}}{{end}}{{if .Event.Time}} at {{.Event.Time}}{{end}}{{if .Event.Location}} — {{.Event.Location}}{{end}} · {{.TotalGoing}} going">
<meta name="twitter:image" content="{{.BaseURL}}/e/{{.Event.Slug}}/og.png">
{{end}} {{end}}
{{define "admin-bar"}}{{if .IsAdmin}}<div class="admin-bar">ADMIN VIEW — share the guest link: /e/{{.Event.Slug}}</div>{{end}}{{end}} {{define "admin-bar"}}{{if .IsAdmin}}<div class="admin-bar">ADMIN VIEW — share the guest link: /e/{{.Event.Slug}}</div>{{end}}{{end}}