// @flow // This class handles schema validation, persistence, and modification. // // Each individual Schema object should be immutable. The helpers to // do things with the Schema just return a new schema when the schema // is changed. // // The canonical place to store this Schema is in the database itself, // in a _SCHEMA collection. This is not the right way to do it for an // open source framework, but it's backward compatible, so we're // keeping it this way for now. // // In API-handling code, you should only use the Schema class via the // DatabaseController. This will let us replace the schema logic for // different databases. // TODO: hide all schema logic inside the database adapter. // @flow-disable-next const Parse = require('parse/node').Parse; import { StorageAdapter } from '../Adapters/Storage/StorageAdapter'; import DatabaseController from './DatabaseController'; import type { Schema, SchemaFields, ClassLevelPermissions, SchemaField, LoadSchemaOptions, } from './types'; const defaultColumns: { [string]: SchemaFields } = Object.freeze({ // Contain the default columns for every parse object type (except _Join collection) _Default: { objectId: { type: 'String' }, createdAt: { type: 'Date' }, updatedAt: { type: 'Date' }, ACL: { type: 'ACL' }, }, // The additional default columns for the _User collection (in addition to DefaultCols) _User: { username: { type: 'String' }, password: { type: 'String' }, email: { type: 'String' }, emailVerified: { type: 'Boolean' }, authData: { type: 'Object' }, }, // The additional default columns for the _Installation collection (in addition to DefaultCols) _Installation: { installationId: { type: 'String' }, deviceToken: { type: 'String' }, channels: { type: 'Array' }, deviceType: { type: 'String' }, pushType: { type: 'String' }, GCMSenderId: { type: 'String' }, timeZone: { type: 'String' }, localeIdentifier: { type: 'String' }, badge: { type: 'Number' }, appVersion: { type: 'String' }, appName: { type: 'String' }, appIdentifier: { type: 'String' }, parseVersion: { type: 'String' }, }, // The additional default columns for the _Role collection (in addition to DefaultCols) _Role: { name: { type: 'String' }, users: { type: 'Relation', targetClass: '_User' }, roles: { type: 'Relation', targetClass: '_Role' }, }, // The additional default columns for the _Session collection (in addition to DefaultCols) _Session: { restricted: { type: 'Boolean' }, user: { type: 'Pointer', targetClass: '_User' }, installationId: { type: 'String' }, sessionToken: { type: 'String' }, expiresAt: { type: 'Date' }, createdWith: { type: 'Object' }, }, _Product: { productIdentifier: { type: 'String' }, download: { type: 'File' }, downloadName: { type: 'String' }, icon: { type: 'File' }, order: { type: 'Number' }, title: { type: 'String' }, subtitle: { type: 'String' }, }, _PushStatus: { pushTime: { type: 'String' }, source: { type: 'String' }, // rest or webui query: { type: 'String' }, // the stringified JSON query payload: { type: 'String' }, // the stringified JSON payload, title: { type: 'String' }, expiry: { type: 'Number' }, expiration_interval: { type: 'Number' }, status: { type: 'String' }, numSent: { type: 'Number' }, numFailed: { type: 'Number' }, pushHash: { type: 'String' }, errorMessage: { type: 'Object' }, sentPerType: { type: 'Object' }, failedPerType: { type: 'Object' }, sentPerUTCOffset: { type: 'Object' }, failedPerUTCOffset: { type: 'Object' }, count: { type: 'Number' }, // tracks # of batches queued and pending }, _JobStatus: { jobName: { type: 'String' }, source: { type: 'String' }, status: { type: 'String' }, message: { type: 'String' }, params: { type: 'Object' }, // params received when calling the job finishedAt: { type: 'Date' }, }, _JobSchedule: { jobName: { type: 'String' }, description: { type: 'String' }, params: { type: 'String' }, startAfter: { type: 'String' }, daysOfWeek: { type: 'Array' }, timeOfDay: { type: 'String' }, lastRun: { type: 'Number' }, repeatMinutes: { type: 'Number' }, }, _Hooks: { functionName: { type: 'String' }, className: { type: 'String' }, triggerName: { type: 'String' }, url: { type: 'String' }, }, _GlobalConfig: { objectId: { type: 'String' }, params: { type: 'Object' }, }, _Audience: { objectId: { type: 'String' }, name: { type: 'String' }, query: { type: 'String' }, //storing query as JSON string to prevent "Nested keys should not contain the '$' or '.' characters" error lastUsed: { type: 'Date' }, timesUsed: { type: 'Number' }, }, }); const requiredColumns = Object.freeze({ _Product: ['productIdentifier', 'icon', 'order', 'title', 'subtitle'], _Role: ['name', 'ACL'], }); const systemClasses = Object.freeze([ '_User', '_Installation', '_Role', '_Session', '_Product', '_PushStatus', '_JobStatus', '_JobSchedule', '_Audience', ]); const volatileClasses = Object.freeze([ '_JobStatus', '_PushStatus', '_Hooks', '_GlobalConfig', '_JobSchedule', '_Audience', ]); // 10 alpha numberic chars + uppercase const userIdRegex = /^[a-zA-Z0-9]{10}$/; // Anything that start with role const roleRegex = /^role:.*/; // * permission const publicRegex = /^\*$/; const requireAuthenticationRegex = /^requiresAuthentication$/; const permissionKeyRegex = Object.freeze([ userIdRegex, roleRegex, publicRegex, requireAuthenticationRegex, ]); function verifyPermissionKey(key) { const result = permissionKeyRegex.reduce((isGood, regEx) => { isGood = isGood || key.match(regEx) != null; return isGood; }, false); if (!result) { throw new Parse.Error( Parse.Error.INVALID_JSON, `'${key}' is not a valid key for class level permissions` ); } } const CLPValidKeys = Object.freeze([ 'find', 'count', 'get', 'create', 'update', 'delete', 'addField', 'readUserFields', 'writeUserFields', ]); function validateCLP(perms: ClassLevelPermissions, fields: SchemaFields) { if (!perms) { return; } Object.keys(perms).forEach(operation => { if (CLPValidKeys.indexOf(operation) == -1) { throw new Parse.Error( Parse.Error.INVALID_JSON, `${operation} is not a valid operation for class level permissions` ); } if (!perms[operation]) { return; } if (operation === 'readUserFields' || operation === 'writeUserFields') { if (!Array.isArray(perms[operation])) { // @flow-disable-next throw new Parse.Error( Parse.Error.INVALID_JSON, `'${ perms[operation] }' is not a valid value for class level permissions ${operation}` ); } else { perms[operation].forEach(key => { if ( !fields[key] || fields[key].type != 'Pointer' || fields[key].targetClass != '_User' ) { throw new Parse.Error( Parse.Error.INVALID_JSON, `'${key}' is not a valid column for class level pointer permissions ${operation}` ); } }); } return; } // @flow-disable-next Object.keys(perms[operation]).forEach(key => { verifyPermissionKey(key); // @flow-disable-next const perm = perms[operation][key]; if (perm !== true) { // @flow-disable-next throw new Parse.Error( Parse.Error.INVALID_JSON, `'${perm}' is not a valid value for class level permissions ${operation}:${key}:${perm}` ); } }); }); } const joinClassRegex = /^_Join:[A-Za-z0-9_]+:[A-Za-z0-9_]+/; const classAndFieldRegex = /^[A-Za-z][A-Za-z0-9_]*$/; function classNameIsValid(className: string): boolean { // Valid classes must: return ( // Be one of _User, _Installation, _Role, _Session OR systemClasses.indexOf(className) > -1 || // Be a join table OR joinClassRegex.test(className) || // Include only alpha-numeric and underscores, and not start with an underscore or number fieldNameIsValid(className) ); } // Valid fields must be alpha-numeric, and not start with an underscore or number function fieldNameIsValid(fieldName: string): boolean { return classAndFieldRegex.test(fieldName); } // Checks that it's not trying to clobber one of the default fields of the class. function fieldNameIsValidForClass( fieldName: string, className: string ): boolean { if (!fieldNameIsValid(fieldName)) { return false; } if (defaultColumns._Default[fieldName]) { return false; } if (defaultColumns[className] && defaultColumns[className][fieldName]) { return false; } return true; } function invalidClassNameMessage(className: string): string { return ( 'Invalid classname: ' + className + ', classnames can only have alphanumeric characters and _, and must start with an alpha character ' ); } const invalidJsonError = new Parse.Error( Parse.Error.INVALID_JSON, 'invalid JSON' ); const validNonRelationOrPointerTypes = [ 'Number', 'String', 'Boolean', 'Date', 'Object', 'Array', 'GeoPoint', 'File', 'Bytes', 'Polygon', ]; // Returns an error suitable for throwing if the type is invalid const fieldTypeIsInvalid = ({ type, targetClass }) => { if (['Pointer', 'Relation'].indexOf(type) >= 0) { if (!targetClass) { return new Parse.Error(135, `type ${type} needs a class name`); } else if (typeof targetClass !== 'string') { return invalidJsonError; } else if (!classNameIsValid(targetClass)) { return new Parse.Error( Parse.Error.INVALID_CLASS_NAME, invalidClassNameMessage(targetClass) ); } else { return undefined; } } if (typeof type !== 'string') { return invalidJsonError; } if (validNonRelationOrPointerTypes.indexOf(type) < 0) { return new Parse.Error( Parse.Error.INCORRECT_TYPE, `invalid field type: ${type}` ); } return undefined; }; const convertSchemaToAdapterSchema = (schema: any) => { schema = injectDefaultSchema(schema); delete schema.fields.ACL; schema.fields._rperm = { type: 'Array' }; schema.fields._wperm = { type: 'Array' }; if (schema.className === '_User') { delete schema.fields.password; schema.fields._hashed_password = { type: 'String' }; } return schema; }; const convertAdapterSchemaToParseSchema = ({ ...schema }) => { delete schema.fields._rperm; delete schema.fields._wperm; schema.fields.ACL = { type: 'ACL' }; if (schema.className === '_User') { delete schema.fields.authData; //Auth data is implicit delete schema.fields._hashed_password; schema.fields.password = { type: 'String' }; } if (schema.indexes && Object.keys(schema.indexes).length === 0) { delete schema.indexes; } return schema; }; class SchemaData { __data: any; constructor(allSchemas = []) { this.__data = {}; allSchemas.forEach(schema => { Object.defineProperty(this, schema.className, { get: () => { if (!this.__data[schema.className]) { const data = {}; data.fields = injectDefaultSchema(schema).fields; data.classLevelPermissions = schema.classLevelPermissions; data.indexes = schema.indexes; this.__data[schema.className] = data; } return this.__data[schema.className]; }, }); }); // Inject the in-memory classes volatileClasses.forEach(className => { Object.defineProperty(this, className, { get: () => { if (!this.__data[className]) { const schema = injectDefaultSchema({ className, fields: {}, classLevelPermissions: {}, }); const data = {}; data.fields = schema.fields; data.classLevelPermissions = schema.classLevelPermissions; data.indexes = schema.indexes; this.__data[className] = data; } return this.__data[className]; }, }); }); } } const injectDefaultSchema = ({ className, fields, classLevelPermissions, indexes, }: Schema) => { const defaultSchema: Schema = { className, fields: { ...defaultColumns._Default, ...(defaultColumns[className] || {}), ...fields, }, classLevelPermissions, }; if (indexes && Object.keys(indexes).length !== 0) { defaultSchema.indexes = indexes; } return defaultSchema; }; const _HooksSchema = { className: '_Hooks', fields: defaultColumns._Hooks }; const _GlobalConfigSchema = { className: '_GlobalConfig', fields: defaultColumns._GlobalConfig, }; const _PushStatusSchema = convertSchemaToAdapterSchema( injectDefaultSchema({ className: '_PushStatus', fields: {}, classLevelPermissions: {}, }) ); const _JobStatusSchema = convertSchemaToAdapterSchema( injectDefaultSchema({ className: '_JobStatus', fields: {}, classLevelPermissions: {}, }) ); const _JobScheduleSchema = convertSchemaToAdapterSchema( injectDefaultSchema({ className: '_JobSchedule', fields: {}, classLevelPermissions: {}, }) ); const _AudienceSchema = convertSchemaToAdapterSchema( injectDefaultSchema({ className: '_Audience', fields: defaultColumns._Audience, classLevelPermissions: {}, }) ); const VolatileClassesSchemas = [ _HooksSchema, _JobStatusSchema, _JobScheduleSchema, _PushStatusSchema, _GlobalConfigSchema, _AudienceSchema, ]; const dbTypeMatchesObjectType = ( dbType: SchemaField | string, objectType: SchemaField ) => { if (dbType.type !== objectType.type) return false; if (dbType.targetClass !== objectType.targetClass) return false; if (dbType === objectType.type) return true; if (dbType.type === objectType.type) return true; return false; }; const typeToString = (type: SchemaField | string): string => { if (typeof type === 'string') { return type; } if (type.targetClass) { return `${type.type}<${type.targetClass}>`; } return `${type.type}`; }; // Stores the entire schema of the app in a weird hybrid format somewhere between // the mongo format and the Parse format. Soon, this will all be Parse format. export default class SchemaController { _dbAdapter: StorageAdapter; schemaData: { [string]: Schema }; _cache: any; reloadDataPromise: Promise; constructor(databaseAdapter: StorageAdapter, schemaCache: any) { this._dbAdapter = databaseAdapter; this._cache = schemaCache; this.schemaData = new SchemaData(); } reloadData(options: LoadSchemaOptions = { clearCache: false }): Promise { let promise = Promise.resolve(); if (options.clearCache) { promise = promise.then(() => { return this._cache.clear(); }); } if (this.reloadDataPromise && !options.clearCache) { return this.reloadDataPromise; } this.reloadDataPromise = promise .then(() => { return this.getAllClasses(options).then( allSchemas => { this.schemaData = new SchemaData(allSchemas); delete this.reloadDataPromise; }, err => { this.schemaData = new SchemaData(); delete this.reloadDataPromise; throw err; } ); }) .then(() => {}); return this.reloadDataPromise; } getAllClasses( options: LoadSchemaOptions = { clearCache: false } ): Promise> { let promise = Promise.resolve(); if (options.clearCache) { promise = this._cache.clear(); } return promise .then(() => { return this._cache.getAllClasses(); }) .then(allClasses => { if (allClasses && allClasses.length && !options.clearCache) { return Promise.resolve(allClasses); } return this._dbAdapter .getAllClasses() .then(allSchemas => allSchemas.map(injectDefaultSchema)) .then(allSchemas => { return this._cache.setAllClasses(allSchemas).then(() => { return allSchemas; }); }); }); } getOneSchema( className: string, allowVolatileClasses: boolean = false, options: LoadSchemaOptions = { clearCache: false } ): Promise { let promise = Promise.resolve(); if (options.clearCache) { promise = this._cache.clear(); } return promise.then(() => { if (allowVolatileClasses && volatileClasses.indexOf(className) > -1) { const data = this.schemaData[className]; return Promise.resolve({ className, fields: data.fields, classLevelPermissions: data.classLevelPermissions, indexes: data.indexes, }); } return this._cache.getOneSchema(className).then(cached => { if (cached && !options.clearCache) { return Promise.resolve(cached); } return this._dbAdapter .getClass(className) .then(injectDefaultSchema) .then(result => { return this._cache.setOneSchema(className, result).then(() => { return result; }); }); }); }); } // Create a new class that includes the three default fields. // ACL is an implicit column that does not get an entry in the // _SCHEMAS database. Returns a promise that resolves with the // created schema, in mongo format. // on success, and rejects with an error on fail. Ensure you // have authorization (master key, or client class creation // enabled) before calling this function. addClassIfNotExists( className: string, fields: SchemaFields = {}, classLevelPermissions: any, indexes: any = {} ): Promise { var validationError = this.validateNewClass( className, fields, classLevelPermissions ); if (validationError) { return Promise.reject(validationError); } return this._dbAdapter .createClass( className, convertSchemaToAdapterSchema({ fields, classLevelPermissions, indexes, className, }) ) .then(convertAdapterSchemaToParseSchema) .then(res => { return this._cache.clear().then(() => { return Promise.resolve(res); }); }) .catch(error => { if (error && error.code === Parse.Error.DUPLICATE_VALUE) { throw new Parse.Error( Parse.Error.INVALID_CLASS_NAME, `Class ${className} already exists.` ); } else { throw error; } }); } updateClass( className: string, submittedFields: SchemaFields, classLevelPermissions: any, indexes: any, database: DatabaseController ) { return this.getOneSchema(className) .then(schema => { const existingFields = schema.fields; Object.keys(submittedFields).forEach(name => { const field = submittedFields[name]; if (existingFields[name] && field.__op !== 'Delete') { throw new Parse.Error(255, `Field ${name} exists, cannot update.`); } if (!existingFields[name] && field.__op === 'Delete') { throw new Parse.Error( 255, `Field ${name} does not exist, cannot delete.` ); } }); delete existingFields._rperm; delete existingFields._wperm; const newSchema = buildMergedSchemaObject( existingFields, submittedFields ); const defaultFields = defaultColumns[className] || defaultColumns._Default; const fullNewSchema = Object.assign({}, newSchema, defaultFields); const validationError = this.validateSchemaData( className, newSchema, classLevelPermissions, Object.keys(existingFields) ); if (validationError) { throw new Parse.Error(validationError.code, validationError.error); } // Finally we have checked to make sure the request is valid and we can start deleting fields. // Do all deletions first, then a single save to _SCHEMA collection to handle all additions. const deletedFields: string[] = []; const insertedFields = []; Object.keys(submittedFields).forEach(fieldName => { if (submittedFields[fieldName].__op === 'Delete') { deletedFields.push(fieldName); } else { insertedFields.push(fieldName); } }); let deletePromise = Promise.resolve(); if (deletedFields.length > 0) { deletePromise = this.deleteFields(deletedFields, className, database); } return ( deletePromise // Delete Everything .then(() => this.reloadData({ clearCache: true })) // Reload our Schema, so we have all the new values .then(() => { const promises = insertedFields.map(fieldName => { const type = submittedFields[fieldName]; return this.enforceFieldExists(className, fieldName, type); }); return Promise.all(promises); }) .then(() => this.setPermissions(className, classLevelPermissions, newSchema) ) .then(() => this._dbAdapter.setIndexesWithSchemaFormat( className, indexes, schema.indexes, fullNewSchema ) ) .then(() => this.reloadData({ clearCache: true })) //TODO: Move this logic into the database adapter .then(() => { const schema = this.schemaData[className]; const reloadedSchema: Schema = { className: className, fields: schema.fields, classLevelPermissions: schema.classLevelPermissions, }; if (schema.indexes && Object.keys(schema.indexes).length !== 0) { reloadedSchema.indexes = schema.indexes; } return reloadedSchema; }) ); }) .catch(error => { if (error === undefined) { throw new Parse.Error( Parse.Error.INVALID_CLASS_NAME, `Class ${className} does not exist.` ); } else { throw error; } }); } // Returns a promise that resolves successfully to the new schema // object or fails with a reason. enforceClassExists(className: string): Promise { if (this.schemaData[className]) { return Promise.resolve(this); } // We don't have this class. Update the schema return ( this.addClassIfNotExists(className) // The schema update succeeded. Reload the schema .then(() => this.reloadData({ clearCache: true })) .catch(() => { // The schema update failed. This can be okay - it might // have failed because there's a race condition and a different // client is making the exact same schema update that we want. // So just reload the schema. return this.reloadData({ clearCache: true }); }) .then(() => { // Ensure that the schema now validates if (this.schemaData[className]) { return this; } else { throw new Parse.Error( Parse.Error.INVALID_JSON, `Failed to add ${className}` ); } }) .catch(() => { // The schema still doesn't validate. Give up throw new Parse.Error( Parse.Error.INVALID_JSON, 'schema class name does not revalidate' ); }) ); } validateNewClass( className: string, fields: SchemaFields = {}, classLevelPermissions: any ): any { if (this.schemaData[className]) { throw new Parse.Error( Parse.Error.INVALID_CLASS_NAME, `Class ${className} already exists.` ); } if (!classNameIsValid(className)) { return { code: Parse.Error.INVALID_CLASS_NAME, error: invalidClassNameMessage(className), }; } return this.validateSchemaData( className, fields, classLevelPermissions, [] ); } validateSchemaData( className: string, fields: SchemaFields, classLevelPermissions: ClassLevelPermissions, existingFieldNames: Array ) { for (const fieldName in fields) { if (existingFieldNames.indexOf(fieldName) < 0) { if (!fieldNameIsValid(fieldName)) { return { code: Parse.Error.INVALID_KEY_NAME, error: 'invalid field name: ' + fieldName, }; } if (!fieldNameIsValidForClass(fieldName, className)) { return { code: 136, error: 'field ' + fieldName + ' cannot be added', }; } const error = fieldTypeIsInvalid(fields[fieldName]); if (error) return { code: error.code, error: error.message }; } } for (const fieldName in defaultColumns[className]) { fields[fieldName] = defaultColumns[className][fieldName]; } const geoPoints = Object.keys(fields).filter( key => fields[key] && fields[key].type === 'GeoPoint' ); if (geoPoints.length > 1) { return { code: Parse.Error.INCORRECT_TYPE, error: 'currently, only one GeoPoint field may exist in an object. Adding ' + geoPoints[1] + ' when ' + geoPoints[0] + ' already exists.', }; } validateCLP(classLevelPermissions, fields); } // Sets the Class-level permissions for a given className, which must exist. setPermissions(className: string, perms: any, newSchema: SchemaFields) { if (typeof perms === 'undefined') { return Promise.resolve(); } validateCLP(perms, newSchema); return this._dbAdapter.setClassLevelPermissions(className, perms); } // Returns a promise that resolves successfully to the new schema // object if the provided className-fieldName-type tuple is valid. // The className must already be validated. // If 'freeze' is true, refuse to update the schema for this field. enforceFieldExists( className: string, fieldName: string, type: string | SchemaField ) { if (fieldName.indexOf('.') > 0) { // subdocument key (x.y) => ok if x is of type 'object' fieldName = fieldName.split('.')[0]; type = 'Object'; } if (!fieldNameIsValid(fieldName)) { throw new Parse.Error( Parse.Error.INVALID_KEY_NAME, `Invalid field name: ${fieldName}.` ); } // If someone tries to create a new field with null/undefined as the value, return; if (!type) { return Promise.resolve(this); } return this.reloadData().then(() => { const expectedType = this.getExpectedType(className, fieldName); if (typeof type === 'string') { type = { type }; } if (expectedType) { if (!dbTypeMatchesObjectType(expectedType, type)) { throw new Parse.Error( Parse.Error.INCORRECT_TYPE, `schema mismatch for ${className}.${fieldName}; expected ${typeToString( expectedType )} but got ${typeToString(type)}` ); } return this; } return this._dbAdapter .addFieldIfNotExists(className, fieldName, type) .then( () => { // The update succeeded. Reload the schema return this.reloadData({ clearCache: true }); }, error => { if (error.code == Parse.Error.INCORRECT_TYPE) { // Make sure that we throw errors when it is appropriate to do so. throw error; } // The update failed. This can be okay - it might have been a race // condition where another client updated the schema in the same // way that we wanted to. So, just reload the schema return this.reloadData({ clearCache: true }); } ) .then(() => { // Ensure that the schema now validates const expectedType = this.getExpectedType(className, fieldName); if (typeof type === 'string') { type = { type }; } if (!expectedType || !dbTypeMatchesObjectType(expectedType, type)) { throw new Parse.Error( Parse.Error.INVALID_JSON, `Could not add field ${fieldName}` ); } // Remove the cached schema this._cache.clear(); return this; }); }); } // maintain compatibility deleteField( fieldName: string, className: string, database: DatabaseController ) { return this.deleteFields([fieldName], className, database); } // Delete fields, and remove that data from all objects. This is intended // to remove unused fields, if other writers are writing objects that include // this field, the field may reappear. Returns a Promise that resolves with // no object on success, or rejects with { code, error } on failure. // Passing the database and prefix is necessary in order to drop relation collections // and remove fields from objects. Ideally the database would belong to // a database adapter and this function would close over it or access it via member. deleteFields( fieldNames: Array, className: string, database: DatabaseController ) { if (!classNameIsValid(className)) { throw new Parse.Error( Parse.Error.INVALID_CLASS_NAME, invalidClassNameMessage(className) ); } fieldNames.forEach(fieldName => { if (!fieldNameIsValid(fieldName)) { throw new Parse.Error( Parse.Error.INVALID_KEY_NAME, `invalid field name: ${fieldName}` ); } //Don't allow deleting the default fields. if (!fieldNameIsValidForClass(fieldName, className)) { throw new Parse.Error(136, `field ${fieldName} cannot be changed`); } }); return this.getOneSchema(className, false, { clearCache: true }) .catch(error => { if (error === undefined) { throw new Parse.Error( Parse.Error.INVALID_CLASS_NAME, `Class ${className} does not exist.` ); } else { throw error; } }) .then(schema => { fieldNames.forEach(fieldName => { if (!schema.fields[fieldName]) { throw new Parse.Error( 255, `Field ${fieldName} does not exist, cannot delete.` ); } }); const schemaFields = { ...schema.fields }; return database.adapter .deleteFields(className, schema, fieldNames) .then(() => { return Promise.all( fieldNames.map(fieldName => { const field = schemaFields[fieldName]; if (field && field.type === 'Relation') { //For relations, drop the _Join table return database.adapter.deleteClass( `_Join:${fieldName}:${className}` ); } return Promise.resolve(); }) ); }); }) .then(() => { this._cache.clear(); }); } // Validates an object provided in REST format. // Returns a promise that resolves to the new schema if this object is // valid. validateObject(className: string, object: any, query: any) { let geocount = 0; let promise = this.enforceClassExists(className); for (const fieldName in object) { if (object[fieldName] === undefined) { continue; } const expected = getType(object[fieldName]); if (expected === 'GeoPoint') { geocount++; } if (geocount > 1) { // Make sure all field validation operations run before we return. // If not - we are continuing to run logic, but already provided response from the server. return promise.then(() => { return Promise.reject( new Parse.Error( Parse.Error.INCORRECT_TYPE, 'there can only be one geopoint field in a class' ) ); }); } if (!expected) { continue; } if (fieldName === 'ACL') { // Every object has ACL implicitly. continue; } promise = promise.then(schema => schema.enforceFieldExists(className, fieldName, expected) ); } promise = thenValidateRequiredColumns(promise, className, object, query); return promise; } // Validates that all the properties are set for the object validateRequiredColumns(className: string, object: any, query: any) { const columns = requiredColumns[className]; if (!columns || columns.length == 0) { return Promise.resolve(this); } const missingColumns = columns.filter(function(column) { if (query && query.objectId) { if (object[column] && typeof object[column] === 'object') { // Trying to delete a required column return object[column].__op == 'Delete'; } // Not trying to do anything there return false; } return !object[column]; }); if (missingColumns.length > 0) { throw new Parse.Error( Parse.Error.INCORRECT_TYPE, missingColumns[0] + ' is required.' ); } return Promise.resolve(this); } // Validates the base CLP for an operation testBaseCLP(className: string, aclGroup: string[], operation: string) { const classSchema = this.schemaData[className]; if ( !classSchema || !classSchema.classLevelPermissions || !classSchema.classLevelPermissions[operation] ) { return true; } const perms = classSchema.classLevelPermissions[operation]; // Handle the public scenario quickly if (perms['*']) { return true; } // Check permissions against the aclGroup provided (array of userId/roles) if ( aclGroup.some(acl => { return perms[acl] === true; }) ) { return true; } return false; } // Validates an operation passes class-level-permissions set in the schema validatePermission(className: string, aclGroup: string[], operation: string) { if (this.testBaseCLP(className, aclGroup, operation)) { return Promise.resolve(); } const classSchema = this.schemaData[className]; if ( !classSchema || !classSchema.classLevelPermissions || !classSchema.classLevelPermissions[operation] ) { return true; } const classPerms = classSchema.classLevelPermissions; const perms = classSchema.classLevelPermissions[operation]; // If only for authenticated users // make sure we have an aclGroup if (perms['requiresAuthentication']) { // If aclGroup has * (public) if (!aclGroup || aclGroup.length == 0) { throw new Parse.Error( Parse.Error.OBJECT_NOT_FOUND, 'Permission denied, user needs to be authenticated.' ); } else if (aclGroup.indexOf('*') > -1 && aclGroup.length == 1) { throw new Parse.Error( Parse.Error.OBJECT_NOT_FOUND, 'Permission denied, user needs to be authenticated.' ); } // requiresAuthentication passed, just move forward // probably would be wise at some point to rename to 'authenticatedUser' return Promise.resolve(); } // No matching CLP, let's check the Pointer permissions // And handle those later const permissionField = ['get', 'find', 'count'].indexOf(operation) > -1 ? 'readUserFields' : 'writeUserFields'; // Reject create when write lockdown if (permissionField == 'writeUserFields' && operation == 'create') { throw new Parse.Error( Parse.Error.OPERATION_FORBIDDEN, `Permission denied for action ${operation} on class ${className}.` ); } // Process the readUserFields later if ( Array.isArray(classPerms[permissionField]) && classPerms[permissionField].length > 0 ) { return Promise.resolve(); } throw new Parse.Error( Parse.Error.OPERATION_FORBIDDEN, `Permission denied for action ${operation} on class ${className}.` ); } // Returns the expected type for a className+key combination // or undefined if the schema is not set getExpectedType( className: string, fieldName: string ): ?(SchemaField | string) { if (this.schemaData[className]) { const expectedType = this.schemaData[className].fields[fieldName]; return expectedType === 'map' ? 'Object' : expectedType; } return undefined; } // Checks if a given class is in the schema. hasClass(className: string) { return this.reloadData().then(() => !!this.schemaData[className]); } } // Returns a promise for a new Schema. const load = ( dbAdapter: StorageAdapter, schemaCache: any, options: any ): Promise => { const schema = new SchemaController(dbAdapter, schemaCache); return schema.reloadData(options).then(() => schema); }; // Builds a new schema (in schema API response format) out of an // existing mongo schema + a schemas API put request. This response // does not include the default fields, as it is intended to be passed // to mongoSchemaFromFieldsAndClassName. No validation is done here, it // is done in mongoSchemaFromFieldsAndClassName. function buildMergedSchemaObject( existingFields: SchemaFields, putRequest: any ): SchemaFields { const newSchema = {}; // @flow-disable-next const sysSchemaField = Object.keys(defaultColumns).indexOf(existingFields._id) === -1 ? [] : Object.keys(defaultColumns[existingFields._id]); for (const oldField in existingFields) { if ( oldField !== '_id' && oldField !== 'ACL' && oldField !== 'updatedAt' && oldField !== 'createdAt' && oldField !== 'objectId' ) { if ( sysSchemaField.length > 0 && sysSchemaField.indexOf(oldField) !== -1 ) { continue; } const fieldIsDeleted = putRequest[oldField] && putRequest[oldField].__op === 'Delete'; if (!fieldIsDeleted) { newSchema[oldField] = existingFields[oldField]; } } } for (const newField in putRequest) { if (newField !== 'objectId' && putRequest[newField].__op !== 'Delete') { if ( sysSchemaField.length > 0 && sysSchemaField.indexOf(newField) !== -1 ) { continue; } newSchema[newField] = putRequest[newField]; } } return newSchema; } // Given a schema promise, construct another schema promise that // validates this field once the schema loads. function thenValidateRequiredColumns(schemaPromise, className, object, query) { return schemaPromise.then(schema => { return schema.validateRequiredColumns(className, object, query); }); } // Gets the type from a REST API formatted object, where 'type' is // extended past javascript types to include the rest of the Parse // type system. // The output should be a valid schema value. // TODO: ensure that this is compatible with the format used in Open DB function getType(obj: any): ?(SchemaField | string) { const type = typeof obj; switch (type) { case 'boolean': return 'Boolean'; case 'string': return 'String'; case 'number': return 'Number'; case 'map': case 'object': if (!obj) { return undefined; } return getObjectType(obj); case 'function': case 'symbol': case 'undefined': default: throw 'bad obj: ' + obj; } } // This gets the type for non-JSON types like pointers and files, but // also gets the appropriate type for $ operators. // Returns null if the type is unknown. function getObjectType(obj): ?(SchemaField | string) { if (obj instanceof Array) { return 'Array'; } if (obj.__type) { switch (obj.__type) { case 'Pointer': if (obj.className) { return { type: 'Pointer', targetClass: obj.className, }; } break; case 'Relation': if (obj.className) { return { type: 'Relation', targetClass: obj.className, }; } break; case 'File': if (obj.name) { return 'File'; } break; case 'Date': if (obj.iso) { return 'Date'; } break; case 'GeoPoint': if (obj.latitude != null && obj.longitude != null) { return 'GeoPoint'; } break; case 'Bytes': if (obj.base64) { return 'Bytes'; } break; case 'Polygon': if (obj.coordinates) { return 'Polygon'; } break; } throw new Parse.Error( Parse.Error.INCORRECT_TYPE, 'This is not a valid ' + obj.__type ); } if (obj['$ne']) { return getObjectType(obj['$ne']); } if (obj.__op) { switch (obj.__op) { case 'Increment': return 'Number'; case 'Delete': return null; case 'Add': case 'AddUnique': case 'Remove': return 'Array'; case 'AddRelation': case 'RemoveRelation': return { type: 'Relation', targetClass: obj.objects[0].className, }; case 'Batch': return getObjectType(obj.ops[0]); default: throw 'unexpected op: ' + obj.__op; } } return 'Object'; } export { load, classNameIsValid, fieldNameIsValid, invalidClassNameMessage, buildMergedSchemaObject, systemClasses, defaultColumns, convertSchemaToAdapterSchema, VolatileClassesSchemas, SchemaController, };