Skip to content

Commit 09a8c51

Browse files
iHiDclaude
andauthored
Fetch image data over the internal ALB (#14)
The satori renderer fetched its payload from the public site, which meant going out through Cloudflare and needing this lambda's NAT address allowlisted to get back in. That coupling is what left every image timing out for four days when bot mitigation was turned on, and it would do it again the next time the rules are tightened. /spi is already internal-only - the public ALB fixed-responds it, the internal one forwards it to Rails - so pointing at internal.exercism.org keeps the request inside the VPC. No Cloudflare, no round trip out to the internet and back, no NAT data charges, and nothing to allowlist. The Chrome path still uses the public site. It's screenshotting a rendered page rather than reading a payload, so it has no internal equivalent until profiles are ported too. Needs exercism/website#9351 deployed first: it moves the payload onto the existing /spi/solution_image_data endpoint. Claude-Session: https://claude.ai/code/session_018iQj4EdMfNsWP6NtFTqwUU Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
1 parent c6ba6ff commit 09a8c51

3 files changed

Lines changed: 27 additions & 17 deletions

File tree

dev/render.js

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -16,13 +16,13 @@
1616
// solution-go-tabs.json is tab-indented Go at indent_size 4. Space-indented
1717
// tracks look the same whatever indent_size says, so it takes a track that
1818
// actually ships literal tabs to see that value being honoured.
19-
// node dev/render.js --url https://exercism.org/images/solutions/ruby/bob/ihid
19+
// node dev/render.js --url https://internal.exercism.org/spi/solution_image_data/ruby/bob/ihid
2020
// node dev/render.js --out /tmp/mine.png
2121
//
22-
// --url hits the real data endpoint, so it needs the website to be serving
23-
// /data (exercism/website#9348) and to not be behind a bot challenge. Against
24-
// production you'll get a Cloudflare challenge unless your IP is allowlisted;
25-
// point it at localhost:3020 instead.
22+
// --url takes the data endpoint itself. internal.exercism.org only resolves
23+
// from inside the VPC, so locally point it at your own Rails:
24+
//
25+
// node dev/render.js --url http://localhost:3020/spi/solution_image_data/ruby/bob/ihid
2626

2727
const fs = require("fs");
2828
const path = require("path");
@@ -41,11 +41,11 @@ const fixture = flag("fixture", path.join(__dirname, "fixtures", "solution.json"
4141
async function payload() {
4242
if (!url) return JSON.parse(fs.readFileSync(fixture, "utf8"));
4343

44-
const response = await fetch(`${url}/data`);
44+
const response = await fetch(url);
4545
if (!response.ok) {
4646
throw new Error(
47-
`${url}/data returned ${response.status}. ` +
48-
"A 403 with a Cloudflare challenge means this IP isn't allowlisted."
47+
`${url} returned ${response.status}. ` +
48+
"internal.exercism.org only resolves inside the VPC - use your local Rails."
4949
);
5050
}
5151
return response.json();
@@ -59,7 +59,7 @@ async function main() {
5959

6060
const started = Date.now();
6161
const { generate } = require("../satori_renderer");
62-
const { body, contentType } = await generate({ url: url || "fixture://solution" });
62+
const { body, contentType } = await generate({ dataUrl: url || "fixture://solution" });
6363
const elapsed = Date.now() - started;
6464

6565
fs.writeFileSync(out, body);

index.js

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,12 @@ const satoriRenderer = require("./satori_renderer");
1212
const imagePath = "/tmp/screenshot.jpg";
1313
const baseUrl = "https://exercism.org";
1414

15+
// The satori renderer fetches its data over the internal ALB rather than the
16+
// public site, so it never leaves the VPC and doesn't depend on this lambda's
17+
// NAT address being allowlisted in Cloudflare. The Chrome path still uses the
18+
// public site: it's screenshotting a rendered page, not reading a payload.
19+
const internalBaseUrl = process.env.INTERNAL_BASE_URL || "https://internal.exercism.org";
20+
1521
// Generating an image costs a few seconds of headless Chrome at 2GB, so we only
1622
// ever want to pay for it once per distinct URL. CDN edge caches can't give us
1723
// that on their own: they're per-PoP, they evict the long tail (most images are
@@ -47,6 +53,7 @@ function rawPathToScreenshotData(rawPath) {
4753
return {
4854
kind: "solution",
4955
url: `${baseUrl}/images/solutions/${track_slug}/${exercise_slug}/${user_handle}`,
56+
dataUrl: `${internalBaseUrl}/spi/solution_image_data/${track_slug}/${exercise_slug}/${user_handle}`,
5057
imageSelector: "#image-content",
5158
waitForSelector: "#image-content .c-code-pane",
5259
};

satori_renderer.js

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -290,21 +290,24 @@ function card(data) {
290290
);
291291
}
292292

293-
async function fetchData(pageUrl) {
294-
const url = `${pageUrl}/data`;
295-
const response = await fetch(url);
293+
async function fetchData(dataUrl) {
294+
const response = await fetch(dataUrl);
296295

297296
if (!response.ok) {
298-
throw new Error(`Fetching ${url} failed with ${response.status}`);
297+
throw new Error(`Fetching ${dataUrl} failed with ${response.status}`);
299298
}
300299

301300
return response.json();
302301
}
303302

304-
// Takes the same page URL the Chrome path would have navigated to, so the
305-
// rawPath -> URL mapping stays in one place.
306-
async function generate({ url }) {
307-
const data = await fetchData(url);
303+
// dataUrl points at the internal ALB, so this never leaves the VPC. Fetching
304+
// from the public site meant going out through Cloudflare and needing the NAT
305+
// address allowlisted to get back in - the coupling that left every image
306+
// timing out for four days when bot mitigation was turned on.
307+
//
308+
// Built in index.js so the rawPath -> URL mapping stays in one place.
309+
async function generate({ dataUrl }) {
310+
const data = await fetchData(dataUrl);
308311

309312
const svg = await satori(card(data), { width: WIDTH, fonts: fonts() });
310313
const png = new Resvg(svg, { fitTo: { mode: "width", value: WIDTH } }).render().asPng();

0 commit comments

Comments
 (0)