eleven / messenger Public

server: /img shrinks oversized card images to card width (#1253)

shrinkCardImage on the fetch service's image proxy: JPEG/PNG wider than
1600px re-encode at 1200 (CatmullRom, same format, JPEG q80) — og:images
are routinely thousands of px wide for a card that renders at a few
hundred, and every viewer pays that transfer. GIFs pass through (a resized
GIF freezes), as does anything that fails to decode, claims bomb-scale
pixel dims (config-checked before any full decode), or comes out bigger
'shrunk' — every failure path returns the original bytes.

New dep: golang.org/x/image (official; CatmullRom scaler). docs/fetch.md's
client-flip + resize roadmap entries promoted to shipped sections.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell 588bc030bc6d249645f4c9554d2d80480edcde66 parent c8a2b94
5 files changed, +148 −19
  • docs/fetch.md +25 −9
  • fetch.go +54 −0
  • fetch_test.go +56 −0
  • go.mod +3 −2
  • go.sum +10 −8
diff --git a/docs/fetch.md b/docs/fetch.md
index 2e3a853..ebddbe9 100644
--- a/docs/fetch.md
+++ b/docs/fetch.md
@@ -70,17 +70,33 @@ deployed anywhere yet; when it ships, the planet-scale guide gains its row.
No secrets in env — the salt and signing key mint themselves into the DB on
first boot.
+## The client flip (shipped, env-gated)
+
+`LCHAT_FETCH_URL` on an instance routes its clients here: the chat page CSP
+grows the origin in `connect-src` + `window.__fetchURL` injects it (web), and
+`fetch_url` rides `/api/group`'s member-gated read (native — `FeatureGates`,
+latched so a failed read never reroutes). Both platforms prefer the service
+with instance-unfurl fallback on transport failure. Self-host default stays
+*local fetch* — a self-hosted instance silently shipping its members' URLs to
+our shared service would betray the self-host story; pointing at the shared
+service (or their own) is opt-in config.
+
+## Resize (shipped)
+
+`/img` re-encodes oversized JPEG/PNG card images down to card width
+(`shrinkCardImage`: >1600px wide → 1200px, CatmullRom, same format) — an
+og:image is routinely thousands of px for a card rendering at a few hundred,
+and every viewer pays that transfer. GIFs pass through (resizing freezes the
+animation), as does anything that fails to decode, claims bomb-scale pixel
+dims, or comes out bigger "shrunk" — polish never breaks a card. Public web
+images only; member photos keep their client-side pipeline
+(`processImage`), always.
+
## Roadmap
-- **Phase 2 — the client flip.** Instance serves the fetch URL to clients
- (config), CSP grows `connect-src`/`img-src` for it, `host.unfurl` prefers
- the fetch service with instance-unfurl fallback, and hashcash lands with it.
- Self-host default stays *local fetch* — a self-hosted instance silently
- shipping its members' URLs to our shared service would betray the self-host story;
- pointing at the shared service (or their own) is opt-in config.
-- **Resize.** Public card images only (og:image can be multi-MB); a flag on
- the `/img` path, never a new trust decision. Member photos keep their
- client-side pipeline.
+- **Hashcash** before the shared service is load-bearing/default-on for the
+ hosted fleet (the provisioner's machinery; anonymous callers pay CPU, not
+ identity).
## Giphy (`GET /api/gif`)
diff --git a/fetch.go b/fetch.go
index f4bb433..5f5a839 100644
--- a/fetch.go
+++ b/fetch.go
@@ -21,6 +21,7 @@
package main
import (
+ "bytes"
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
@@ -30,6 +31,9 @@ import (
"encoding/json"
"flag"
"fmt"
+ "image"
+ "image/jpeg"
+ "image/png"
"io"
"log"
"net"
@@ -40,12 +44,18 @@ import (
"strings"
"sync"
"time"
+
+ xdraw "golang.org/x/image/draw"
)
const (
fetchImgMaxBytes = 8 * 1024 * 1024 // largest card image we'll proxy
fetchImgTimeout = 15 * time.Second
fetchImgCache = "public, max-age=86400"
+ // Cards render at most a few hundred CSS px wide; og:images routinely
+ // arrive at several thousand. Anything wider than Over re-encodes at To.
+ fetchImgResizeOver = 1600
+ fetchImgResizeTo = 1200
)
// fetchImgClient fetches card images. Same SSRF posture as ogClient — every
@@ -371,12 +381,56 @@ func (g *fetchServer) serveImage(w http.ResponseWriter, r *http.Request) {
httpError(w, http.StatusBadGateway, "image too large")
return
}
+ body = shrinkCardImage(ct, body)
w.Header().Set("Content-Type", resp.Header.Get("Content-Type"))
w.Header().Set("Cache-Control", fetchImgCache)
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Write(body)
}
+// shrinkCardImage re-encodes an oversized JPEG/PNG down to card width — an
+// og:image is often thousands of px wide for a card that renders at a few
+// hundred, and every viewer pays that transfer. Only those two formats: a
+// resized GIF would freeze its animation, and nothing else here has a stdlib
+// encoder. Every failure path returns the original bytes — polish must never
+// break a card — and a "shrink" that came out bigger keeps the original too.
+func shrinkCardImage(ct string, body []byte) []byte {
+ if !strings.HasPrefix(ct, "image/jpeg") && !strings.HasPrefix(ct, "image/png") {
+ return body
+ }
+ cfg, kind, err := image.DecodeConfig(bytes.NewReader(body))
+ if err != nil || cfg.Width <= fetchImgResizeOver {
+ return body
+ }
+ // Decompression bomb: a tiny file can claim enormous pixel dims. Don't
+ // decode those ourselves — pass the bytes through and let the browser's
+ // own limits deal with it.
+ if cfg.Height <= 0 || cfg.Width*cfg.Height > 40<<20 {
+ return body
+ }
+ src, _, err := image.Decode(bytes.NewReader(body))
+ if err != nil {
+ return body
+ }
+ h := cfg.Height * fetchImgResizeTo / cfg.Width
+ if h < 1 {
+ h = 1
+ }
+ dst := image.NewRGBA(image.Rect(0, 0, fetchImgResizeTo, h))
+ xdraw.CatmullRom.Scale(dst, dst.Bounds(), src, src.Bounds(), xdraw.Over, nil)
+ var out bytes.Buffer
+ switch kind {
+ case "png":
+ err = png.Encode(&out, dst)
+ default:
+ err = jpeg.Encode(&out, dst, &jpeg.Options{Quality: 80})
+ }
+ if err != nil || out.Len() >= len(body) {
+ return body
+ }
+ return out.Bytes()
+}
+
// giphyAPIBase is a var so tests can stand a fake upstream in.
var giphyAPIBase = "https://api.giphy.com/v1/gifs/"
diff --git a/fetch_test.go b/fetch_test.go
index 38a91b6..2b111f3 100644
--- a/fetch_test.go
+++ b/fetch_test.go
@@ -1,7 +1,10 @@
package main
import (
+ "bytes"
"encoding/json"
+ "image"
+ "image/png"
"io"
"net/http"
"net/http/httptest"
@@ -313,3 +316,56 @@ func TestFetchSecretsPersist(t *testing.T) {
t.Fatalf("weak secrets: key %d bytes, salt %q", len(key), salt)
}
}
+
+// Oversized JPEG/PNG card images shrink to card width on the way through the
+// proxy; GIFs (animation) and already-small images pass through untouched.
+func TestFetchImageProxyShrinks(t *testing.T) {
+ ts, g := newTestFetch(t)
+
+ allowPrivatePreviewHosts = true
+ t.Cleanup(func() { allowPrivatePreviewHosts = false })
+
+ // A 2400×1200 PNG (well over fetchImgResizeOver) and a small one.
+ big := image.NewRGBA(image.Rect(0, 0, 2400, 1200))
+ var bigBuf bytes.Buffer
+ png.Encode(&bigBuf, big)
+ var smallBuf bytes.Buffer
+ png.Encode(&smallBuf, image.NewRGBA(image.Rect(0, 0, 320, 200)))
+
+ site := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "image/png")
+ if r.URL.Path == "/big.png" {
+ w.Write(bigBuf.Bytes())
+ } else {
+ w.Write(smallBuf.Bytes())
+ }
+ }))
+ t.Cleanup(site.Close)
+
+ get := func(path string) []byte {
+ t.Helper()
+ req, _ := http.NewRequest("GET", ts.URL+"/api/unfurl", nil)
+ r, err := http.Get(g.proxyImageURL(&http.Request{Host: strings.TrimPrefix(ts.URL, "http://"), URL: req.URL}, site.URL+path))
+ if err != nil {
+ t.Fatalf("proxy %s: %v", path, err)
+ }
+ defer r.Body.Close()
+ b, _ := io.ReadAll(r.Body)
+ if r.StatusCode != http.StatusOK {
+ t.Fatalf("proxy %s: status %d", path, r.StatusCode)
+ }
+ return b
+ }
+
+ shrunk := get("/big.png")
+ cfg, _, err := image.DecodeConfig(bytes.NewReader(shrunk))
+ if err != nil || cfg.Width != fetchImgResizeTo || cfg.Height != 600 {
+ t.Fatalf("big.png came back %dx%d err=%v (want %dx600)", cfg.Width, cfg.Height, err, fetchImgResizeTo)
+ }
+ if len(shrunk) >= bigBuf.Len() {
+ t.Fatalf("shrink didn't shrink: %d >= %d", len(shrunk), bigBuf.Len())
+ }
+ if got := get("/small.png"); string(got) != smallBuf.String() {
+ t.Fatal("small image should pass through byte-identical")
+ }
+}
diff --git a/go.mod b/go.mod
index d5ab4dd..0aa274f 100644
--- a/go.mod
+++ b/go.mod
@@ -17,6 +17,7 @@ require (
github.com/go-webauthn/webauthn v0.11.2
github.com/sqids/sqids-go v0.4.1
golang.org/x/crypto v0.52.0
+ golang.org/x/image v0.44.0
golang.org/x/term v0.45.0
modernc.org/sqlite v1.44.3
)
@@ -69,9 +70,9 @@ require (
github.com/x448/float16 v0.8.4 // indirect
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect
golang.org/x/net v0.55.0 // indirect
- golang.org/x/sync v0.20.0 // indirect
+ golang.org/x/sync v0.22.0 // indirect
golang.org/x/sys v0.47.0 // indirect
- golang.org/x/text v0.37.0 // indirect
+ golang.org/x/text v0.40.0 // indirect
google.golang.org/protobuf v1.36.10 // indirect
modernc.org/libc v1.67.6 // indirect
modernc.org/mathutil v1.7.1 // indirect
diff --git a/go.sum b/go.sum
index afff804..00b7fed 100644
--- a/go.sum
+++ b/go.sum
@@ -207,13 +207,15 @@ golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988=
golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc=
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2wtOnGAHZWCHUM4KGzY=
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70=
+golang.org/x/image v0.44.0 h1:+tDekMZED9+LrtB3G5xzRggpVh9CARjZqROla3R3R+I=
+golang.org/x/image v0.44.0/go.mod h1:V8K3KE9KKKE+pLpQDOeN18w9oacNSvy1tDOirTu4xtY=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
-golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM=
-golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU=
+golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ=
+golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
@@ -233,8 +235,8 @@ golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
-golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
-golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
+golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
+golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
@@ -269,8 +271,8 @@ golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
-golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
-golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
+golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
+golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
golang.org/x/time v0.7.0 h1:ntUhktv3OPE6TgYxXWv9vKvUSJyIFJlyohwbkEwPrKQ=
golang.org/x/time v0.7.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
@@ -279,8 +281,8 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
-golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c=
-golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI=
+golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q=
+golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/api v0.155.0 h1:vBmGhCYs0djJttDNynWo44zosHlPvHmA0XiN2zP2DtA=