perfect-postcode/screenshot/src/validation.ts

114 lines
3 KiB
TypeScript

export class ValidationError extends Error {
status = 400;
}
export interface ValidatedScreenshotRequest {
pagePath: string;
qs: URLSearchParams;
}
const MAX_REPEATED_PARAMS = 40;
const MAX_VALUE_LENGTH = 500;
const NUMERIC_RE = /^-?(?:\d+|\d*\.\d+)$/;
const PATH_RE = /^\/(?:invite\/[A-Za-z0-9]{1,20})?$/;
const SAFE_VALUE_RE = /^[^\u0000-\u001f\u007f]+$/;
const REPEATED_KEYS = ['filter', 'school', 'crime', 'ethnicity', 'poi', 'tt'] as const;
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 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 qs = new URLSearchParams();
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);
}
for (const key of REPEATED_KEYS) {
for (const value of repeatedStrings(query, key)) {
assertSafeValue(key, value);
qs.append(key, value);
}
}
const pagePath = firstString(query, 'path') ?? '/';
if (!PATH_RE.test(pagePath)) {
validationError('path is invalid');
}
return { pagePath, qs };
}