Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,276 @@
import strings from '../../../resources';
import type { OcParameter } from '../../../types';

export interface ValidationResult {
isValid: boolean;
errors: {
mandatory: Record<string, string>;
types: Record<string, string>;
message: string;
};
}

export interface CompiledParameterSchema {
isEmpty: boolean;
defaults: Array<[string, string | number | boolean]>;
mandatory: string[];
types: Record<string, string>;
coercions: Record<string, 'boolean' | 'number' | 'string'>;
enums: Record<string, ReadonlyArray<string | number | boolean>>;
}

const emptyCompiledSchema: CompiledParameterSchema = {
isEmpty: true,
defaults: [],
mandatory: [],
types: Object.create(null),
coercions: Object.create(null),
enums: Object.create(null)
};

export function compileParameterSchema(
expectedParameters?: Record<string, OcParameter> | null
): CompiledParameterSchema {
if (
!expectedParameters ||
typeof expectedParameters !== 'object' ||
Array.isArray(expectedParameters)
) {
return emptyCompiledSchema;
}

const keys = Object.keys(expectedParameters);
if (keys.length === 0) {
return emptyCompiledSchema;
}

const defaults: Array<[string, string | number | boolean]> = [];
const mandatory: string[] = [];
const types: Record<string, string> = Object.create(null);
const coercions: Record<string, 'boolean' | 'number' | 'string'> =
Object.create(null);
const enums: Record<
string,
ReadonlyArray<string | number | boolean>
> = Object.create(null);

for (const name in expectedParameters) {
if (!Object.hasOwn(expectedParameters, name)) {
continue;
}
const param = (expectedParameters as Record<string, OcParameter>)[name];
if (!param || typeof param !== 'object') {
throw new TypeError(`Invalid parameter schema for "${name}"`);
}

const rawType = (param as OcParameter).type as unknown as string;
const normalizedType =
typeof rawType === 'string' ? rawType.toLowerCase() : '';
types[name] = normalizedType;
if (rawType === 'boolean' || rawType === 'number' || rawType === 'string') {
coercions[name] = rawType;
}

const enumValues = (param as OcParameter).enum;
if (typeof enumValues !== 'undefined') {
enums[name] = enumValues as ReadonlyArray<string | number | boolean>;
}

if (param.mandatory) {
mandatory.push(name);
} else if (typeof param.default !== 'undefined') {
defaults.push([name, param.default as string | number | boolean]);
}
}

return {
isEmpty: false,
defaults,
mandatory,
types,
coercions,
enums
};
}

export function processParameters(
requestParameters:
| Record<string, string | number | boolean>
| null
| undefined,
compiled: CompiledParameterSchema
): {
params: Record<string, string | number | boolean>;
validation: ValidationResult;
} {
if (compiled.isEmpty) {
const source = requestParameters as
| Record<string, string | number | boolean>
| null
| undefined;
if (!source || typeof source !== 'object') {
return {
params: {},
validation: {
isValid: true,
errors: { mandatory: {}, types: {}, message: '' }
}
};
}
const params: Record<string, string | number | boolean> = {};
for (const key in source) {
if (!Object.hasOwn(source, key)) {
continue;
}
if (key === '__ocAcceptLanguage') {
continue;
}
params[key] = (source as Record<string, any>)[key];
}
return {
params,
validation: {
isValid: true,
errors: { mandatory: {}, types: {}, message: '' }
}
};
}

let source: Record<string, any>;
if (requestParameters == null || typeof requestParameters !== 'object') {
source = {};
} else {
source = requestParameters as Record<string, any>;
}

const defaults = compiled.defaults;
for (let i = 0; i < defaults.length; i++) {
const entry = defaults[i]!;
const key = entry[0];
const defVal = entry[1];
const cur = source[key];
if (cur === null || cur === undefined) {
source[key] = defVal;
}
}

const params: Record<string, string | number | boolean> = {};
let mandatoryErrors: Record<string, string> | null = null;
let typeErrors: Record<string, string> | null = null;
let isValid = true;

const types = compiled.types;
const coercions = compiled.coercions;
const enums = compiled.enums;

for (const key in source) {
if (!Object.hasOwn(source, key)) {
continue;
}
const isDeclared = Object.hasOwn(types, key);
if (isDeclared) {
const rawVal = source[key] as string | number | boolean;
const type = types[key]!;
const coercion = coercions[key];
let sanitised: string | number | boolean;

if (coercion === 'boolean') {
if (typeof rawVal === 'string') {
if (rawVal === 'true') {
sanitised = true;
} else if (rawVal === 'false') {
sanitised = false;
} else {
sanitised = rawVal;
}
} else {
sanitised = rawVal;
}
} else if (coercion === 'number') {
sanitised = Number(rawVal as any);
} else if (coercion === 'string') {
sanitised = rawVal == null ? '' : (rawVal as any);
} else {
sanitised = rawVal;
}

params[key] = sanitised as string | number | boolean;

let typeValid: boolean;
if (type === 'boolean') {
typeValid = typeof sanitised === 'boolean';
} else if (type === 'number') {
typeValid = typeof sanitised === 'number';
} else if (type === 'string') {
typeValid = typeof sanitised === 'string';
} else {
typeValid = false;
}

if (!typeValid) {
isValid = false;
if (!typeErrors) {
typeErrors = {};
}
typeErrors[key] = strings.errors.registry.PARAMETER_WRONG_FORMAT_CODE;
continue;
}

const enumVals = enums[key];
if (enumVals && !(enumVals as any).includes(sanitised)) {
isValid = false;
if (!typeErrors) {
typeErrors = {};
}
typeErrors[key] = strings.errors.registry.PARAMETER_WRONG_VALUE(
key,
enumVals as unknown as string[]
);
}
} else {
if (key === '__ocAcceptLanguage') {
continue;
}
params[key] = source[key] as string | number | boolean;
}
}

const mandatory = compiled.mandatory;
for (let i = 0; i < mandatory.length; i++) {
const name = mandatory[i]!;
if (!(name in params)) {
isValid = false;
if (!mandatoryErrors) {
mandatoryErrors = {};
}
mandatoryErrors[name] =
strings.errors.registry.MANDATORY_PARAMETER_MISSING_CODE;
}
}

let message = '';
if (mandatoryErrors) {
const keys = Object.keys(mandatoryErrors);
const joined = keys.join(', ');
message += strings.errors.registry.MANDATORY_PARAMETER_MISSING(joined);
}
if (typeErrors) {
if (message.length > 0) {
message += '; ';
}
const keys = Object.keys(typeErrors);
const joined = keys.join(', ');
message += strings.errors.registry.PARAMETER_WRONG_FORMAT(joined);
}

const validation: ValidationResult = {
isValid,
errors: {
mandatory: mandatoryErrors || {},
types: typeErrors || {},
message
}
};

return { params, validation };
}
60 changes: 46 additions & 14 deletions packages/oc/src/registry/routes/helpers/get-component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,19 +8,27 @@ import emptyResponseHandler from 'oc-empty-response-handler';
import { fromPromise } from 'universalify';
import strings from '../../../resources';
import settings from '../../../resources/settings';
import type { Component, Config, PluginContext, Plugins } from '../../../types';
import type {
Component,
Config,
OcParameter,
PluginContext,
Plugins
} from '../../../types';
import BoundedCache from '../../../utils/bounded-cache';
import isTemplateLegacy from '../../../utils/is-template-legacy';
import eventsHandler from '../../domain/events-handler';
import type { CookieOptions } from '../../domain/http-server/types';
import NestedRenderer from '../../domain/nested-renderer';
import type { Repository } from '../../domain/repository';
import RequireWrapper from '../../domain/require-wrapper';
import * as sanitiser from '../../domain/sanitiser';
import * as urlBuilder from '../../domain/url-builder';
import * as validator from '../../domain/validators';
import { validateTemplateOcVersion } from '../../domain/validators';
import applyDefaultValues from './apply-default-values';
import {
compileParameterSchema,
processParameters
} from './component-parameter-processor';
import { processStackTrace } from './format-error-stack';
import * as getComponentFallback from './get-component-fallback';

Expand Down Expand Up @@ -169,6 +177,32 @@ export default function getComponent(
return names;
};
const inFlight = new Map<string, Promise<unknown>>();
const parameterSchemaCache = new WeakMap<
object,
ReturnType<typeof compileParameterSchema>
>();
const emptyParameterSchema = compileParameterSchema(undefined);
const getCompiledParameterSchema = (
expectedParameters: Record<string, OcParameter> | undefined
) => {
if (!expectedParameters || typeof expectedParameters !== 'object') {
return emptyParameterSchema;
}
let compiled = parameterSchemaCache.get(expectedParameters as object);
if (compiled) {
return compiled;
}
if (Object.keys(expectedParameters).length === 0) {
parameterSchemaCache.set(
expectedParameters as object,
emptyParameterSchema
);
return emptyParameterSchema;
}
compiled = compileParameterSchema(expectedParameters);
parameterSchemaCache.set(expectedParameters as object, compiled);
return compiled;
};

const singleFlight = <T>(key: string, operation: () => Promise<T>) => {
const pending = inFlight.get(key) as Promise<T> | undefined;
Expand Down Expand Up @@ -366,18 +400,16 @@ export default function getComponent(
});
}

// sanitise and check params
const appliedParams = applyDefaultValues(
requestedComponent.parameters,
component.oc.parameters
);
const params = sanitiser.sanitiseComponentParameters(
appliedParams,
component.oc.parameters
// sanitise and check params (compiled schema + single scan)
const compiledParameterSchema = getCompiledParameterSchema(
component.oc.parameters as unknown as Record<string, OcParameter>
);
const validationResult = validator.validateComponentParameters(
params,
component.oc.parameters
const { params, validation: validationResult } = processParameters(
requestedComponent.parameters as unknown as Record<
string,
string | number | boolean
>,
compiledParameterSchema
);

if (!options.action && !validationResult.isValid) {
Expand Down
Loading
Loading