@appsemble/node-utils
Version:
NodeJS utilities used by Appsemble internally.
425 lines • 17.8 kB
JavaScript
import { randomUUID } from 'node:crypto';
import { mapValues } from '@appsemble/utils';
import { addMilliseconds, isPast, parseISO } from 'date-fns';
import { ValidationError, Validator } from 'jsonschema';
import { partition } from 'lodash-es';
import parseDuration from 'parse-duration';
import { preProcessCSV } from './csv.js';
import { handleValidatorResult, TempFile, } from './index.js';
import { throwKoaError } from './koa.js';
import { AssetUploadValidationError, validateUploadedFile } from './uploadValidation.js';
export function stripResource({ $author, $created, $editor, $ephemeral, $etag, $group, $seed, $updated, ...data }) {
return data;
}
/**
* Works on a resource, which has been processed on the server by the streamParser
*
* @param data The resource to be serialized, optionally containing parsed TempFile instance assets
* @returns An object containing the resource and an array of the assets referenced
* from the resource
*/
export function serializeServerResource(data) {
const assets = [];
const extractAssets = (value) => {
if (Array.isArray(value)) {
return value.map(extractAssets);
}
if (value instanceof TempFile) {
return String(assets.push(value) - 1);
}
if (value instanceof Date) {
return value.toJSON();
}
if (value && typeof value === 'object') {
return mapValues(value, extractAssets);
}
return value;
};
const resource = extractAssets(data);
if (!assets.length) {
return resource;
}
return {
resource,
assets,
};
}
export function isSerializedMultipartBody(value) {
return (value != null &&
typeof value === 'object' &&
!Array.isArray(value) &&
'resource' in value &&
'assets' in value &&
Array.isArray(value.assets));
}
/**
* Get the resource definition of an app by name.
*
* If there is no match, a 404 HTTP error is thrown.
*
* @param appDefinition The app definition to get the resource definition of
* @param resourceType The name of the resource definition to get.
* @param ctx Context used to throw back the errors.
* @param view The view that’s being used.
* @returns The matching resource definition.
*/
export function getResourceDefinition(appDefinition, resourceType, ctx, view) {
if (!appDefinition) {
if (ctx === undefined) {
throw new Error('App not found');
}
else {
throwKoaError(ctx, 404, 'App not found');
}
}
const definition = appDefinition.resources?.[resourceType];
if (!definition) {
if (ctx === undefined || ctx.response === undefined) {
throw new Error(`App does not have resources called ${resourceType}`);
}
else {
throwKoaError(ctx, 404, `App does not have resources called ${resourceType}`);
}
}
if (view && !definition.views?.[view]) {
if (ctx === undefined) {
throw new Error(`View ${view} does not exist for resource type ${resourceType}`);
}
else {
throwKoaError(ctx, 404, `View ${view} does not exist for resource type ${resourceType}`);
}
}
return definition;
}
/**
* Extracts the IDs of resource request body.
*
* @param ctx The Koa context to extract the body from.
* @param suppliedBody A resource body to use directly without reading from context.
* Supports multipart-style `{ resource, assets }` input.
* @returns A tuple which consists of:
*
* 1. One or more resources processed from the request body.
* 2. A list of newly uploaded assets which should be linked to the resources.
* 3. preValidateProperty function used for reconstructing resources from a CSV file.
*/
export function extractResourceBody(ctx, suppliedBody) {
let body;
let assets = [];
let preValidateProperty;
if (suppliedBody !== undefined) {
if (isSerializedMultipartBody(suppliedBody)) {
assets = suppliedBody.assets;
body = suppliedBody.resource;
if (Array.isArray(body) && body.length === 1) {
[body] = body;
}
}
else {
body = suppliedBody;
}
}
else if (ctx?.request?.body && ctx.is('multipart/form-data')) {
({ assets = [], resource: body } = ctx.request.body);
if (Array.isArray(body) && body.length === 1) {
[body] = body;
}
}
else if (ctx?.request?.body) {
if (ctx.is('text/csv')) {
preValidateProperty = preProcessCSV;
}
({ body } = ctx.request);
}
else if (ctx) {
({ body } = ctx);
}
else {
throw new Error('No resource body provided.');
}
return [
Array.isArray(body) ? body.map(stripResource) : stripResource(body),
assets,
preValidateProperty,
];
}
// XXX: unify with webhooks logic
/**
* Process an incoming resource request body.
*
* This handles JSON schema validation, resource expiration, and asset linking and validation.
*
* @param ctx The Koa context to process.
* @param definition The resource definition to use for processing the request body.
* @param knownAssetIds A list of asset IDs that are already known to be linked to the resource.
* @param knownExpires A previously known expires value.
* @param knownAssetNameIds A list of asset ids with asset names that already exist.
* @param [isPatch] if the "PATCH" HTTP method is being used.
* @param resourceBody Used to process resources from non-request contexts.
* @returns A tuple which consists of:
*
* 1. One or more resources processed from the request body.
* 2. A list of newly uploaded assets which should be linked to the resources.
* 3. Asset IDs from the `knownAssetIds` array which are no longer used.
*/
export async function processResourceBody(ctx, definition, knownAssetIds = [], knownExpires, knownAssetNameIds = [], isPatch = false, resourceBody) {
const [resource, assets, preValidateProperty] = extractResourceBody(ctx, resourceBody);
const validator = new Validator();
const reusedAssets = new Set();
const usedAssetIndices = new Set();
const validatedAssetMimes = new Map();
const customErrors = [];
for (const [index, asset] of assets.entries()) {
try {
validatedAssetMimes.set(index, await validateUploadedFile(asset));
}
catch (error) {
if (!(error instanceof AssetUploadValidationError)) {
throw error;
}
customErrors.push(new ValidationError(error.message, index, undefined, ['assets', index], 'binary', 'content'));
}
}
const thumbnailAssetSuffix = '-thumbnail.png';
const [thumbnailAssets, regularAssets] = partition(
// Preserve original index to handle asset references in data
assets.map((asset, index) => ({ asset, index })), ({ asset }) => asset.filename?.endsWith(thumbnailAssetSuffix));
const preparedRegularAssets = regularAssets.map(({ asset: { filename, mime, path }, index }) => ({
index,
asset: {
id: randomUUID(),
filename,
mime: validatedAssetMimes.get(index) ?? mime,
path,
},
}));
// Maps 1 unique thumbnail asset to 1 unique regular asset (without other thumbnail assets
// pointing to it), or a random UUID if no match is found
// eslint-disable-next-line unicorn/no-array-reduce
const { prepared: preparedThumbnailAssets } = thumbnailAssets.reduce(({ prepared, used }, { asset, index }) => {
const reg = preparedRegularAssets.find((regular) => regular.asset.filename?.startsWith(asset.filename.replace(thumbnailAssetSuffix, '')) &&
!used.includes(regular.asset.id));
const id = reg ? `${reg.asset.id}-thumbnail` : randomUUID();
return {
prepared: [...prepared, { index, asset: { ...asset, id } }],
used: used.concat(reg?.asset.id ?? []),
};
}, { used: [], prepared: [] });
const assetIndexIdMap = new Map();
for (const { asset, index } of preparedRegularAssets) {
assetIndexIdMap.set(index, asset.id);
}
for (const { asset, index } of preparedThumbnailAssets) {
assetIndexIdMap.set(index, asset.id);
}
const preparedAssets = [...preparedRegularAssets, ...preparedThumbnailAssets].map(({ asset }) => asset);
validator.customFormats.binary = (input) => {
if (knownAssetIds.includes(input)) {
reusedAssets.add(input);
return true;
}
const assetNameId = knownAssetNameIds.find((idName) => idName.name === input);
if (assetNameId) {
reusedAssets.add(assetNameId.id);
return true;
}
if (!/^\d+$/.test(input)) {
return false;
}
const num = Number(input);
if (usedAssetIndices.has(num)) {
return false;
}
return num >= 0 && num < assets.length;
};
const isPatchRequest = ctx.request?.method === 'PATCH' || isPatch;
const patchedSchema = {
...definition.schema,
required: isPatchRequest ? [] : definition.schema.required,
properties: {
...definition.schema.properties,
id: { type: 'integer' },
$expires: {
anyOf: [
{ type: 'string', format: 'date-time' },
{
type: 'string',
pattern:
// TODO: This pattern is duplicated at least 5 times across the codebase
/^(\d+(y|yr|years))?\s*(\d+months)?\s*(\d+(w|wk|weeks))?\s*(\d+(d|days))?\s*(\d+(h|hr|hours))?\s*(\d+(m|min|minutes))?\s*(\d+(s|sec|seconds))?$/
.source,
},
],
},
$clonable: { type: 'boolean' },
$thumbnails: {
type: 'array',
items: { type: 'string', format: 'binary' },
},
...Object.fromEntries(Object.values(definition.references ?? {}).map((reference) => [
`$${reference.resource}`,
{ type: 'integer' },
])),
},
};
const expiresDuration = definition.expires ? parseDuration(definition.expires) : undefined;
const result = validator.validate(resource, Array.isArray(resource) ? { type: 'array', items: patchedSchema } : patchedSchema, {
base: '#',
preValidateProperty,
nestedErrors: true,
rewrite(value, propertySchema, options, { path }) {
const { format, oneOf } = propertySchema;
let propertyName;
if (Array.isArray(resource) && path.length === 2 && typeof path[0] === 'number') {
propertyName = path[1];
}
else if (path.length === 1) {
propertyName = path[0];
}
if (propertyName === '$thumbnails') {
return;
}
if (propertyName === '$expires') {
if (value !== undefined) {
const date = parseISO(value);
if (Number.isNaN(date.getTime())) {
// @ts-expect-error 2345 argument of type is not assignable to parameter of type
// (strictNullChecks) - Severe
return addMilliseconds(new Date(), parseDuration(value));
}
if (isPast(date) &&
!customErrors.some((error) => error.message === 'has already passed' && error.path === path)) {
customErrors.push(new ValidationError('has already passed', value, undefined, path));
}
return date;
}
if (knownExpires) {
return knownExpires;
}
if (expiresDuration) {
return addMilliseconds(new Date(), expiresDuration);
}
}
if (value === undefined) {
if (propertyName === '$clonable') {
return definition.clonable;
}
// Materialize schema property defaults for missing values. Patch requests skip this,
// because a missing property there means the stored value is kept.
const defaultValue = propertySchema.default;
if (!isPatchRequest && defaultValue !== undefined) {
return typeof defaultValue === 'object' && defaultValue != null
? structuredClone(defaultValue)
: defaultValue;
}
return;
}
if (format !== 'binary' && !oneOf?.some((s) => s.format === 'binary')) {
return value;
}
if (knownAssetIds.includes(value)) {
return value;
}
if (value == null) {
return value;
}
const num = Number(value);
if (!assetIndexIdMap.has(num)) {
return value;
}
const uuid = assetIndexIdMap.get(num);
const currentResource = Array.isArray(resource) ? resource[path[0]] : resource;
const preparedAsset = preparedAssets.find((asset) => asset.id === uuid);
if (preparedAsset) {
preparedAsset.resource = currentResource;
}
usedAssetIndices.add(num);
return uuid;
},
});
result.errors.push(...customErrors);
for (const index of [...assetIndexIdMap.keys()].filter((idx) => !usedAssetIndices.has(idx))) {
result.errors.push(new ValidationError('is not referenced from the resource', index, undefined, ['assets', index], 'binary', 'format'));
}
handleValidatorResult(ctx, result, 'Resource validation failed');
return [resource, preparedAssets, knownAssetIds.filter((id) => !reusedAssets.has(id))];
}
/**
* Validate that the references declared in the resource definition point at existing resources.
*
* Reference properties which hold no value are skipped. Expired resources are treated as
* non-existent. The referenced ids are collected per referenced resource type, so each type is
* checked using a single lookup, even when multiple resources are submitted at once.
*
* If a reference is broken, an HTTP 400 error naming the offending property path is thrown.
*
* @param ctx The Koa context used to throw back the error response.
* @param app The app the resources belong to.
* @param definition The definition of the resource type being created or updated.
* @param processedBody One or more resources as processed by `processResourceBody`.
* @param getAppResources The function used to look up resources of the referenced types.
*/
export async function validateResourceReferences(ctx, app, definition, processedBody, getAppResources) {
const references = Object.entries(definition.references ?? {});
if (!references.length) {
return;
}
const resources = Array.isArray(processedBody) ? processedBody : [processedBody];
const referencedIds = new Map();
for (const [propertyName, reference] of references) {
for (const resource of resources) {
for (const value of [resource[propertyName]].flat()) {
if (value == null || value === '') {
continue;
}
const id = typeof value === 'string' ? Number(value) : value;
if (typeof id === 'number' && Number.isInteger(id)) {
if (!referencedIds.has(reference.resource)) {
referencedIds.set(reference.resource, new Set());
}
referencedIds.get(reference.resource).add(id);
}
}
}
}
const existingIds = new Map();
await Promise.all([...referencedIds].map(async ([type, ids]) => {
const existing = await getAppResources({
app,
context: ctx,
type,
findOptions: {
where: {
and: [
{ type },
{ or: [...ids].map((id) => ({ id })) },
{ expires: { or: [{ gt: new Date() }, null] } },
],
},
},
});
existingIds.set(type, new Set(existing.map((resource) => Number(resource.id))));
}));
const errors = [];
for (const [propertyName, reference] of references) {
for (const [index, resource] of resources.entries()) {
for (const value of [resource[propertyName]].flat()) {
if (value == null || value === '') {
continue;
}
const id = typeof value === 'string' ? Number(value) : value;
if (typeof id === 'number' && existingIds.get(reference.resource)?.has(id)) {
continue;
}
errors.push(new ValidationError(`does not reference an existing resource of type ${reference.resource}`, value, undefined, Array.isArray(processedBody) ? [index, propertyName] : [propertyName]));
}
}
}
if (errors.length) {
if (ctx.response === undefined) {
throw new Error('Resource validation failed', { cause: errors });
}
throwKoaError(ctx, 400, 'Resource validation failed', { errors });
}
}
//# sourceMappingURL=resource.js.map