194 lines
4.9 KiB
TypeScript
194 lines
4.9 KiB
TypeScript
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<string, unknown>;
|
|
|
|
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<string>([
|
|
'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 };
|
|
}
|