From e7f718c785ad99198f6ce219e5d7bb7555d6efc4 Mon Sep 17 00:00:00 2001 From: Stefan Zermatten Date: Tue, 8 Mar 2022 13:15:48 +0200 Subject: [PATCH] Prevented updates from running on the server if they fail client validation --- app/imports/api/simpleSchemaConfig.js | 5 + .../ui/components/global/SmartInputMixin.js | 2 + .../CreaturePropertyDialog.vue | 25 +- .../collection2/.npm/package/.gitignore | 1 + app/packages/collection2/.npm/package/README | 7 + .../.npm/package/npm-shrinkwrap.json | 20 + app/packages/collection2/.versions | 50 ++ app/packages/collection2/collection2.js | 739 ++++++++++++++++++ app/packages/collection2/lib.js | 31 + app/packages/collection2/package.js | 33 + 10 files changed, 893 insertions(+), 20 deletions(-) create mode 100644 app/packages/collection2/.npm/package/.gitignore create mode 100644 app/packages/collection2/.npm/package/README create mode 100644 app/packages/collection2/.npm/package/npm-shrinkwrap.json create mode 100644 app/packages/collection2/.versions create mode 100644 app/packages/collection2/collection2.js create mode 100644 app/packages/collection2/lib.js create mode 100644 app/packages/collection2/package.js diff --git a/app/imports/api/simpleSchemaConfig.js b/app/imports/api/simpleSchemaConfig.js index aff36261..6b960a40 100644 --- a/app/imports/api/simpleSchemaConfig.js +++ b/app/imports/api/simpleSchemaConfig.js @@ -1,4 +1,9 @@ import SimpleSchema from 'simpl-schema'; +import { set } from 'lodash'; + +set(Meteor.settings, + 'packages.collection2.disableCollectionNamesInValidation', + true); SimpleSchema.extendOptions([ 'parseLevel', diff --git a/app/imports/ui/components/global/SmartInputMixin.js b/app/imports/ui/components/global/SmartInputMixin.js index 66ebb058..5e44cc7b 100644 --- a/app/imports/ui/components/global/SmartInputMixin.js +++ b/app/imports/ui/components/global/SmartInputMixin.js @@ -106,6 +106,8 @@ export default { this.ackErrors = error; } else if (error.reason){ this.ackErrors = error.reason; + } else if (error.message){ + this.ackErrors = error.message; } else { this.ackErrors = 'Something went wrong' console.error(error); diff --git a/app/imports/ui/creature/creatureProperties/CreaturePropertyDialog.vue b/app/imports/ui/creature/creatureProperties/CreaturePropertyDialog.vue index 81106f65..64f684ff 100644 --- a/app/imports/ui/creature/creatureProperties/CreaturePropertyDialog.vue +++ b/app/imports/ui/creature/creatureProperties/CreaturePropertyDialog.vue @@ -220,36 +220,21 @@ export default { }, change({path, value, ack}){ if (path && path[0] === 'equipped'){ - equipItem.call({_id: this.currentId, equipped: value}, (error) =>{ - if (error) console.warn(error); - ack && ack(error && error.reason || error); - }); + equipItem.call({_id: this.currentId, equipped: value}, ack); return; } - updateCreatureProperty.call({_id: this.currentId, path, value}, (error) =>{ - if (error) console.warn(error); - ack && ack(error && error.reason || error); - }); + updateCreatureProperty.call({_id: this.currentId, path, value}, ack); }, damage({operation, value, ack}){ - damageProperty.call({_id: this.currentId, operation, value}, (error) =>{ - if (error) console.warn(error); - ack && ack(error && error.reason || error); - }); + damageProperty.call({_id: this.currentId, operation, value}, ack); }, push({path, value, ack}){ - pushToProperty.call({_id: this.currentId, path, value}, (error) =>{ - if (error) console.warn(error); - ack && ack(error && error.reason || error); - }); + pushToProperty.call({_id: this.currentId, path, value}, ack); }, pull({path, ack}){ let itemId = get(this.model, path)._id; path.pop(); - pullFromProperty.call({_id: this.currentId, path, itemId}, (error) =>{ - if (error) console.warn(error); - ack && ack(error && error.reason || error); - }); + pullFromProperty.call({_id: this.currentId, path, itemId}, ack); }, remove(){ const _id = this.currentId; diff --git a/app/packages/collection2/.npm/package/.gitignore b/app/packages/collection2/.npm/package/.gitignore new file mode 100644 index 00000000..3c3629e6 --- /dev/null +++ b/app/packages/collection2/.npm/package/.gitignore @@ -0,0 +1 @@ +node_modules diff --git a/app/packages/collection2/.npm/package/README b/app/packages/collection2/.npm/package/README new file mode 100644 index 00000000..3d492553 --- /dev/null +++ b/app/packages/collection2/.npm/package/README @@ -0,0 +1,7 @@ +This directory and the files immediately inside it are automatically generated +when you change this package's NPM dependencies. Commit the files in this +directory (npm-shrinkwrap.json, .gitignore, and this README) to source control +so that others run the same versions of sub-dependencies. + +You should NOT check in the node_modules directory that Meteor automatically +creates; if you are using git, the .gitignore file tells git to ignore it. diff --git a/app/packages/collection2/.npm/package/npm-shrinkwrap.json b/app/packages/collection2/.npm/package/npm-shrinkwrap.json new file mode 100644 index 00000000..7c7e4cf6 --- /dev/null +++ b/app/packages/collection2/.npm/package/npm-shrinkwrap.json @@ -0,0 +1,20 @@ +{ + "lockfileVersion": 1, + "dependencies": { + "lodash.isempty": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/lodash.isempty/-/lodash.isempty-4.4.0.tgz", + "integrity": "sha1-b4bL7di+TsmHvpqvM8loTbGzHn4=" + }, + "lodash.isequal": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.isequal/-/lodash.isequal-4.5.0.tgz", + "integrity": "sha1-QVxEePK8wwEgwizhDtMib30+GOA=" + }, + "lodash.isobject": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/lodash.isobject/-/lodash.isobject-3.0.2.tgz", + "integrity": "sha1-PI+41bW/S/kK4G4U8qUwpO2TXh0=" + } + } +} diff --git a/app/packages/collection2/.versions b/app/packages/collection2/.versions new file mode 100644 index 00000000..3b51faec --- /dev/null +++ b/app/packages/collection2/.versions @@ -0,0 +1,50 @@ +aldeed:collection2@3.5.0 +allow-deny@1.1.0 +babel-compiler@7.7.0 +babel-runtime@1.5.0 +base64@1.0.12 +binary-heap@1.0.11 +boilerplate-generator@1.7.1 +callback-hook@1.3.1 +check@1.3.1 +ddp@1.4.0 +ddp-client@2.5.0 +ddp-common@1.4.0 +ddp-server@2.4.0 +diff-sequence@1.1.1 +dynamic-import@0.7.1 +ecmascript@0.15.3 +ecmascript-runtime@0.7.0 +ecmascript-runtime-client@0.11.1 +ecmascript-runtime-server@0.10.1 +ejson@1.1.1 +fetch@0.1.1 +geojson-utils@1.0.10 +id-map@1.1.1 +inter-process-messaging@0.1.1 +logging@1.2.0 +meteor@1.9.3 +minimongo@1.7.0 +modern-browsers@0.1.5 +modules@0.16.0 +modules-runtime@0.12.0 +mongo@1.12.0 +mongo-decimal@0.1.2 +mongo-dev-server@1.1.0 +mongo-id@1.0.8 +npm-mongo@3.9.1 +ordered-dict@1.1.0 +promise@0.12.0 +raix:eventemitter@1.0.0 +random@1.2.0 +react-fast-refresh@0.1.1 +reload@1.3.1 +retry@1.1.0 +routepolicy@1.1.1 +socket-stream-client@0.4.0 +tmeasday:check-npm-versions@1.0.2 +tracker@1.2.0 +typescript@4.3.5 +underscore@1.0.10 +webapp@1.11.1 +webapp-hashing@1.1.0 diff --git a/app/packages/collection2/collection2.js b/app/packages/collection2/collection2.js new file mode 100644 index 00000000..422dbf95 --- /dev/null +++ b/app/packages/collection2/collection2.js @@ -0,0 +1,739 @@ +import { EventEmitter } from 'meteor/raix:eventemitter'; +import { Meteor } from 'meteor/meteor'; +import { Mongo } from 'meteor/mongo'; +import { checkNpmVersions } from 'meteor/tmeasday:check-npm-versions'; +import { EJSON } from 'meteor/ejson'; +import isEmpty from 'lodash.isempty'; +import isEqual from 'lodash.isequal'; +import isObject from 'lodash.isobject'; +import { flattenSelector } from './lib'; + +checkNpmVersions({ 'simpl-schema': '>=0.0.0' }, 'aldeed:collection2'); + +const SimpleSchema = require('simpl-schema').default; + +// Exported only for listening to events +const Collection2 = new EventEmitter(); + +Collection2.cleanOptions = { + filter: true, + autoConvert: true, + removeEmptyStrings: true, + trimStrings: true, + removeNullsFromArrays: false, +}; + +/** + * Mongo.Collection.prototype.attachSchema + * @param {SimpleSchema|Object} ss - SimpleSchema instance or a schema definition object + * from which to create a new SimpleSchema instance + * @param {Object} [options] + * @param {Boolean} [options.transform=false] Set to `true` if your document must be passed + * through the collection's transform to properly validate. + * @param {Boolean} [options.replace=false] Set to `true` to replace any existing schema instead of combining + * @return {undefined} + * + * Use this method to attach a schema to a collection created by another package, + * such as Meteor.users. It is most likely unsafe to call this method more than + * once for a single collection, or to call this for a collection that had a + * schema object passed to its constructor. + */ +Mongo.Collection.prototype.attachSchema = function c2AttachSchema(ss, options) { + options = options || {}; + + // Allow passing just the schema object + if (!SimpleSchema.isSimpleSchema(ss)) { + ss = new SimpleSchema(ss); + } + + function attachTo(obj) { + // we need an array to hold multiple schemas + // position 0 is reserved for the "base" schema + obj._c2 = obj._c2 || {}; + obj._c2._simpleSchemas = obj._c2._simpleSchemas || [ null ]; + + if (typeof options.selector === 'object') { + // Selector Schemas + + // Extend selector schema with base schema + const baseSchema = obj._c2._simpleSchemas[0]; + if (baseSchema) { + ss = extendSchema(baseSchema.schema, ss); + } + + // Index of existing schema with identical selector + let schemaIndex; + + // Loop through existing schemas with selectors, + for (schemaIndex = obj._c2._simpleSchemas.length - 1; 0 < schemaIndex; schemaIndex--) { + const schema = obj._c2._simpleSchemas[schemaIndex]; + if (schema && isEqual(schema.selector, options.selector)) break; + } + + if (schemaIndex <= 0) { + // We didn't find the schema in our array - push it into the array + obj._c2._simpleSchemas.push({ + schema: ss, + selector: options.selector, + }); + } else { + // We found a schema with an identical selector in our array, + if (options.replace === true) { + // Replace existing selector schema with new selector schema + obj._c2._simpleSchemas[schemaIndex].schema = ss; + } else { + // Extend existing selector schema with new selector schema. + obj._c2._simpleSchemas[schemaIndex].schema = extendSchema(obj._c2._simpleSchemas[schemaIndex].schema, ss); + } + } + } else { + // Base Schema + if (options.replace === true) { + // Replace base schema and delete all other schemas + obj._c2._simpleSchemas = [{ + schema: ss, + selector: options.selector, + }]; + } else { + // Set base schema if not yet set + if (!obj._c2._simpleSchemas[0]) { + return obj._c2._simpleSchemas[0] = { schema: ss, selector: undefined }; + } + // Extend base schema and therefore extend all schemas + obj._c2._simpleSchemas.forEach((schema, index) => { + if (obj._c2._simpleSchemas[index]) { + obj._c2._simpleSchemas[index].schema = extendSchema(obj._c2._simpleSchemas[index].schema, ss); + } + }); + } + } + } + + attachTo(this); + // Attach the schema to the underlying LocalCollection, too + if (this._collection instanceof LocalCollection) { + this._collection._c2 = this._collection._c2 || {}; + attachTo(this._collection); + } + + defineDeny(this, options); + keepInsecure(this); + + Collection2.emit('schema.attached', this, ss, options); +}; + +[Mongo.Collection, LocalCollection].forEach((obj) => { + /** + * simpleSchema + * @description function detect the correct schema by given params. If it + * detect multi-schema presence in the collection, then it made an attempt to find a + * `selector` in args + * @param {Object} doc - It could be on update/upsert or document + * itself on insert/remove + * @param {Object} [options] - It could be on update/upsert etc + * @param {Object} [query] - it could be on update/upsert + * @return {Object} Schema + */ + obj.prototype.simpleSchema = function (doc, options, query) { + if (!this._c2) return null; + if (this._c2._simpleSchema) return this._c2._simpleSchema; + + const schemas = this._c2._simpleSchemas; + if (schemas && schemas.length > 0) { + + let schema, selector, target; + // Position 0 reserved for base schema + for (var i = 1; i < schemas.length; i++) { + schema = schemas[i]; + selector = Object.keys(schema.selector)[0]; + + // We will set this to undefined because in theory you might want to select + // on a null value. + target = undefined; + // here we are looking for selector in different places + // $set should have more priority here + if (doc.$set && typeof doc.$set[selector] !== 'undefined') { + target = doc.$set[selector]; + } else if (typeof doc[selector] !== 'undefined') { + target = doc[selector]; + } else if (options && options.selector) { + target = options.selector[selector]; + } else if (query && query[selector]) { // on upsert/update operations + target = query[selector]; + } + + // we need to compare given selector with doc property or option to + // find right schema + if (target !== undefined && target === schema.selector[selector]) { + return schema.schema; + } + } + if (schemas[0]) { + return schemas[0].schema; + } else { + throw new Error('No default schema'); + } + } + + return null; + }; +}); + +// Wrap DB write operation methods +['insert', 'update'].forEach((methodName) => { + const _super = Mongo.Collection.prototype[methodName]; + Mongo.Collection.prototype[methodName] = function(...args) { + let options = (methodName === 'insert') ? args[1] : args[2]; + + // Support missing options arg + if (!options || typeof options === 'function') { + options = {}; + } + + if (this._c2 && options.bypassCollection2 !== true) { + let userId = null; + try { // https://github.com/aldeed/meteor-collection2/issues/175 + userId = Meteor.userId(); + } catch (err) {} + + args = doValidate( + this, + methodName, + args, + Meteor.isServer || this._connection === null, // getAutoValues + userId, + Meteor.isServer // isFromTrustedCode + ); + if (!args) { + // doValidate already called the callback or threw the error so we're done. + // But insert should always return an ID to match core behavior. + return methodName === 'insert' ? this._makeNewID() : undefined; + } + } else { + // We still need to adjust args because insert does not take options + if (methodName === 'insert' && typeof args[1] !== 'function') args.splice(1, 1); + } + + return _super.apply(this, args); + }; +}); + +/* + * Private + */ + +function doValidate(collection, type, args, getAutoValues, userId, isFromTrustedCode) { + let doc, callback, error, options, isUpsert, selector, last, hasCallback; + + if (!args.length) { + throw new Error(type + ' requires an argument'); + } + + // Gather arguments and cache the selector + if (type === 'insert') { + doc = args[0]; + options = args[1]; + callback = args[2]; + + // The real insert doesn't take options + if (typeof options === 'function') { + args = [doc, options]; + } else if (typeof callback === 'function') { + args = [doc, callback]; + } else { + args = [doc]; + } + } else if (type === 'update') { + selector = args[0]; + doc = args[1]; + options = args[2]; + callback = args[3]; + } else { + throw new Error('invalid type argument'); + } + + const validatedObjectWasInitiallyEmpty = isEmpty(doc); + + // Support missing options arg + if (!callback && typeof options === 'function') { + callback = options; + options = {}; + } + options = options || {}; + + last = args.length - 1; + + hasCallback = (typeof args[last] === 'function'); + + // If update was called with upsert:true, flag as an upsert + isUpsert = (type === 'update' && options.upsert === true); + + // we need to pass `doc` and `options` to `simpleSchema` method, that's why + // schema declaration moved here + let schema = collection.simpleSchema(doc, options, selector); + const isLocalCollection = (collection._connection === null); + + // On the server and for local collections, we allow passing `getAutoValues: false` to disable autoValue functions + if ((Meteor.isServer || isLocalCollection) && options.getAutoValues === false) { + getAutoValues = false; + } + + // Process pick/omit options if they are present + const picks = Array.isArray(options.pick) ? options.pick : null; + const omits = Array.isArray(options.omit) ? options.omit : null; + + if (picks && omits) { + // Pick and omit cannot both be present in the options + throw new Error('pick and omit options are mutually exclusive'); + } else if (picks) { + schema = schema.pick(...picks); + } else if (omits) { + schema = schema.omit(...omits); + } + + // Determine validation context + let validationContext = options.validationContext; + if (validationContext) { + if (typeof validationContext === 'string') { + validationContext = schema.namedContext(validationContext); + } + } else { + validationContext = schema.namedContext(); + } + + // Add a default callback function if we're on the client and no callback was given + /* + if (Meteor.isClient && !callback) { + // Client can't block, so it can't report errors by exception, + // only by callback. If they forget the callback, give them a + // default one that logs the error, so they aren't totally + // baffled if their writes don't work because their database is + // down. + callback = function(err) { + if (err) { + Meteor._debug(type + " failed: " + (err.reason || err.stack)); + } + }; + } + */ + + // If client validation is fine or is skipped but then something + // is found to be invalid on the server, we get that error back + // as a special Meteor.Error that we need to parse. + if (Meteor.isClient && hasCallback) { + callback = args[last] = wrapCallbackForParsingServerErrors(validationContext, callback); + } + + const schemaAllowsId = schema.allowsKey('_id'); + if (type === 'insert' && !doc._id && schemaAllowsId) { + doc._id = collection._makeNewID(); + } + + // Get the docId for passing in the autoValue/custom context + let docId; + if (type === 'insert') { + docId = doc._id; // might be undefined + } else if (type === 'update' && selector) { + docId = typeof selector === 'string' || selector instanceof Mongo.ObjectID ? selector : selector._id; + } + + // If _id has already been added, remove it temporarily if it's + // not explicitly defined in the schema. + let cachedId; + if (doc._id && !schemaAllowsId) { + cachedId = doc._id; + delete doc._id; + } + + const autoValueContext = { + isInsert: (type === 'insert'), + isUpdate: (type === 'update' && options.upsert !== true), + isUpsert, + userId, + isFromTrustedCode, + docId, + isLocalCollection + }; + + const extendAutoValueContext = { + ...((schema._cleanOptions || {}).extendAutoValueContext || {}), + ...autoValueContext, + ...options.extendAutoValueContext, + }; + + const cleanOptionsForThisOperation = {}; + ['autoConvert', 'filter', 'removeEmptyStrings', 'removeNullsFromArrays', 'trimStrings'].forEach(prop => { + if (typeof options[prop] === 'boolean') { + cleanOptionsForThisOperation[prop] = options[prop]; + } + }); + + // Preliminary cleaning on both client and server. On the server and for local + // collections, automatic values will also be set at this point. + schema.clean(doc, { + mutate: true, // Clean the doc/modifier in place + isModifier: (type !== 'insert'), + // Start with some Collection2 defaults, which will usually be overwritten + ...Collection2.cleanOptions, + // The extend with the schema-level defaults (from SimpleSchema constructor options) + ...(schema._cleanOptions || {}), + // Finally, options for this specific operation should take precedence + ...cleanOptionsForThisOperation, + extendAutoValueContext, // This was extended separately above + getAutoValues, // Force this override + }); + + // We clone before validating because in some cases we need to adjust the + // object a bit before validating it. If we adjusted `doc` itself, our + // changes would persist into the database. + let docToValidate = {}; + for (var prop in doc) { + // We omit prototype properties when cloning because they will not be valid + // and mongo omits them when saving to the database anyway. + if (Object.prototype.hasOwnProperty.call(doc, prop)) { + docToValidate[prop] = doc[prop]; + } + } + + // On the server, upserts are possible; SimpleSchema handles upserts pretty + // well by default, but it will not know about the fields in the selector, + // which are also stored in the database if an insert is performed. So we + // will allow these fields to be considered for validation by adding them + // to the $set in the modifier, while stripping out query selectors as these + // don't make it into the upserted document and break validation. + // This is no doubt prone to errors, but there probably isn't any better way + // right now. + if (Meteor.isServer && isUpsert && isObject(selector)) { + const set = docToValidate.$set || {}; + docToValidate.$set = flattenSelector(selector); + + if (!schemaAllowsId) delete docToValidate.$set._id; + Object.assign(docToValidate.$set, set); + } + // Set automatic values for validation on the client. + // On the server, we already updated doc with auto values, but on the client, + // we will add them to docToValidate for validation purposes only. + // This is because we want all actual values generated on the server. + if (Meteor.isClient && !isLocalCollection) { + schema.clean(docToValidate, { + autoConvert: false, + extendAutoValueContext, + filter: false, + getAutoValues: true, + isModifier: (type !== 'insert'), + mutate: true, // Clean the doc/modifier in place + removeEmptyStrings: false, + removeNullsFromArrays: false, + trimStrings: false, + }); + } + + // XXX Maybe move this into SimpleSchema + if (!validatedObjectWasInitiallyEmpty && isEmpty(docToValidate)) { + throw new Error('After filtering out keys not in the schema, your ' + + (type === 'update' ? 'modifier' : 'object') + + ' is now empty'); + } + + // Validate doc + let isValid; + if (options.validate === false) { + isValid = true; + } else { + isValid = validationContext.validate(docToValidate, { + modifier: (type === 'update' || type === 'upsert'), + upsert: isUpsert, + extendedCustomContext: { + isInsert: (type === 'insert'), + isUpdate: (type === 'update' && options.upsert !== true), + isUpsert, + userId, + isFromTrustedCode, + docId, + isLocalCollection, + ...(options.extendedCustomContext || {}), + }, + }); + } + + if (isValid) { + // Add the ID back + if (cachedId) { + doc._id = cachedId; + } + + // Update the args to reflect the cleaned doc + // XXX not sure this is necessary since we mutate + if (type === 'insert') { + args[0] = doc; + } else { + args[1] = doc; + } + + // If callback, set invalidKey when we get a mongo unique error + if (Meteor.isServer && hasCallback) { + args[last] = wrapCallbackForParsingMongoValidationErrors(validationContext, args[last]); + } + + return args; + } else { + error = getErrorObject(validationContext, Meteor.settings?.packages?.collection2?.disableCollectionNamesInValidation ? '' : `in ${collection._name} ${type}`); + if (callback) { + // insert/update/upsert pass `false` when there's an error, so we do that + callback(error, false); + } else { + throw error; + } + } +} + +function getErrorObject(context, appendToMessage = '') { + let message; + const invalidKeys = (typeof context.validationErrors === 'function') ? context.validationErrors() : context.invalidKeys(); + if (invalidKeys.length) { + const firstErrorKey = invalidKeys[0].name; + const firstErrorMessage = context.keyErrorMessage(firstErrorKey); + + // If the error is in a nested key, add the full key to the error message + // to be more helpful. + if (firstErrorKey.indexOf('.') === -1) { + message = firstErrorMessage; + } else { + message = `${firstErrorMessage} (${firstErrorKey})`; + } + } else { + message = 'Failed validation'; + } + message = `${message} ${appendToMessage}`.trim(); + const error = new Error(message); + error.invalidKeys = invalidKeys; + error.validationContext = context; + // If on the server, we add a sanitized error, too, in case we're + // called from a method. + if (Meteor.isServer) { + error.sanitizedError = new Meteor.Error(400, message, EJSON.stringify(error.invalidKeys)); + } + return error; +} + +function addUniqueError(context, errorMessage) { + const name = errorMessage.split('c2_')[1].split(' ')[0]; + const val = errorMessage.split('dup key:')[1].split('"')[1]; + + const addValidationErrorsPropName = (typeof context.addValidationErrors === 'function') ? 'addValidationErrors' : 'addInvalidKeys'; + context[addValidationErrorsPropName]([{ + name: name, + type: 'notUnique', + value: val + }]); +} + +function wrapCallbackForParsingMongoValidationErrors(validationContext, cb) { + return function wrappedCallbackForParsingMongoValidationErrors(...args) { + const error = args[0]; + if (error && + ((error.name === 'MongoError' && error.code === 11001) || error.message.indexOf('MongoError: E11000') !== -1) && + error.message.indexOf('c2_') !== -1) { + addUniqueError(validationContext, error.message); + args[0] = getErrorObject(validationContext); + } + return cb.apply(this, args); + }; +} + +function wrapCallbackForParsingServerErrors(validationContext, cb) { + const addValidationErrorsPropName = (typeof validationContext.addValidationErrors === 'function') ? 'addValidationErrors' : 'addInvalidKeys'; + return function wrappedCallbackForParsingServerErrors(...args) { + const error = args[0]; + // Handle our own validation errors + if (error instanceof Meteor.Error && + error.error === 400 && + error.reason === 'INVALID' && + typeof error.details === 'string') { + const invalidKeysFromServer = EJSON.parse(error.details); + validationContext[addValidationErrorsPropName](invalidKeysFromServer); + args[0] = getErrorObject(validationContext); + } + // Handle Mongo unique index errors, which are forwarded to the client as 409 errors + else if (error instanceof Meteor.Error && + error.error === 409 && + error.reason && + error.reason.indexOf('E11000') !== -1 && + error.reason.indexOf('c2_') !== -1) { + addUniqueError(validationContext, error.reason); + args[0] = getErrorObject(validationContext); + } + return cb.apply(this, args); + }; +} + +let alreadyInsecure = {}; +function keepInsecure(c) { + // If insecure package is in use, we need to add allow rules that return + // true. Otherwise, it would seemingly turn off insecure mode. + if (Package && Package.insecure && !alreadyInsecure[c._name]) { + c.allow({ + insert: function() { + return true; + }, + update: function() { + return true; + }, + remove: function () { + return true; + }, + fetch: [], + transform: null + }); + alreadyInsecure[c._name] = true; + } + // If insecure package is NOT in use, then adding the two deny functions + // does not have any effect on the main app's security paradigm. The + // user will still be required to add at least one allow function of her + // own for each operation for this collection. And the user may still add + // additional deny functions, but does not have to. +} + +let alreadyDefined = {}; +function defineDeny(c, options) { + if (!alreadyDefined[c._name]) { + + const isLocalCollection = (c._connection === null); + + // First define deny functions to extend doc with the results of clean + // and auto-values. This must be done with "transform: null" or we would be + // extending a clone of doc and therefore have no effect. + c.deny({ + insert: function(userId, doc) { + // Referenced doc is cleaned in place + c.simpleSchema(doc).clean(doc, { + mutate: true, + isModifier: false, + // We don't do these here because they are done on the client if desired + filter: false, + autoConvert: false, + removeEmptyStrings: false, + trimStrings: false, + extendAutoValueContext: { + isInsert: true, + isUpdate: false, + isUpsert: false, + userId: userId, + isFromTrustedCode: false, + docId: doc._id, + isLocalCollection: isLocalCollection + } + }); + + return false; + }, + update: function(userId, doc, fields, modifier) { + // Referenced modifier is cleaned in place + c.simpleSchema(modifier).clean(modifier, { + mutate: true, + isModifier: true, + // We don't do these here because they are done on the client if desired + filter: false, + autoConvert: false, + removeEmptyStrings: false, + trimStrings: false, + extendAutoValueContext: { + isInsert: false, + isUpdate: true, + isUpsert: false, + userId: userId, + isFromTrustedCode: false, + docId: doc && doc._id, + isLocalCollection: isLocalCollection + } + }); + + return false; + }, + fetch: ['_id'], + transform: null + }); + + // Second define deny functions to validate again on the server + // for client-initiated inserts and updates. These should be + // called after the clean/auto-value functions since we're adding + // them after. These must *not* have "transform: null" if options.transform is true because + // we need to pass the doc through any transforms to be sure + // that custom types are properly recognized for type validation. + c.deny({ + insert: function(userId, doc) { + // We pass the false options because we will have done them on client if desired + doValidate( + c, + 'insert', + [ + doc, + { + trimStrings: false, + removeEmptyStrings: false, + filter: false, + autoConvert: false + }, + function(error) { + if (error) { + throw new Meteor.Error(400, 'INVALID', EJSON.stringify(error.invalidKeys)); + } + } + ], + false, // getAutoValues + userId, + false // isFromTrustedCode + ); + + return false; + }, + update: function(userId, doc, fields, modifier) { + // NOTE: This will never be an upsert because client-side upserts + // are not allowed once you define allow/deny functions. + // We pass the false options because we will have done them on client if desired + doValidate( + c, + 'update', + [ + {_id: doc && doc._id}, + modifier, + { + trimStrings: false, + removeEmptyStrings: false, + filter: false, + autoConvert: false + }, + function(error) { + if (error) { + throw new Meteor.Error(400, 'INVALID', EJSON.stringify(error.invalidKeys)); + } + } + ], + false, // getAutoValues + userId, + false // isFromTrustedCode + ); + + return false; + }, + fetch: ['_id'], + ...(options.transform === true ? {} : {transform: null}), + }); + + // note that we've already done this collection so that we don't do it again + // if attachSchema is called again + alreadyDefined[c._name] = true; + } +} + +function extendSchema(s1, s2) { + if (s2.version >= 2) { + const ss = new SimpleSchema(s1); + ss.extend(s2); + return ss; + } else { + return new SimpleSchema([ s1, s2 ]); + } +} + +export default Collection2; diff --git a/app/packages/collection2/lib.js b/app/packages/collection2/lib.js new file mode 100644 index 00000000..057195d2 --- /dev/null +++ b/app/packages/collection2/lib.js @@ -0,0 +1,31 @@ +export function flattenSelector(selector) { + // If selector uses $and format, convert to plain object selector + if (Array.isArray(selector.$and)) { + selector.$and.forEach(sel => { + Object.assign(selector, flattenSelector(sel)); + }); + + delete selector.$and + } + + const obj = {} + + Object.entries(selector).forEach(([key, value]) => { + // Ignoring logical selectors (https://docs.mongodb.com/manual/reference/operator/query/#logical) + if (!key.startsWith("$")) { + if (typeof value === 'object' && value !== null) { + if (value.$eq !== undefined) { + obj[key] = value.$eq + } else if (Array.isArray(value.$in) && value.$in.length === 1) { + obj[key] = value.$in[0] + } else if (Object.keys(value).every(v => !(typeof v === "string" && v.startsWith("$")))) { + obj[key] = value + } + } else { + obj[key] = value + } + } + }) + + return obj +} diff --git a/app/packages/collection2/package.js b/app/packages/collection2/package.js new file mode 100644 index 00000000..4177ad10 --- /dev/null +++ b/app/packages/collection2/package.js @@ -0,0 +1,33 @@ +/* global Package */ + +Package.describe({ + name: "aldeed:collection2", + summary: "Automatic validation of Meteor Mongo insert and update operations on the client and server", + version: "3.5.0", + documentation: "../../README.md", + git: "https://github.com/aldeed/meteor-collection2.git" +}); + +Npm.depends({ + 'lodash.isempty': '4.4.0', + 'lodash.isequal': '4.5.0', + 'lodash.isobject': '3.0.2', +}); + +Package.onUse(function(api) { + api.versionsFrom(['1.12.1', '2.3']); + api.use('mongo'); + api.imply('mongo'); + api.use('minimongo'); + api.use('ejson'); + api.use('raix:eventemitter@1.0.0'); + api.use('ecmascript'); + api.use('tmeasday:check-npm-versions@1.0.2'); + + // Allow us to detect 'insecure'. + api.use('insecure@1.0.7', {weak: true}); + + api.mainModule('collection2.js'); + + api.export('Collection2'); +});