clone

a browser-first diagram editor with live svg previews, local drafts and encrypted, accountless sharing

main
diagramzip / renderers / edge / src / runtime / validation.ts
validation.ts 6.0K
1import { RequestError } from './errors'2import { isEngineId, type EngineId, type RenderMetadata, type RenderPresentation, type RenderRequest } from './types'34const MAX_REQUEST_BYTES = 1_048_5765const MAX_SOURCE_LENGTH = 524_2886const MAX_OPTION_COUNT = 6478function objectValue(value: unknown, name: string): Record<string, unknown> {9 if (typeof value !== 'object' || value === null || Array.isArray(value)) {10 throw new RequestError(400, 'invalid_request', `${name} must be an object.`)11 }12 return value as Record<string, unknown>13}1415function boundedString(value: unknown, name: string, maximum: number): string {16 if (typeof value !== 'string') throw new RequestError(400, 'invalid_request', `${name} must be a string.`)17 if (value.length > maximum) throw new RequestError(413, 'request_too_large', `${name} is too large.`)18 return value19}2021async function boundedBody(request: Request): Promise<string> {22 const declaredLength = request.headers.get('Content-Length')23 if (declaredLength !== null) {24 const parsedLength = Number(declaredLength)25 if (!Number.isInteger(parsedLength) || parsedLength < 0) {26 throw new RequestError(400, 'invalid_request', 'Content-Length is invalid.')27 }28 if (parsedLength > MAX_REQUEST_BYTES) {29 throw new RequestError(413, 'request_too_large', 'Render request is too large.')30 }31 }3233 if (request.body === null) throw new RequestError(400, 'invalid_request', 'A JSON request body is required.')34 const reader = request.body.getReader()35 const chunks: Uint8Array[] = []36 let length = 037 while (true) {38 const { done, value } = await reader.read()39 if (done) break40 length += value.byteLength41 if (length > MAX_REQUEST_BYTES) {42 await reader.cancel('request too large')43 throw new RequestError(413, 'request_too_large', 'Render request is too large.')44 }45 chunks.push(value)46 }47 const bytes = new Uint8Array(length)48 let offset = 049 for (const chunk of chunks) {50 bytes.set(chunk, offset)51 offset += chunk.byteLength52 }53 return new TextDecoder().decode(bytes)54}5556function optionsValue(value: unknown): Record<string, string> {57 const input = value === undefined ? {} : objectValue(value, 'options')58 const entries = Object.entries(input)59 if (entries.length > MAX_OPTION_COUNT) {60 throw new RequestError(400, 'invalid_options', `options cannot contain more than ${MAX_OPTION_COUNT} entries.`)61 }62 const output: Record<string, string> = {}63 for (const [name, option] of entries) {64 if (!/^[a-z0-9][a-z0-9_-]{0,63}$/i.test(name)) {65 throw new RequestError(400, 'invalid_options', `Invalid renderer option: ${name}.`)66 }67 if (!['string', 'number', 'boolean'].includes(typeof option)) {68 throw new RequestError(400, 'invalid_options', `Renderer option ${name} must be a string, number, or boolean.`)69 }70 const normalized = String(option)71 if (normalized.length > 4_096) {72 throw new RequestError(400, 'invalid_options', `Renderer option ${name} is too large.`)73 }74 output[name.toLowerCase()] = normalized75 }76 return output77}7879function metadataValue(value: unknown): RenderMetadata {80 const input = value === undefined ? {} : objectValue(value, 'metadata')81 return {82 title: boundedString(input.title ?? '', 'metadata.title', 200),83 description: boundedString(input.description ?? '', 'metadata.description', 2_000),84 }85}8687function presentationValue(value: unknown): RenderPresentation {88 const input = value === undefined ? {} : objectValue(value, 'presentation')89 const background = boundedString(input.background ?? '', 'presentation.background', 7)90 const padding = input.padding ?? 091 const frame = input.frame ?? false92 if (background !== '' && !/^#[0-9a-f]{6}$/i.test(background)) {93 throw new RequestError(400, 'invalid_presentation', 'presentation.background must be an RGB hex color.')94 }95 if (!Number.isInteger(padding) || Number(padding) < 0 || Number(padding) > 256) {96 throw new RequestError(400, 'invalid_presentation', 'presentation.padding must be an integer from 0 to 256.')97 }98 if (typeof frame !== 'boolean') {99 throw new RequestError(400, 'invalid_presentation', 'presentation.frame must be a boolean.')100 }101 return { background, padding: Number(padding), frame }102}103104async function renderInput(request: Request): Promise<Record<string, unknown>> {105 if (!request.headers.get('Content-Type')?.toLowerCase().startsWith('application/json')) {106 throw new RequestError(415, 'unsupported_media_type', 'Render requests must use application/json.')107 }108 let value: unknown109 try {110 value = JSON.parse(await boundedBody(request))111 } catch (error) {112 if (error instanceof RequestError) throw error113 throw new RequestError(400, 'invalid_json', 'Render request is not valid JSON.')114 }115 return objectValue(value, 'request')116}117118function parsedRequest(input: Record<string, unknown>, engine: EngineId): RenderRequest {119 const source = boundedString(input.source, 'source', MAX_SOURCE_LENGTH)120 if (source.trim() === '') throw new RequestError(400, 'empty_source', 'Diagram source cannot be empty.')121 if (input.format !== undefined && input.format !== 'svg') {122 throw new RequestError(400, 'unsupported_format', 'The v2 rendering plane currently supports SVG only.')123 }124 return {125 engine,126 source,127 format: 'svg',128 options: optionsValue(input.options),129 metadata: metadataValue(input.metadata),130 presentation: presentationValue(input.presentation),131 }132}133134export async function parseRenderRequest(request: Request): Promise<RenderRequest> {135 const input = await renderInput(request)136 const engine = boundedString(input.engine, 'engine', 32)137 if (!isEngineId(engine)) throw new RequestError(400, 'unknown_engine', `Unknown diagram engine: ${engine}.`)138 return parsedRequest(input, engine)139}140141export async function parseUnitRenderRequest(request: Request, engine: EngineId): Promise<RenderRequest> {142 const input = await renderInput(request)143 if (input.engine !== undefined) {144 throw new RequestError(400, 'invalid_request', 'Renderer unit requests must not select an engine.')145 }146 return parsedRequest(input, engine)147}