178 lines
4.8 KiB
TypeScript
178 lines
4.8 KiB
TypeScript
import type { IncomingMessage } from 'node:http';
|
|
|
|
export const MAX_CURSOR_LENGTH = 1_024;
|
|
export const COLLECTION_PATTERN = /^[a-z][a-z0-9_-]{0,47}$/;
|
|
export const DOCUMENT_ID_PATTERN = /^[A-Za-z0-9._~-]{1,128}$/;
|
|
|
|
export type ParserFailure = (
|
|
status: number,
|
|
code: string,
|
|
message: string,
|
|
) => never;
|
|
|
|
export type ParserMessages = Readonly<{
|
|
invalidRequest: string;
|
|
invalidRevision: string;
|
|
requestTooLarge: string;
|
|
}>;
|
|
|
|
export function isRecord(value: unknown): value is Record<string, unknown> {
|
|
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
}
|
|
|
|
export function boundedString(value: unknown, maximum: number): string | null {
|
|
if (typeof value !== 'string') return null;
|
|
return value && value.length <= maximum ? value : null;
|
|
}
|
|
|
|
export async function readBoundedJson(
|
|
req: IncomingMessage,
|
|
maxBytes: number,
|
|
fail: ParserFailure,
|
|
messages: ParserMessages,
|
|
): Promise<Record<string, unknown>> {
|
|
const declared = req.headers['content-length'];
|
|
const declaredValue = Array.isArray(declared) ? declared[0] : declared;
|
|
if (declaredValue && /^\d+$/.test(declaredValue) && Number(declaredValue) > maxBytes) {
|
|
return fail(413, 'request_too_large', messages.requestTooLarge);
|
|
}
|
|
const chunks: Buffer[] = [];
|
|
let size = 0;
|
|
for await (const chunk of req) {
|
|
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
size += buffer.byteLength;
|
|
if (size > maxBytes) {
|
|
return fail(413, 'request_too_large', messages.requestTooLarge);
|
|
}
|
|
chunks.push(buffer);
|
|
}
|
|
try {
|
|
const value = JSON.parse(Buffer.concat(chunks).toString('utf8')) as unknown;
|
|
if (!isRecord(value)) throw new Error('object required');
|
|
return value;
|
|
} catch {
|
|
return fail(422, 'invalid_request', messages.invalidRequest);
|
|
}
|
|
}
|
|
|
|
export function requireExactKeys(
|
|
value: Record<string, unknown>,
|
|
keys: readonly string[],
|
|
fail: ParserFailure,
|
|
message: string,
|
|
): void {
|
|
const expected = new Set(keys);
|
|
if (Object.keys(value).some((key) => !expected.has(key))
|
|
|| keys.some((key) => !Object.prototype.hasOwnProperty.call(value, key))) {
|
|
fail(422, 'invalid_request', message);
|
|
}
|
|
}
|
|
|
|
function decodeSegment(
|
|
value: string,
|
|
maximum: number,
|
|
pattern: RegExp,
|
|
fail: ParserFailure,
|
|
message: string,
|
|
): string {
|
|
let decoded: string;
|
|
try {
|
|
decoded = decodeURIComponent(value);
|
|
} catch {
|
|
return fail(422, 'invalid_request', message);
|
|
}
|
|
if (decoded.length > maximum || !pattern.test(decoded)) {
|
|
return fail(422, 'invalid_request', message);
|
|
}
|
|
return decoded;
|
|
}
|
|
|
|
export function parseCollection(
|
|
value: string,
|
|
fail: ParserFailure,
|
|
message: string,
|
|
): string {
|
|
return decodeSegment(value, 48, COLLECTION_PATTERN, fail, message);
|
|
}
|
|
|
|
export function parseDocumentId(
|
|
value: string,
|
|
fail: ParserFailure,
|
|
message: string,
|
|
): string {
|
|
const documentId = decodeSegment(value, 128, DOCUMENT_ID_PATTERN, fail, message);
|
|
if (documentId === '.' || documentId === '..') {
|
|
return fail(422, 'invalid_request', message);
|
|
}
|
|
return documentId;
|
|
}
|
|
|
|
export function requireQueryKeys(
|
|
url: URL,
|
|
allowed: readonly string[],
|
|
fail: ParserFailure,
|
|
message: string,
|
|
): void {
|
|
const accepted = new Set(allowed);
|
|
if ([...url.searchParams.keys()].some((key) => !accepted.has(key))) {
|
|
fail(422, 'invalid_request', message);
|
|
}
|
|
}
|
|
|
|
export function parseLimit(
|
|
url: URL,
|
|
fail: ParserFailure,
|
|
message: string,
|
|
): number | undefined {
|
|
const values = url.searchParams.getAll('limit');
|
|
if (values.length === 0) return undefined;
|
|
if (values.length !== 1 || !/^[1-9]\d*$/.test(values[0])) {
|
|
return fail(422, 'invalid_request', message);
|
|
}
|
|
const limit = Number(values[0]);
|
|
if (!Number.isSafeInteger(limit) || limit < 1 || limit > 100) {
|
|
return fail(422, 'invalid_request', message);
|
|
}
|
|
return limit;
|
|
}
|
|
|
|
export function parseCursor(
|
|
url: URL,
|
|
fail: ParserFailure,
|
|
message: string,
|
|
): string | undefined {
|
|
const values = url.searchParams.getAll('cursor');
|
|
if (values.length === 0) return undefined;
|
|
const cursor = values.length === 1 ? boundedString(values[0], MAX_CURSOR_LENGTH) : null;
|
|
if (!cursor) return fail(422, 'invalid_request', message);
|
|
return cursor;
|
|
}
|
|
|
|
export function parseIfMatch(
|
|
req: IncomingMessage,
|
|
fail: ParserFailure,
|
|
message: string,
|
|
): number | undefined {
|
|
const value = req.headers['if-match'];
|
|
if (value === undefined) return undefined;
|
|
if (Array.isArray(value) || !/^"[1-9]\d*"$/.test(value)) {
|
|
return fail(422, 'invalid_revision', message);
|
|
}
|
|
const revision = Number(value.slice(1, -1));
|
|
if (!Number.isSafeInteger(revision) || revision < 1) {
|
|
return fail(422, 'invalid_revision', message);
|
|
}
|
|
return revision;
|
|
}
|
|
|
|
export function parseData(
|
|
value: unknown,
|
|
fail: ParserFailure,
|
|
message: string,
|
|
): Record<string, unknown> {
|
|
if (!isRecord(value)) {
|
|
return fail(422, 'invalid_request', message);
|
|
}
|
|
return value;
|
|
}
|