Image Proxy
Lumio's public pages show images that live on third-party CDNs: Twitch, 7TV, BetterTTV and FrankerFaceZ emotes, platform avatars, Twitch panel artwork and Bits graphics. If the visitor's browser loaded them from those CDNs, each request would send the visitor's IP address and user agent to the provider. The image proxy avoids that. The browser loads /img/<provider>/<path> from the Lumio host it is already on, and the Lumio server fetches the upstream image without any visitor data.
The proxy is a route handler in each public app, built from the shared package @lumio/image-proxy (shared/image-proxy):
| App | Route | Covered pages |
|---|---|---|
apps/stats | src/app/img/[...path]/route.ts | every Stats page |
apps/web | src/app/img/[...path]/route.ts | the share report /share/history/[token], the ideas board /ideas and /ideas/[id], the invite pages /invite/*, and the other marketing and legal pages |
The signed-in dashboard, Multichat, popouts and overlays do not use it. They load images directly from the CDNs, because their users are contract parties. GIPHY GIFs are not proxied anywhere; on public pages they still wait for Functional consent (Cookies Consent Library).
Providers
A proxy URL names a provider key, never a host or a full URL, so the proxy can reach only these hosts, and on each host only these first path segments:
| Key | Upstream host | Allowed first segment | Query |
|---|---|---|---|
ttv | static-cdn.jtvnw.net | emoticons, jtv_user_pictures, badges | none |
ttvpanel | panels.twitch.tv | exactly one segment panel-<channel id>-image-<uuid> (Twitch panel artwork) | none |
7tv | cdn.7tv.app | emote | none |
bttv | cdn.betterttv.net | emote | none |
ffz | cdn.frankerfacez.com | emote | none |
yt | yt3.ggpht.com | one opaque segment, or ytc/<one segment> | none |
discord | cdn.discordapp.com | avatars, icons (never attachments) | size ∈ 16, 32, 64, 128, 256, 512, 1024, 2048, 4096 |
kick | files.kick.com | images, emotes, kicks, channel_subscriber_badges | none |
kickext | ext.cdn.kick.com | chat | none |
bits | d3aqoihi2n8ty8.cloudfront.net | actions | none |
The table lives in shared/image-proxy/src/providers.ts. Adding a provider or a segment widens what the proxy fetches, so it needs an AppSec review.
Rewriting URLs
proxiedImageUrl(src) from @lumio/image-proxy turns https://static-cdn.jtvnw.net/emoticons/v2/25/default/dark/1.0 into /img/ttv/emoticons/v2/25/default/dark/1.0. It returns null for anything the proxy would refuse. The caller then renders its fallback (initials, the emote code, a placeholder) and never the raw URL.
proxiedImageUrl and the handler share one validator (shared/image-proxy/src/path.ts). A URL is accepted only in its canonical form, so every image has exactly one proxy URL and one cache key:
https:only, no userinfo, no port (:443included), no fragment, nothing the URL parser would normalise (dot segments, upper-case host).- Path segments match
[A-Za-z0-9._~=-]+: no%(nothing encoded), no\, no empty segment (//, a trailing/), no.or... The upstream path is at most 512 bytes. - A query string only where the provider allows one. Each parameter appears at most once, is never empty, takes a value from the fixed set, and keys are in sorted order.
proxiedSrcSet(srcSet) does the same for every candidate of a srcset and returns null if any candidate cannot be proxied.
Where the components get the rewrite:
- Stats:
ProxiedImagePolicyin the root layout setsAvatarImagePolicy resolveSrc={proxiedImageUrl}from@lumio/ui, so everyAvatarImageandChannelAvatarloads the proxied URL.EmoteImageand the panel artwork (proxiedSlidesinchannel-panels.tsx) rewrite on the server, so the HTML carries no CDN URL. - Web:
PublicMediaPolicy(src/components/third-party-media-gate.tsx) sets the sameAvatarImagePolicy. Other leaves calluseImageSrc()/useImageSrcSet():ChatEmoteImage,BitsToken, the report's emote, chatter, AutoMod and event avatars, and the emote hover card. Outside a policy (dashboard) these hooks return the URL unchanged.
Request handling
| Step | Rule |
|---|---|
| Path | Canonical form as above. A syntax error is 400; an unknown provider or first segment is 404. Nothing is fetched. |
| Outbound headers | Built fresh. No incoming header is copied: no X-Forwarded-For, Forwarded, CF-Connecting-IP, X-Real-IP, Cookie, Referer, User-Agent or Accept-Language. The proxy sends User-Agent: LumioImageProxy/1 (+https://lumio.vision) and Accept: image/avif,image/webp,image/png,image/gif,image/jpeg, with credentials: "omit" and referrerPolicy: "no-referrer". |
| Data Cache | cache: "no-store" on the fetch and dynamic = "force-dynamic" on the route, so Next keeps no copy. |
| Redirects | redirect: "manual". At most 2 redirects, each only to a URL proxiedImageUrl would also accept. Anything else is 502. |
| Timeout | 5 s, and the fetch is also aborted when the visitor's request goes away (request.signal). |
| Status | Only an upstream 200 passes. An upstream 404 is 404; every other status is 502. |
| Type | image/png, image/jpeg, image/gif, image/webp or image/avif from the upstream Content-Type. No SVG. The bytes are not inspected, with one exception: files.kick.com serves emotes as binary/octet-stream, so for the kick provider only an octet-stream body is typed by its file signature. It passes only if the signature names one of the five raster types. |
| Size | At most 2 MiB. Content-Length is checked first, and the body is counted while it is read. |
Logs carry the provider key and the status class only, never the path, because avatar paths contain platform user ids.
Response
Every response gets fresh headers. Only the validated Content-Type comes from upstream; upstream Set-Cookie, Cache-Control and other headers are dropped.
| Header | 200 | Errors (400, 404, 502) |
|---|---|---|
Cache-Control | public, max-age=86400, s-maxage=86400 | public, max-age=60 |
Content-Type | the validated image type | none (empty body) |
X-Content-Type-Options | nosniff | nosniff |
Content-Security-Policy | default-src 'none'; sandbox | same |
Cross-Origin-Resource-Policy | same-origin | same |
Caching and retention
There is no server-side store: no Redis key, no object storage, no disk, no Next Data Cache. The only copies of an image are HTTP caches, the Cloudflare edge and the visitor's browser, and max-age=86400 bounds both to 24 hours whatever the upstream sends. Invalidation is expiry only.
The 24 hours bound the image bytes. The avatar URL itself is kept by the read model for as long as the stream report exists, until erasure; the proxy adds no retention of its own. After an erasure the page stops referencing the image, and remaining cached copies expire within 24 hours.
Cloudflare settings matter here: a zone Browser Cache TTL above 24 hours, or an Edge Cache TTL cache rule that ignores the origin header, would keep copies longer. Both must respect the origin Cache-Control for /img/*.
Request interception
/img/ bypasses request interception. The apps/web proxy.ts matcher excludes img/, so the route gets no login redirect, no locale cookie and no CSP nonce, and a publicly cached image never carries a Set-Cookie. apps/stats/src/proxy.ts still runs on /img/ (it sets X-Robots-Tag per deployment), but it never sets the ?lang= locale cookie there, the same rule as for /og/. robots.txt does not disallow /img/, so search engines can fetch images on indexed pages.
Next itself normalises a request path before the handler runs. Dot segments (/../, /%2e%2e/) are resolved first, so they can only reach a path that passes the allow-list. A doubled or trailing / gets a 308 to the canonical path.
Tests
shared/image-proxy/__tests__/ (vitest project image-proxy) covers the rewrite for every provider, the rewrite and handler refusing every non-canonical or disallowed input, a test that the mocked upstream fetch receives none of the visitor's headers, the redirect, type, size, status and timeout limits, and the Cache-Control values. apps/web/__tests__/third-party-media-gate.test.tsx checks that a public tree renders only /img/ URLs, and apps/web/__tests__/proxy-img-matcher.test.ts checks the matcher exclusion.