UNPKG

@appsemble/node-utils

Version:

NodeJS utilities used by Appsemble internally.

425 lines 17.8 kB
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