This commit is contained in:
Andras Schmelczer 2026-05-14 22:07:14 +01:00
parent 084117cea8
commit a8de0a614d
36 changed files with 1329 additions and 522 deletions

View file

@ -24,12 +24,17 @@ if (!APP_URL) {
process.exit(1);
}
if (!CACHE_DIR) {
const CACHE_ENABLED = parseOptionalBoolEnv(
"SCREENSHOT_CACHE_ENABLED",
!isDevelopmentAppUrl(APP_URL),
);
if (CACHE_ENABLED && !CACHE_DIR) {
console.error("Error: CACHE_DIR environment variable is required");
process.exit(1);
}
const cache = new ScreenshotCache(CACHE_DIR);
const cache = CACHE_ENABLED ? new ScreenshotCache(CACHE_DIR as string) : null;
const app = express();
app.set("trust proxy", true);
@ -55,6 +60,28 @@ function parseRequiredPositiveIntEnv(name: string): number {
return value;
}
function parseOptionalBoolEnv(name: string, defaultValue: boolean): boolean {
const raw = process.env[name];
if (raw == null || raw === "") return defaultValue;
if (raw === "1" || raw.toLowerCase() === "true") return true;
if (raw === "0" || raw.toLowerCase() === "false") return false;
throw new Error(`${name} must be true or false`);
}
function isDevelopmentAppUrl(rawUrl: string): boolean {
try {
const url = new URL(rawUrl);
return (
url.hostname === "localhost" ||
url.hostname === "127.0.0.1" ||
url.hostname === "frontend" ||
url.port === "3001"
);
} catch {
return false;
}
}
function grantScreenshotSlot(): ReleaseScreenshotSlot {
activeScreenshots += 1;
let released = false;
@ -159,24 +186,26 @@ app.get("/screenshot", async (req, res) => {
const { pagePath, qs } = buildScreenshotRequest(
req.query as Record<string, unknown>,
);
if (pagePath !== "/") qs.set("path", pagePath);
// Include auth status in cache key so authenticated screenshots
// (with hexagons outside free zone) are cached separately
const authHeader = req.headers.authorization;
if (authHeader) qs.set("_auth", "1");
const cacheKey = cache.buildKey(qs);
qs.delete("_auth");
qs.delete("path");
// Check cache first
const cached = cache.get(cacheKey);
if (cached) {
res.setHeader("Content-Type", "image/jpeg");
res.setHeader("Cache-Control", "public, max-age=86400");
res.setHeader("X-Cache", "HIT");
cached.pipe(res);
return;
let cacheKey: string | null = null;
if (cache) {
const cacheParams = new URLSearchParams(qs);
if (pagePath !== "/") cacheParams.set("path", pagePath);
// Include auth status in cache key so authenticated screenshots
// (with hexagons outside free zone) are cached separately
if (authHeader) cacheParams.set("_auth", "1");
cacheKey = cache.buildKey(cacheParams);
// Check cache first
const cached = cache.get(cacheKey);
if (cached) {
res.setHeader("Content-Type", "image/jpeg");
res.setHeader("Cache-Control", "public, max-age=86400");
res.setHeader("X-Cache", "HIT");
cached.pipe(res);
return;
}
}
if (!allowScreenshotRequest(req)) {
@ -199,11 +228,11 @@ app.get("/screenshot", async (req, res) => {
const jpeg = await takeScreenshot(url, authHeader);
// Cache it
cache.set(cacheKey, jpeg);
if (cache && cacheKey) cache.set(cacheKey, jpeg);
res.setHeader("Content-Type", "image/jpeg");
res.setHeader("Cache-Control", "public, max-age=86400");
res.setHeader("X-Cache", "MISS");
res.setHeader("Cache-Control", cache ? "public, max-age=86400" : "no-store");
res.setHeader("X-Cache", cache ? "MISS" : "BYPASS");
res.send(jpeg);
} catch (err) {
if (err instanceof ValidationError) {
@ -220,7 +249,8 @@ app.get("/screenshot", async (req, res) => {
const server = app.listen(PORT, () => {
console.log(`Screenshot service listening on port ${PORT}`);
console.log(` APP_URL: ${APP_URL}`);
console.log(` CACHE_DIR: ${CACHE_DIR}`);
console.log(` CACHE_ENABLED: ${CACHE_ENABLED}`);
if (cache) console.log(` CACHE_DIR: ${CACHE_DIR}`);
console.log(` SCREENSHOT_CONCURRENCY: ${SCREENSHOT_CONCURRENCY}`);
console.log(
` SCREENSHOT_RATE_LIMIT: ${RATE_LIMIT_MAX}/${RATE_LIMIT_WINDOW_MS}ms`,

View file

@ -27,6 +27,7 @@ test('buildScreenshotRequest accepts supported screenshot parameters', () => {
tt: 'transit:kings-cross:Kings Cross:b:0:30',
share: 'abc123',
pc: 'SW1A 1AA',
lang: 'fr-CA',
});
assert.equal(result.pagePath, '/invite/abc123');
@ -60,6 +61,7 @@ test('buildScreenshotRequest accepts supported screenshot parameters', () => {
]);
assert.equal(result.qs.get('share'), 'abc123');
assert.equal(result.qs.get('pc'), 'SW1A 1AA');
assert.equal(result.qs.get('lang'), 'fr');
});
test('buildScreenshotRequest safely passes through future dashboard parameters', () => {
@ -110,3 +112,7 @@ test('buildScreenshotRequest rejects reserved screenshot service parameters', ()
test('buildScreenshotRequest rejects unsafe passthrough parameter names', () => {
assert.throws(() => buildScreenshotRequest({ 'filter[]': 'Feature:0:1' }), ValidationError);
});
test('buildScreenshotRequest rejects unsupported languages', () => {
assert.throws(() => buildScreenshotRequest({ og: '1', lang: 'es' }), ValidationError);
});

View file

@ -14,6 +14,7 @@ const NUMERIC_RE = /^-?(?:\d+|\d*\.\d+)$/;
const PATH_RE = /^\/(?:invite\/[A-Za-z0-9]{1,20})?$/;
const QUERY_KEY_RE = /^[A-Za-z][A-Za-z0-9_-]{0,63}$/;
const SAFE_VALUE_RE = /^[^\u0000-\u001f\u007f]+$/;
const SUPPORTED_LANGUAGES = new Set(['en', 'fr', 'de', 'zh', 'hi', 'hu']);
const REPEATED_KEYS = [
'filter',
'school',
@ -78,6 +79,16 @@ function assertSafeKey(key: string): void {
}
}
function toSupportedLanguage(value: string): string | null {
const lower = value.toLowerCase();
if (SUPPORTED_LANGUAGES.has(lower)) return lower;
const prefix = lower.split('-')[0];
if (SUPPORTED_LANGUAGES.has(prefix)) return prefix;
return null;
}
function appendSafeValues(qs: URLSearchParams, query: Query, key: string): void {
assertSafeKey(key);
for (const value of repeatedStrings(query, key)) {
@ -118,6 +129,7 @@ export function buildScreenshotRequest(query: Query): ValidatedScreenshotRequest
'zoom',
'tab',
'og',
'lang',
'path',
...PASSTHROUGH_SINGLE_KEYS,
...REPEATED_KEYS,
@ -143,6 +155,16 @@ export function buildScreenshotRequest(query: Query): ValidatedScreenshotRequest
qs.set('og', og);
}
const lang = firstString(query, 'lang');
if (lang != null) {
assertSafeValue('lang', lang);
const supported = toSupportedLanguage(lang);
if (!supported) {
validationError('lang is invalid');
}
qs.set('lang', supported);
}
for (const key of PASSTHROUGH_SINGLE_KEYS) {
const value = firstString(query, key);
if (value == null) continue;