diff --git a/package.json b/package.json index 846ab3183a..8afdf40e6d 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,7 @@ "body-parser": "^1.14.2", "colors": "^1.1.2", "commander": "^2.9.0", + "deep-equal": "^1.0.1", "deepcopy": "^0.6.1", "express": "^4.13.4", "intersect": "^1.0.1", diff --git a/spec/PersistentSettingsStore.spec.js b/spec/PersistentSettingsStore.spec.js new file mode 100644 index 0000000000..a95a1994ff --- /dev/null +++ b/spec/PersistentSettingsStore.spec.js @@ -0,0 +1,134 @@ +'use strict'; + +var PersistentSettingsStore = require('../src/PersistentSettingsStore').default; +var DatabaseAdapter = require('../src/DatabaseAdapter'); +var appId = 'test'; +var collectionPrefix = 'test_'; +var store; +var settingsCollection; + +describe('PersistentSettingsStore', function () { + beforeEach(function () { + store = PersistentSettingsStore({ + freshness: 0.1 + }, { + defined: 0 + }); + }); + + describe('mocked db', function () { + beforeEach(function () { + settingsCollection = jasmine.createSpyObj('settingsCollection', ['find', 'upsertOne']); + settingsCollection.find.and.returnValue(Promise.resolve([])); + settingsCollection.upsertOne.and.returnValue(Promise.resolve()); + + spyOn(DatabaseAdapter, 'getDatabaseConnection').and.returnValue({ + adaptiveCollection: _ => { + return Promise.resolve(settingsCollection); + } + }); + }); + + it('does not persist locked settings', function() { + store.set(appId, { + applicationId: appId + }); + + expect(store.get(appId, 'persisted').applicationId).toBeUndefined; + expect(store.get(appId, 'locked').applicationId).toEqual(appId); + }); + + it('does not persist defined settings by default', function() { + store.set(appId, { + defined: 0 + }); + + expect(store.get(appId, 'persisted').defined).toBeUndefined; + expect(store.get(appId, 'locked').defined).toBeDefined; + }); + + it('persists defined settings if lockDefinedSettings false', function() { + store = PersistentSettingsStore({ + lockDefinedSettings: false + }, { + defined: 0 + }); + + store.set(appId, { + defined: 0 + }); + + expect(store.get(appId, 'persisted').defined).toBeDefined; + expect(store.get(appId, 'locked').defined).toBeUndefined; + }); + + it('does not allow modification of locked settings', function() { + store.set(appId, { + defined: 0 + }); + + store.get(appId).defined = 2; + + expect(store.get(appId).defined).toEqual(0); + }); + + it('allows modification of persisted settings', function() { + store.set(appId, { + modifiable: 0 + }); + + store.get(appId).modifiable = 2; + expect(store.get(appId).modifiable).toEqual(2); + }); + + it('respects freshness option', function(done) { + // freshness 100 ms + store.set(appId, { + modifiable: 0 + }); + + function get() { + store.get(appId); + } + setTimeout(get, 50); + // freshness expires + setTimeout(get, 150); + setTimeout(get, 200); + // freshness expires + setTimeout(get, 300) + setTimeout(function () { + // three calls: one for initial pull, two from expired freshness + expect(settingsCollection.find.calls.count()).toEqual(3); + done(); + }, 350); + }); + + it('pushes on setting change', function(done) { + store.set(appId, { + applicationId: appId, + modifiable: 0 + }); + + setTimeout(function () { + store.get(appId).modifiable = 2; + }, 100); + + setTimeout(function () { + var calls = settingsCollection.upsertOne.calls; + expect(calls.count()).toEqual(2); + expect(calls.argsFor(0)[1]).toEqual({ + applicationId: appId, + persisted: { + modifiable: 0 + } + }); + expect(calls.argsFor(1)[1]).toEqual({ + $set: { + 'persisted.modifiable': 2 + } + }); + done(); + }, 200); + }); + }); +}); diff --git a/src/Config.js b/src/Config.js index 3e2ac36834..5e8debf155 100644 --- a/src/Config.js +++ b/src/Config.js @@ -49,6 +49,7 @@ export class Config { this.liveQueryController = cacheInfo.liveQueryController; this.sessionLength = cacheInfo.sessionLength; this.generateSessionExpiresAt = this.generateSessionExpiresAt.bind(this); + this.settingsCacheOptions = cacheInfo.settingsCacheOptions; } static validate(options) { diff --git a/src/ParseServer.js b/src/ParseServer.js index 1c1eececf1..77ea71dc33 100644 --- a/src/ParseServer.js +++ b/src/ParseServer.js @@ -15,6 +15,7 @@ var batch = require('./batch'), import { logger, configureLogger } from './logger'; import cache from './cache'; +import PersistentSettingsStore from './PersistentSettingsStore'; import Config from './Config'; import parseServerPackage from '../package.json'; import PromiseRouter from './PromiseRouter'; @@ -81,43 +82,48 @@ addParseCloud(); class ParseServer { - constructor({ - appId = requiredParameter('You must provide an appId!'), - masterKey = requiredParameter('You must provide a masterKey!'), - appName, - databaseAdapter, - filesAdapter, - push, - loggerAdapter, - logsFolder, - databaseURI = DatabaseAdapter.defaultDatabaseURI, - databaseOptions, - cloud, - collectionPrefix = '', - clientKey, - javascriptKey, - dotNetKey, - restAPIKey, - fileKey = 'invalid-file-key', - facebookAppIds = [], - enableAnonymousUsers = true, - allowClientClassCreation = true, - oauth = {}, - serverURL = requiredParameter('You must provide a serverURL!'), - maxUploadSize = '20mb', - verifyUserEmails = false, - emailAdapter, - publicServerURL, - customPages = { - invalidLink: undefined, - verifyEmailSuccess: undefined, - choosePassword: undefined, - passwordResetSuccess: undefined - }, - liveQuery = {}, - sessionLength = 31536000, // 1 Year in seconds - verbose = false, - }) { + constructor(definedOptions = {}) { + let { + appId = requiredParameter('You must provide an appId!'), + masterKey = requiredParameter('You must provide a masterKey!'), + appName, + databaseAdapter, + filesAdapter, + push, + loggerAdapter, + logsFolder, + databaseURI = DatabaseAdapter.defaultDatabaseURI, + databaseOptions, + cloud, + collectionPrefix = '', + clientKey, + javascriptKey, + dotNetKey, + restAPIKey, + fileKey = 'invalid-file-key', + facebookAppIds = [], + enableAnonymousUsers = true, + allowClientClassCreation = true, + oauth = {}, + serverURL = requiredParameter('You must provide a serverURL!'), + maxUploadSize = '20mb', + verifyUserEmails = false, + emailAdapter, + publicServerURL, + customPages = { + invalidLink: undefined, + verifyEmailSuccess: undefined, + choosePassword: undefined, + passwordResetSuccess: undefined + }, + liveQuery = {}, + sessionLength = 31536000, // 1 Year in seconds + verbose = false, + settingsCacheOptions = { + lockDefinedSettings: true, + freshness: 15 + } + } = definedOptions; // Initialize the node client SDK automatically Parse.initialize(appId, javascriptKey || 'unused', masterKey); Parse.serverURL = serverURL; @@ -171,7 +177,11 @@ class ParseServer { const userController = new UserController(emailControllerAdapter, appId, { verifyUserEmails }); const liveQueryController = new LiveQueryController(liveQuery); + if (settingsCacheOptions) { + cache.apps = PersistentSettingsStore(settingsCacheOptions, definedOptions); + } cache.apps.set(appId, { + applicationId: appId, masterKey: masterKey, serverURL: serverURL, collectionPrefix: collectionPrefix, @@ -195,6 +205,7 @@ class ParseServer { maxUploadSize: maxUploadSize, liveQueryController: liveQueryController, sessionLength : Number(sessionLength), + settingsCacheOptions: settingsCacheOptions }); // To maintain compatibility. TODO: Remove in some version that breaks backwards compatability diff --git a/src/PersistentSettingsStore.js b/src/PersistentSettingsStore.js new file mode 100644 index 0000000000..79a6c5c3ae --- /dev/null +++ b/src/PersistentSettingsStore.js @@ -0,0 +1,185 @@ +'use strict' + +let equal = require('deep-equal'); +let deepcopy = require('deepcopy'); + +import { logger, configureLogger } from './logger'; +let authDataManager = require('./authDataManager'); +let DatabaseAdapter = require('./DatabaseAdapter'); + +// doesn't make sense to expose / modify these settings +let lockedSettings = [ + 'applicationId', + 'masterKey', + 'serverURL', + 'collectionPrefix', + 'filesController', + 'pushController', + 'loggerController', + 'hooksController', + 'userController', + 'authDataManager', + 'liveQueryController' +]; + +// callbacks for specific setting changes +let onChange = { + logLevel: logLevel => { + configureLogger({ level: logLevel }); + return logLevel; + }, + oauth: (oauth, doc) => { + doc.locked.authDataManager = authDataManager(oauth, doc.settings.enableAnonymousUsers); + return oauth; + }, + enableAnonymousUsers: (enableAnonymousUsers, doc) => { + doc.locked.authDataManager = authDataManager(doc.settings.oauth, enableAnonymousUsers); + return enableAnonymousUsers; + }, + sessionLength: sessionLength => Number(sessionLength) +} + +export default function PersistentSettingsStore(options, definedSettings) { + let { + freshness = 15, + lockDefinedSettings = true + } = options; + let dataStore = {}; + + return { + // filter can be 'locked' or 'persisted' + get: (key, filter) => { + let doc = dataStore[key]; + pullSettings(doc); + if (filter) { + // if filter, return wrapped settings for locked / persisted + return filterDescriptors(doc.settings, doc[filter]); + } else { + // return wrapped settings + return doc.settings; + } + }, + + set: (key, settings) => { + let doc = { + // An object with getter/setters wrapped properties. Values stored in persisted/locked + settings: settings, + // Store for the persisted properties of settings + persisted: {}, + // Store for the locked properties of settings + locked: {}, + // Last time the settings were pulled from the database + lastPull: new Date(1970, 0, 0), + }; + + // wrap settings with getters / setters + setupSettingPersistence(doc, settings); + + // place doc in map + dataStore[key] = doc; + + // sync with database + return pullSettings(doc).then(_ => pushSettings(doc)); + }, + + remove: key => { + delete dataStore[key]; + }, + + clear: _ => { + dataStore = {}; + } + }; + + function getSettingsCollection(doc) { + return DatabaseAdapter.getDatabaseConnection(doc.settings.applicationId, doc.settings.collectionPrefix) + .adaptiveCollection('_ServerSettings'); + } + + function pullSettings(doc) { + if (new Date() - doc.lastPull > freshness * 1000) { + doc.lastPull = new Date(); + + return getSettingsCollection(doc) + .then(coll => coll.find({ 'applicationId': doc.settings.applicationId }, { limit: 1})) + .then(results => { + let databaseSettings = results.length && results[0] && results[0].persisted; + Object.assign(doc.settings, databaseSettings); + }); + } + } + + function pushSettings(doc) { + return getSettingsCollection(doc) + .then(upsert(doc, { + applicationId: doc.settings.applicationId, + persisted: doc.persisted + })); + } + + function pushSetting(doc, setting, value) { + let upsertObject = { $set: {} }; + upsertObject.$set['persisted.' + setting] = value; + + return getSettingsCollection(doc) + .then(upsert(doc, upsertObject)); + } + + function upsert(doc, upsertObject) { + return coll => coll.upsertOne({ applicationId: doc.settings.applicationId }, deepcopy(upsertObject)); + } + + // Instrument settings object with getter/setters to enable persistence + function setupSettingPersistence(doc, settings) { + let definedSettingsArray = Object.keys(definedSettings); + Object.keys(settings).forEach(setting => { + if (lockedSettings.some(locked => locked === setting) || (lockDefinedSettings && definedSettingsArray.some(defined => defined === setting))) { + // for locked settings, attach a getter and a dummy setter. Store actual setting values in doc.locked + doc.locked[setting] = settings[setting]; + Object.defineProperty(doc.settings, setting, { + get: function() { + return doc.locked[setting]; + }, + set: function(val) { + logger.info(`Cannot modify '${setting}' as it is a locked setting`); + } + }) + } + else { + // Store persisted setting values in doc.persisted. If the setting value is undefined set to null instead so that it is sent over the network + doc.persisted[setting] = (settings[setting] === undefined)? null: settings[setting]; + // Attach a getter and a setter to persisted settings which executes onChange callback and stores setting in database. + Object.defineProperty(doc.settings, setting, { + get: function() { + return doc.persisted[setting]; + }, + set: function(val) { + // ignore if previous and new value are equal + if (!equal(val, doc.persisted[setting], { strict: true })) { + // execute change callback for setting if it exists + if (onChange[setting]) { + val = onChange[setting](val, doc); + } + // update in-memory setting + doc.persisted[setting] = val; + + // push setting to database + pushSetting(doc, setting, val); + } + } + }); + } + }); + } + + function filterDescriptors(source, subset) { + let filtered = {}; + let descriptors = Object.keys(subset).reduce((descriptors, key) => { + descriptors[key] = Object.getOwnPropertyDescriptor(source, key); + return descriptors; + }, {}); + Object.defineProperties(filtered, descriptors); + return filtered; + } +} + diff --git a/src/Routers/SettingsRouter.js b/src/Routers/SettingsRouter.js index 6d0a97af6a..72366244e5 100644 --- a/src/Routers/SettingsRouter.js +++ b/src/Routers/SettingsRouter.js @@ -2,25 +2,29 @@ import PromiseRouter from '../PromiseRouter'; import * as middleware from "../middlewares"; import { logger, configureLogger } from '../logger'; import winston from 'winston'; +import cache from '../cache'; export class SettingsRouter extends PromiseRouter { mountRoutes() { this.route('GET', '/settings', middleware.promiseEnforceMasterKeyAccess, (req) => { return Promise.resolve({ - response: { - logLevel: winston.level - } - }) + response: cache.apps.get(req.config.applicationId, 'persisted') + }); }); - this.route('POST','/settings', middleware.promiseEnforceMasterKeyAccess, (req) => { - let body = req.body; - let logLevel = body.logLevel; - if (logLevel) { - configureLogger({level: logLevel}); + + this.route('POST','/settings', middleware.promiseEnforceMasterKeyAccess, (req, res) => { + if (req.config.settingsCacheOptions) { + let body = req.body; + Object.assign(cache.apps.get(req.config.applicationId), body); + return Promise.resolve({ + response: body + }); + } else { + return Promise.reject({ + status: 400, + message: 'Cannot update settings as there are no settingsCacheOptions in parse server config' + }); } - return Promise.resolve({ - response: body - }) }); } } diff --git a/src/cache.js b/src/cache.js index 8893f29b1b..9017d2cab4 100644 --- a/src/cache.js +++ b/src/cache.js @@ -18,7 +18,7 @@ export function CacheStore() { }; } -const apps = CacheStore(); +let apps = CacheStore(); const users = CacheStore(); //So far used only in tests