UNPKG

@appsemble/node-utils

Version:

NodeJS utilities used by Appsemble internally.

295 lines 11.6 kB
import { randomUUID } from 'node:crypto'; import { mapValues } from '@appsemble/utils'; import { addMilliseconds, isPast, parseISO } from 'date-fns'; import { ValidationError, Validator } from 'jsonschema'; import parseDuration from 'parse-duration'; import { preProcessCSV } from './csv.js'; import { handleValidatorResult, TempFile } from './index.js'; import { throwKoaError } from './koa.js'; export function stripResource({ $author, $created, $editor, $ephemeral, $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, }; } /** * 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. * @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) { let body; let assets; let preValidateProperty; 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); assets = []; } else { ({ body } = ctx); assets = []; } return [ Array.isArray(body) ? body.map(stripResource) : stripResource(body), assets, preValidateProperty, ]; } /** * 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. * @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 function processResourceBody(ctx, definition, knownAssetIds = [], knownExpires, knownAssetNameIds = [], isPatch = false) { const [resource, assets, preValidateProperty] = extractResourceBody(ctx); const validator = new Validator(); const assetIdMap = new Map(); const assetUsedMap = new Map(); const reusedAssets = new Set(); const thumbnailAssets = []; const regularAssets = []; const thumbnailAssetSuffix = '-thumbnail.png'; for (const asset of assets) { if (asset.filename?.endsWith(thumbnailAssetSuffix)) { thumbnailAssets.push(asset); } else { regularAssets.push(asset); } } const preparedRegularAssets = regularAssets.map(({ filename, mime, path }, index) => { const id = randomUUID(); assetIdMap.set(index, id); assetUsedMap.set(index, false); return { path, filename, id, mime }; }); const usedPreparedRegularAssets = []; const preparedThumbnailAssets = thumbnailAssets.map(({ filename, mime, path }, index) => { const regularAsset = preparedRegularAssets.find((ra) => !usedPreparedRegularAssets.includes(ra.id) && ra.filename?.startsWith(filename.replace(thumbnailAssetSuffix, ''))); const id = regularAsset ? `${regularAsset.id}-thumbnail` : randomUUID(); // @ts-expect-error 2322 null is not assignable to type (strictNullChecks) - Severe usedPreparedRegularAssets.push(regularAsset?.id); assetIdMap.set(index + preparedRegularAssets.length, id); assetUsedMap.set(index, false); return { path, filename, id, mime }; }); const preparedAssets = [...preparedRegularAssets, ...preparedThumbnailAssets]; 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 (assetUsedMap.get(num)) { return false; } return num >= 0 && num < assets.length; }; const patchedSchema = { ...definition.schema, required: ctx.request?.method === 'PATCH' || isPatch ? [] : definition.schema.required, properties: { ...definition.schema.properties, id: { type: 'integer' }, $expires: { anyOf: [ { type: 'string', format: 'date-time' }, { type: 'string', pattern: /^(\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 customErrors = []; 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, { format, oneOf }, options, { path }) { 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; } 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 (!assetIdMap.has(num)) { return value; } const uuid = assetIdMap.get(num); const currentResource = Array.isArray(resource) ? resource[path[0]] : resource; const preparedAsset = preparedAssets.find((asset) => asset.id === uuid); if (preparedAsset) { preparedAsset.resource = currentResource; } assetUsedMap.set(num, true); return uuid; }, }); result.errors.push(...customErrors); for (const [assetId, used] of assetUsedMap.entries()) { if (!used) { result.errors.push(new ValidationError('is not referenced from the resource', assetId, undefined, ['assets', assetId], 'binary', 'format')); } } handleValidatorResult(ctx, result, 'Resource validation failed'); return [resource, preparedAssets, knownAssetIds.filter((id) => !reusedAssets.has(id))]; } //# sourceMappingURL=resource.js.map