export class ValidationError extends Error { status = 400; } export interface ValidatedScreenshotRequest { pagePath: string; qs: URLSearchParams; } const MAX_REPEATED_PARAMS = 40; const MAX_QUERY_KEYS = 80; const MAX_VALUE_LENGTH = 500; 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', 'crime', 'voteShare', 'ethnicity', 'amenityDistance', 'transportDistance', 'amenityCount2km', 'amenityCount5km', 'poi', 'overlay', 'tt', ] as const; const PASSTHROUGH_SINGLE_KEYS = ['share', 'pc'] as const; const RESERVED_QUERY_KEYS = new Set(['path', 'screenshot', '_auth']); type Query = Record; function validationError(message: string): never { throw new ValidationError(message); } function firstString(query: Query, key: string): string | undefined { const value = query[key]; if (value == null) return undefined; if (Array.isArray(value)) { validationError(`${key} must not be repeated`); } if (typeof value !== 'string') { validationError(`${key} must be a string`); } return value || undefined; } function repeatedStrings(query: Query, key: string): string[] { const value = query[key]; if (value == null) return []; const values = Array.isArray(value) ? value : [value]; if (values.length > MAX_REPEATED_PARAMS) { validationError(`${key} has too many values`); } return values.filter((item): item is string => { if (typeof item !== 'string') { validationError(`${key} values must be strings`); } return item.length > 0; }); } function assertSafeValue(key: string, value: string): void { if (value.length > MAX_VALUE_LENGTH) { validationError(`${key} is too long`); } if (!SAFE_VALUE_RE.test(value)) { validationError(`${key} contains invalid characters`); } } function assertSafeKey(key: string): void { if (!QUERY_KEY_RE.test(key)) { validationError(`${key} is invalid`); } } 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)) { assertSafeValue(key, value); qs.append(key, value); } } function appendBoundedNumber( qs: URLSearchParams, query: Query, key: string, min: number, max: number, ): void { const value = firstString(query, key); if (value == null) return; if (value.length > 40 || !NUMERIC_RE.test(value)) { validationError(`${key} must be a number`); } const numeric = Number(value); if (!Number.isFinite(numeric) || numeric < min || numeric > max) { validationError(`${key} is out of range`); } qs.set(key, value); } export function buildScreenshotRequest(query: Query): ValidatedScreenshotRequest { const queryKeys = Object.keys(query); if (queryKeys.length > MAX_QUERY_KEYS) { validationError('query has too many parameters'); } const qs = new URLSearchParams(); const handledKeys = new Set([ 'lat', 'lon', 'zoom', 'tab', 'og', 'lang', 'path', ...PASSTHROUGH_SINGLE_KEYS, ...REPEATED_KEYS, ]); appendBoundedNumber(qs, query, 'lat', -90, 90); appendBoundedNumber(qs, query, 'lon', -180, 180); appendBoundedNumber(qs, query, 'zoom', 0, 22); const tab = firstString(query, 'tab'); if (tab != null) { if (tab !== 'area' && tab !== 'properties') { validationError('tab is invalid'); } qs.set('tab', tab); } const og = firstString(query, 'og'); if (og != null) { if (og !== '1') { validationError('og is invalid'); } 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; assertSafeValue(key, value); qs.set(key, value); } for (const key of REPEATED_KEYS) { appendSafeValues(qs, query, key); } for (const key of queryKeys) { if (handledKeys.has(key)) continue; if (RESERVED_QUERY_KEYS.has(key)) { validationError(`${key} is reserved`); } appendSafeValues(qs, query, key); } const pagePath = firstString(query, 'path') ?? '/'; if (!PATH_RE.test(pagePath)) { validationError('path is invalid'); } return { pagePath, qs }; }