From a984a594e496f8eaee9a39c9f85d0d0b02d70618 Mon Sep 17 00:00:00 2001 From: Egor Didenko Date: Fri, 17 May 2024 18:05:33 -0400 Subject: [PATCH 1/9] feat(validators): added custom validators --- abstract/UploaderBlock.js | 268 ++----------------------- abstract/ValidationManager.js | 272 ++++++++++++++++++++++++++ blocks/Config/Config.js | 2 + blocks/Config/initialConfig.js | 1 + blocks/Config/normalizeConfigValue.js | 14 ++ demo/raw-regular.html | 1 + types/exported.d.ts | 2 + 7 files changed, 308 insertions(+), 252 deletions(-) create mode 100644 abstract/ValidationManager.js diff --git a/abstract/UploaderBlock.js b/abstract/UploaderBlock.js index 8079bd24d..fa17cb634 100644 --- a/abstract/UploaderBlock.js +++ b/abstract/UploaderBlock.js @@ -2,7 +2,7 @@ import { ActivityBlock } from './ActivityBlock.js'; import { Data } from '@symbiotejs/symbiote'; -import { NetworkError, UploadError, uploadFileGroup } from '@uploadcare/upload-client'; +import { uploadFileGroup } from '@uploadcare/upload-client'; import { calculateMaxCenteredCropFrame } from '../blocks/CloudImageEditor/src/crop-utils.js'; import { parseCropPreset } from '../blocks/CloudImageEditor/src/lib/parseCropPreset.js'; import { EventType } from '../blocks/UploadCtxProvider/EventEmitter.js'; @@ -10,10 +10,8 @@ import { UploadSource } from '../blocks/utils/UploadSource.js'; import { serializeCsv } from '../blocks/utils/comma-separated.js'; import { debounce } from '../blocks/utils/debounce.js'; import { customUserAgent } from '../blocks/utils/userAgent.js'; -import { buildCollectionFileError, buildOutputFileError } from '../utils/buildOutputError.js'; import { createCdnUrl, createCdnUrlModifiers } from '../utils/cdn-utils.js'; -import { IMAGE_ACCEPT_LIST, fileIsImage, matchExtension, matchMimeType, mergeFileTypes } from '../utils/fileTypes.js'; -import { prettyBytes } from '../utils/prettyBytes.js'; +import { IMAGE_ACCEPT_LIST, fileIsImage, mergeFileTypes } from '../utils/fileTypes.js'; import { stringToArray } from '../utils/stringToArray.js'; import { warnOnce } from '../utils/warnOnce.js'; import { uploaderBlockCtx } from './CTX.js'; @@ -22,6 +20,8 @@ import { buildOutputCollectionState } from './buildOutputCollectionState.js'; import { uploadEntrySchema } from './uploadEntrySchema.js'; import { parseCdnUrl } from '../utils/parseCdnUrl.js'; import { SecureUploadsManager } from './SecureUploadsManager.js'; +import { ValidationManager } from './ValidationManager.js'; + export class UploaderBlock extends ActivityBlock { couldBeCtxOwner = false; isCtxOwner = false; @@ -31,74 +31,6 @@ export class UploaderBlock extends ActivityBlock { /** @private */ __initialUploadMetadata = null; - /** - * @private - * @type {(( - * outputEntry: import('../types').OutputFileEntry, - * internalEntry?: import('./TypedData.js').TypedData, - * ) => undefined | ReturnType)[]} - */ - _fileValidators = [ - this._validateIsImage.bind(this), - this._validateFileType.bind(this), - this._validateMaxSizeLimit.bind(this), - this._validateUploadError.bind(this), - ]; - - /** - * @private - * @type {(( - * collection: TypedCollection, - * ) => - * | undefined - * | ReturnType - * | ReturnType[])[]} - */ - _collectionValidators = [ - (collection) => { - const total = collection.size; - const multipleMin = this.cfg.multiple ? this.cfg.multipleMin : 0; - const multipleMax = this.cfg.multiple ? this.cfg.multipleMax : 1; - if (multipleMin && total < multipleMin) { - const message = this.l10n('files-count-limit-error-too-few', { - min: multipleMin, - max: multipleMax, - total, - }); - return buildCollectionFileError({ - type: 'TOO_FEW_FILES', - message, - total, - min: multipleMin, - max: multipleMax, - }); - } - - if (multipleMax && total > multipleMax) { - const message = this.l10n('files-count-limit-error-too-many', { - min: multipleMin, - max: multipleMax, - total, - }); - return buildCollectionFileError({ - type: 'TOO_MANY_FILES', - message, - total, - min: multipleMin, - max: multipleMax, - }); - } - }, - (collection) => { - if (collection.items().some((id) => collection.readProp(id, 'errors').length > 0)) { - return buildCollectionFileError({ - type: 'SOME_FILES_HAS_ERRORS', - message: this.l10n('some-files-were-not-uploaded'), - }); - } - }, - ]; - /** * This is Public JS API method. Could be called before block initialization, so we need to delay state interactions * until block init. @@ -142,11 +74,20 @@ export class UploaderBlock extends ActivityBlock { this.$['*uploadCollection'] = uploadCollection; } + if (!this.has('*validationManager')) { + this.add('*validationManager', new ValidationManager(this)); + } + if (!this.hasCtxOwner && this.couldBeCtxOwner) { this.initCtxOwner(); } } + /** @returns {ValidationManager | null} */ + get validationManager() { + return this.has('*validationManager') ? this.$['*validationManager'] : null; + } + destroyCtxCallback() { this._unobserveCollectionProperties?.(); this._unobserveCollection?.(); @@ -167,17 +108,6 @@ export class UploaderBlock extends ActivityBlock { this._handleCollectionPropertiesUpdate, ); - const runAllValidators = () => { - this._runFileValidators(); - this._runCollectionValidators(); - }; - - this.subConfigValue('maxLocalFileSizeBytes', runAllValidators); - this.subConfigValue('multipleMin', runAllValidators); - this.subConfigValue('multipleMax', runAllValidators); - this.subConfigValue('multiple', runAllValidators); - this.subConfigValue('imgOnly', runAllValidators); - this.subConfigValue('accept', runAllValidators); this.subConfigValue('maxConcurrentRequests', (value) => { this.$['*uploadQueue'].concurrency = Number(value) || 1; }); @@ -416,172 +346,6 @@ export class UploaderBlock extends ActivityBlock { return this.$['*uploadCollection']; } - /** - * @private - * @param {import('../types').OutputFileEntry} outputEntry - */ - _validateFileType(outputEntry) { - const imagesOnly = this.cfg.imgOnly; - const accept = this.cfg.accept; - const allowedFileTypes = mergeFileTypes([...(imagesOnly ? IMAGE_ACCEPT_LIST : []), accept]); - if (!allowedFileTypes.length) return; - - const mimeType = outputEntry.mimeType; - const fileName = outputEntry.name; - - if (!mimeType || !fileName) { - // Skip client validation if mime type or file name are not available for some reasons - return; - } - - const mimeOk = matchMimeType(mimeType, allowedFileTypes); - const extOk = matchExtension(fileName, allowedFileTypes); - - if (!mimeOk && !extOk) { - // Assume file type is not allowed if both mime and ext checks fail - return buildOutputFileError({ - type: 'FORBIDDEN_FILE_TYPE', - message: this.l10n('file-type-not-allowed'), - entry: outputEntry, - }); - } - } - - /** - * @private - * @param {import('../types').OutputFileEntry} outputEntry - */ - _validateMaxSizeLimit(outputEntry) { - const maxFileSize = this.cfg.maxLocalFileSizeBytes; - const fileSize = outputEntry.size; - if (maxFileSize && fileSize && fileSize > maxFileSize) { - return buildOutputFileError({ - type: 'FILE_SIZE_EXCEEDED', - message: this.l10n('files-max-size-limit-error', { maxFileSize: prettyBytes(maxFileSize) }), - entry: outputEntry, - }); - } - } - - /** - * @private - * @param {import('../types').OutputFileEntry} outputEntry - * @param {import('./TypedData.js').TypedData} [internalEntry] - */ - _validateUploadError(outputEntry, internalEntry) { - /** @type {unknown} */ - const cause = internalEntry?.getValue('uploadError'); - if (!cause) { - return; - } - - if (cause instanceof UploadError) { - return buildOutputFileError({ - type: 'UPLOAD_ERROR', - message: cause.message, - entry: outputEntry, - error: cause, - }); - } else if (cause instanceof NetworkError) { - return buildOutputFileError({ - type: 'NETWORK_ERROR', - message: cause.message, - entry: outputEntry, - error: cause, - }); - } else { - const error = cause instanceof Error ? cause : new Error('Unknown error', { cause }); - return buildOutputFileError({ - type: 'UNKNOWN_ERROR', - message: error.message, - entry: outputEntry, - error, - }); - } - } - - /** - * @private - * @param {import('../types').OutputFileEntry} outputEntry - */ - _validateIsImage(outputEntry) { - const imagesOnly = this.cfg.imgOnly; - const isImage = outputEntry.isImage; - if (!imagesOnly || isImage) { - return; - } - if (!outputEntry.fileInfo && outputEntry.externalUrl) { - // skip validation for not uploaded files with external url, cause we don't know if they're images or not - return; - } - if (!outputEntry.fileInfo && !outputEntry.mimeType) { - // skip validation for not uploaded files without mime-type, cause we don't know if they're images or not - return; - } - return buildOutputFileError({ - type: 'NOT_AN_IMAGE', - message: this.l10n('images-only-accepted'), - entry: outputEntry, - }); - } - - /** - * @private - * @param {import('./TypedData.js').TypedData} entry - */ - _runFileValidatorsForEntry(entry) { - const outputEntry = this.getOutputItem(entry.uid); - const errors = []; - - for (const validator of this._fileValidators) { - const error = validator(outputEntry, entry); - if (error) { - errors.push(error); - } - } - entry.setValue('errors', errors); - } - - /** - * @private - * @param {string[]} [entryIds] - */ - _runFileValidators(entryIds) { - const ids = entryIds ?? this.uploadCollection.items(); - for (const id of ids) { - const entry = this.uploadCollection.read(id); - entry && this._runFileValidatorsForEntry(entry); - } - } - - /** @private */ - _runCollectionValidators() { - const collection = this.uploadCollection; - const errors = []; - - for (const validator of this._collectionValidators) { - const errorOrErrors = validator(collection); - if (!errorOrErrors) { - continue; - } - if (Array.isArray(errorOrErrors)) { - errors.push(...errorOrErrors); - } else { - errors.push(errorOrErrors); - } - } - - this.$['*collectionErrors'] = errors; - - if (errors.length > 0) { - this.emit( - EventType.COMMON_UPLOAD_FAILED, - () => /** @type {import('../types').OutputCollectionState<'failed'>} */ (this.getOutputCollectionState()), - { debounce: true }, - ); - } - } - /** * @private * @param {import('../types').OutputCollectionState} collectionState @@ -630,8 +394,8 @@ export class UploaderBlock extends ActivityBlock { if (added.size || removed.size) { this.$['*groupInfo'] = null; } - this._runFileValidators(); - this._runCollectionValidators(); + this.validationManager._runFileValidators(); + this.validationManager._runCollectionValidators(); for (const entry of added) { if (!entry.getValue('silent')) { @@ -681,7 +445,7 @@ export class UploaderBlock extends ActivityBlock { entriesToRunValidation.length > 0 && setTimeout(() => { // We can't modify entry properties in the same tick, so we need to wait a bit - this._runFileValidators(entriesToRunValidation); + this.validationManager._runFileValidators(entriesToRunValidation); }); if (changeMap.uploadProgress) { diff --git a/abstract/ValidationManager.js b/abstract/ValidationManager.js new file mode 100644 index 000000000..1abe42c54 --- /dev/null +++ b/abstract/ValidationManager.js @@ -0,0 +1,272 @@ +// @ts-check +import { NetworkError, UploadError } from '@uploadcare/upload-client'; +import { buildCollectionFileError, buildOutputFileError } from '../utils/buildOutputError.js'; +import { EventType } from '../blocks/UploadCtxProvider/EventEmitter.js'; +import { IMAGE_ACCEPT_LIST, matchExtension, matchMimeType, mergeFileTypes } from '../utils/fileTypes.js'; +import { prettyBytes } from '../utils/prettyBytes.js'; +import { TypedCollection } from './TypedCollection.js'; + +export class ValidationManager { + /** + * @private + * @type {import('./UploaderBlock.js').UploaderBlock | null} + */ + _blockInstance = null; + + /** + * @private + * @type {(( + * outputEntry: import('../types').OutputFileEntry, + * internalEntry?: import('./TypedData.js').TypedData, + * ) => undefined | ReturnType)[]} + */ + _fileValidators = [ + this._validateIsImage.bind(this), + this._validateFileType.bind(this), + this._validateMaxSizeLimit.bind(this), + this._validateUploadError.bind(this), + ]; + + /** + * @private + * @type {(( + * collection: TypedCollection, + * ) => + * | undefined + * | ReturnType + * | ReturnType[])[]} + */ + _collectionValidators = [ + (collection) => { + const total = collection.size; + const multipleMin = this._blockInstance.cfg.multiple ? this._blockInstance.cfg.multipleMin : 0; + const multipleMax = this._blockInstance.cfg.multiple ? this._blockInstance.cfg.multipleMax : 1; + + if (multipleMin && total < multipleMin) { + const message = this._blockInstance.l10n('files-count-limit-error-too-few', { + min: multipleMin, + max: multipleMax, + total, + }); + return buildCollectionFileError({ + type: 'TOO_FEW_FILES', + message, + total, + min: multipleMin, + max: multipleMax, + }); + } + + if (multipleMax && total > multipleMax) { + const message = this._blockInstance.l10n('files-count-limit-error-too-many', { + min: multipleMin, + max: multipleMax, + total, + }); + return buildCollectionFileError({ + type: 'TOO_MANY_FILES', + message, + total, + min: multipleMin, + max: multipleMax, + }); + } + }, + (collection) => { + if (collection.items().some((id) => collection.readProp(id, 'errors').length > 0)) { + return buildCollectionFileError({ + type: 'SOME_FILES_HAS_ERRORS', + message: this._blockInstance.l10n('some-files-were-not-uploaded'), + }); + } + }, + ]; + + /** @param {import('./UploaderBlock.js').UploaderBlock} blockInstance */ + constructor(blockInstance) { + this._blockInstance = blockInstance; + + const runAllValidators = () => { + this._runFileValidators(); + this._runCollectionValidators(); + }; + + this._blockInstance.subConfigValue('maxLocalFileSizeBytes', runAllValidators); + this._blockInstance.subConfigValue('multipleMin', runAllValidators); + this._blockInstance.subConfigValue('multipleMax', runAllValidators); + this._blockInstance.subConfigValue('multiple', runAllValidators); + this._blockInstance.subConfigValue('imgOnly', runAllValidators); + this._blockInstance.subConfigValue('accept', runAllValidators); + } + + /** + * @private + * @param {string[]} [entryIds] + */ + _runFileValidators(entryIds) { + const ids = entryIds ?? this._blockInstance.uploadCollection.items(); + for (const id of ids) { + const entry = this._blockInstance.uploadCollection.read(id); + entry && this._runFileValidatorsForEntry(entry); + } + } + + /** @private */ + _runCollectionValidators() { + const collection = this._blockInstance.uploadCollection; + const errors = []; + + for (const validator of this._collectionValidators) { + const errorOrErrors = validator(collection); + if (!errorOrErrors) { + continue; + } + if (Array.isArray(errorOrErrors)) { + errors.push(...errorOrErrors); + } else { + errors.push(errorOrErrors); + } + } + + this._blockInstance.$['*collectionErrors'] = errors; + + if (errors.length > 0) { + this._blockInstance.emit( + EventType.COMMON_UPLOAD_FAILED, + () => + /** @type {import('../types').OutputCollectionState<'failed'>} */ ( + this._blockInstance.getOutputCollectionState() + ), + { debounce: true }, + ); + } + } + + /** + * @private + * @param {import('./TypedData.js').TypedData} entry + */ + _runFileValidatorsForEntry(entry) { + const outputEntry = this._blockInstance.getOutputItem(entry.uid); + const errors = []; + + for (const validator of this._fileValidators) { + const error = validator(outputEntry, entry); + if (error) { + errors.push(error); + } + } + entry.setValue('errors', errors); + } + + /** + * @private + * @param {import('../types').OutputFileEntry} outputEntry + */ + _validateIsImage(outputEntry) { + const imagesOnly = this._blockInstance.cfg.imgOnly; + const isImage = outputEntry.isImage; + + if (!imagesOnly || isImage) { + return; + } + if (!outputEntry.fileInfo && outputEntry.externalUrl) { + // skip validation for not uploaded files with external url, cause we don't know if they're images or not + return; + } + if (!outputEntry.fileInfo && !outputEntry.mimeType) { + // skip validation for not uploaded files without mime-type, cause we don't know if they're images or not + return; + } + + return buildOutputFileError({ + type: 'NOT_AN_IMAGE', + message: this._blockInstance.l10n('images-only-accepted'), + entry: outputEntry, + }); + } + + /** + * @private + * @param {import('../types').OutputFileEntry} outputEntry + */ + _validateFileType(outputEntry) { + const imagesOnly = this._blockInstance.cfg.imgOnly; + const accept = this._blockInstance.cfg.accept; + const allowedFileTypes = mergeFileTypes([...(imagesOnly ? IMAGE_ACCEPT_LIST : []), accept]); + if (!allowedFileTypes.length) return; + + const mimeType = outputEntry.mimeType; + const fileName = outputEntry.name; + + if (!mimeType || !fileName) { + // Skip client validation if mime type or file name are not available for some reasons + return; + } + + const mimeOk = matchMimeType(mimeType, allowedFileTypes); + const extOk = matchExtension(fileName, allowedFileTypes); + + if (!mimeOk && !extOk) { + // Assume file type is not allowed if both mime and ext checks fail + return buildOutputFileError({ + type: 'FORBIDDEN_FILE_TYPE', + message: this._blockInstance.l10n('file-type-not-allowed'), + entry: outputEntry, + }); + } + } + + /** + * @private + * @param {import('../types').OutputFileEntry} outputEntry + */ + _validateMaxSizeLimit(outputEntry) { + const maxFileSize = this._blockInstance.cfg.maxLocalFileSizeBytes; + const fileSize = outputEntry.size; + if (maxFileSize && fileSize && fileSize > maxFileSize) { + return buildOutputFileError({ + type: 'FILE_SIZE_EXCEEDED', + message: this._blockInstance.l10n('files-max-size-limit-error', { maxFileSize: prettyBytes(maxFileSize) }), + entry: outputEntry, + }); + } + } + + /** + * @private + * @param {import('../types').OutputFileEntry} outputEntry + * @param {import('./TypedData.js').TypedData} [internalEntry] + */ + _validateUploadError(outputEntry, internalEntry) { + /** @type {unknown} */ + const cause = internalEntry?.getValue('uploadError'); + if (!cause) { + return; + } + + if (cause instanceof UploadError) { + return buildOutputFileError({ + type: 'UPLOAD_ERROR', + message: cause.message, + entry: outputEntry, + error: cause, + }); + } else if (cause instanceof NetworkError) { + return buildOutputFileError({ + type: 'NETWORK_ERROR', + message: cause.message, + entry: outputEntry, + error: cause, + }); + } else { + const error = cause instanceof Error ? cause : new Error('Unknown error', { cause }); + return buildOutputFileError({ + type: 'UNKNOWN_ERROR', + message: error.message, + entry: outputEntry, + error, + }); + } + } +} diff --git a/blocks/Config/Config.js b/blocks/Config/Config.js index f9c1d0a74..54f3be6ac 100644 --- a/blocks/Config/Config.js +++ b/blocks/Config/Config.js @@ -19,6 +19,7 @@ const allConfigKeys = /** @type {(keyof import('../../types').ConfigType)[]} */ * 'secureUploadsSignatureResolver', * 'secureDeliveryProxyUrlResolver', * 'iconHrefResolver', + * 'validators' * ]} */ export const complexConfigKeys = [ @@ -27,6 +28,7 @@ export const complexConfigKeys = [ 'secureUploadsSignatureResolver', 'secureDeliveryProxyUrlResolver', 'iconHrefResolver', + 'validators' ]; /** @type {(key: keyof import('../../types').ConfigType) => key is keyof import('../../types').ConfigComplexType} */ diff --git a/blocks/Config/initialConfig.js b/blocks/Config/initialConfig.js index ae5216ed4..de29c42bf 100644 --- a/blocks/Config/initialConfig.js +++ b/blocks/Config/initialConfig.js @@ -64,4 +64,5 @@ export const initialConfig = { secureUploadsSignatureResolver: null, secureDeliveryProxyUrlResolver: null, iconHrefResolver: null, + validators: null, }; diff --git a/blocks/Config/normalizeConfigValue.js b/blocks/Config/normalizeConfigValue.js index 34e20eb90..9e9275112 100644 --- a/blocks/Config/normalizeConfigValue.js +++ b/blocks/Config/normalizeConfigValue.js @@ -74,6 +74,19 @@ const asFunction = (value) => { throw new Error('Invalid value. Must be a function.'); }; +/** @param {unknown} value */ +const asValidators = (value) => { + if (typeof value === 'function') { + return value; + } + + if (Array.isArray(value)) { + return value; + } + + throw new Error('Invalid validators value. Must be an function.'); +}; + /** * @type {{ * [Key in keyof import('../../types').ConfigType]: ( @@ -140,6 +153,7 @@ const mapping = { secureDeliveryProxyUrlResolver: /** @type {typeof asFunction} */ (asFunction), iconHrefResolver: /** @type {typeof asFunction} */ (asFunction), + validators: asValidators, }; /** diff --git a/demo/raw-regular.html b/demo/raw-regular.html index 12432a960..e5f6c4a84 100644 --- a/demo/raw-regular.html +++ b/demo/raw-regular.html @@ -17,6 +17,7 @@ } } + + + + + + + + + + \ No newline at end of file