diff --git a/README.md b/README.md index dfe53e1..97135c6 100644 --- a/README.md +++ b/README.md @@ -42,9 +42,9 @@ Partitioner.partitionCollection(Foo, options); Collections that have been partitioned will behave as if there is a separate instance for each group. In particular, on the server and client, the user's current group is used to do the following: -- `find` and `findOne` operations will only return documents for the current group. -- `insert` will cause documents to appear only in the current group. -- `update` and `remove` operations will only affect documents for the current group. +- `find`, `findOne` and `findOneAsync` operations will only return documents for the current group. +- `insert` and `insertAsync` will cause documents to appear only in the current group. +- `update`, `updateAsync`, `remove` and `removeAsync` operations will only affect documents for the current group. - Attempting any operations on a partitioned collection for which a user has not been assigned to a group will result in an error. This is accomplished using selector rewriting based on the current `userId` both on the client and in server methods, and Meteor's environment variables. For more details see the source. @@ -57,21 +57,21 @@ Adds hooks to a particular collection so that it supports partition operations. **NOTE**: Any documents in the collection that were not created from a group will not be visible to any groups in the partition. You should think of creating a partitioned collection as an atomic operation consisting of declaring the collection and calling `partitionCollection`; we will consider rolling this into a single API call in the future. -#### `Partitioner.group()` +#### `Partitioner.groupAsync()` On the server and client, gets the group of the current user. Returns `undefined` if the user is not logged in or not part of a group. A reactive variable. ## Server API -#### `Partitioner.setUserGroup(userId, groupId)` +#### `Partitioner.setUserGroupAsync(userId, groupId)` Adds a particular user to the group identified by `groupId`. The user will now be able to operate on partitioned collections and will only be able to affect documents scoped to the group. An error will be thrown if the user is already in a group. -#### `Partitioner.getUserGroup(userId)` +#### `Partitioner.getUserGroupAsync(userId)` Gets the group of the current user. -#### `Partitioner.clearUserGroup(userId)` +#### `Partitioner.clearUserGroupAsync(userId)` Removes the current group assignment of the user. The user will no longer be able to operate on any partitioned collections. @@ -79,7 +79,7 @@ Removes the current group assignment of the user. The user will no longer be abl Run a function (presumably doing collection operations) masquerading as a particular group. This is necessary for server-originated code that isn't caused by one particular user. -#### `Partitioner.bindUserGroup(userId, func)` +#### `Partitioner.bindUserGroupAsync(userId, func)` A convenience function for running `Partitioner.bindGroup` as the group of a particular user. @@ -87,7 +87,7 @@ A convenience function for running `Partitioner.bindGroup` as the group of a par Sometimes we need to do operations over the entire underlying collection, including all groups. This provides a way to do that, and will not throw an error if the current user method invocation context is not part of a group. -#### `Partitioner.addToGroup(collection, entityId, groupId)` +#### `Partitioner.addToGroupAsync(collection, entityId, groupId)` Allows a document to be shared across multiple groups. Will throw an error if the collection wasn't partitioned with the `multipleGroups: true` option. E.g.: ``` @@ -99,7 +99,7 @@ const newFooId = Foo.insert({msg: 'Hi, I'm a shared Foo'}); Partitioner.addToGroup(Foo, newFooId, sharedGroupId) ``` -#### `Partitioner.removeFromGroup(collection, entityId, groupId)` +#### `Partitioner.removeFromGroupAsync(collection, entityId, groupId)` Remove a document from a shared group. @@ -136,7 +136,7 @@ Note that due to a current limitation of Meteor (see below), this subscription w ```js Deps.autorun(function() { - var group = Partitioner.group(); + var group = Partitioner.groupAsync(); Meteor.subscribe("fooPub", bar, group); }); ``` @@ -179,7 +179,10 @@ Deps.autorun(function() { To send a message, a client or server method might do something like ```js +// Client ChatMessages.insert({text: "hello world", room: currentRoom, timestamp: Date.now()}); +// Client/Server +ChatMessages.insertAsync({text: "hello world", room: currentRoom, timestamp: Date.now()}); ``` This looks simple enough, until you realize that you need to keep track of the `room` for each message that is entered in to the collection. Why not have some code do it for you automagically? @@ -205,7 +208,7 @@ The client's subscription would simply be the following: ```js Deps.autorun(function() { - var group = Partitioner.group(); + var group = Partitioner.groupAsync(); Meteor.subscribe("messages", group); }); ``` @@ -213,7 +216,10 @@ Deps.autorun(function() { Now, sending a chat message is as easy as this: ```js +// Client ChatMessages.insert({text: "hello world", timestamp: Date.now()}); +// Client/Server +ChatMessages.insertAsync({text: "hello world", timestamp: Date.now()}); ``` To change chat rooms, simply have server code call `Partitioner.setUserGroup` for a particular user. @@ -241,6 +247,11 @@ See [CrowdMapper](https://github.com/mizzao/CrowdMapper) for a highly concurrent ## Version History +### **3.1.0 (2024-12-10)** + +- Meteor 3.1 compatibility +- ref#ctored hooks to use async methods (check this README) + ### **3.0.2 (2022-03-22)** - Limit extent of allowing Accounts Core packages such as `CreateUser`, `_attemptLogin`, etc from searching all users so that publications for the same user which are already running cannot return all users. diff --git a/grouping.js b/grouping.js index 8024b9f..acbba57 100644 --- a/grouping.js +++ b/grouping.js @@ -7,7 +7,7 @@ Meteor.publish(null, function () { }); // Special hook for Meteor.users to scope for each group -function userFindHook(userId, selector /*, options*/) { +async function userFindHookAsync(userId, selector /*, options*/) { const isDirectSelector = Helpers.isDirectUserSelector(selector); if ( ((allowDirectIdSelectors || Partitioner._searchAllUsers.get()) && isDirectSelector) @@ -21,7 +21,7 @@ function userFindHook(userId, selector /*, options*/) { if (!userId && !groupId) return true; if (!groupId) { - const user = Meteor.users._partitionerDirect.findOne(userId, {fields: {group: 1}}); + const user = await Meteor.users._partitionerDirect.findOneAsync(userId, {fields: {group: 1}}); // user will be undefined inside reactive publish when user is deleted while subscribed if (!user) return false; @@ -30,12 +30,12 @@ function userFindHook(userId, selector /*, options*/) { // If user is admin and not in a group, proceed as normal (select all users) // do user2 findOne separately so that the findOne above can hit the cache - if (!groupId && Meteor.users._partitionerDirect.findOne(userId, {fields: {admin: 1}}).admin) return true; + if (!groupId && (await Meteor.users._partitionerDirect.findOneAsync(userId, {fields: {admin: 1}})).admin) return true; // Normal users need to be in a group if (!groupId) throw new Meteor.Error(403, ErrMsg.groupErr); - Partitioner._currentGroup.set(groupId); + Partitioner._currentGroup._set(groupId); } filter = { @@ -56,22 +56,22 @@ function userFindHook(userId, selector /*, options*/) { return true; } -function hookGetSetGroup(userId) { +async function hookGetSetGroupAsync(userId) { let groupId = Partitioner._currentGroup.get(); if (!groupId) { if (!userId) throw new Meteor.Error(403, ErrMsg.userIdErr); - groupId = Partitioner.getUserGroup(userId); + groupId = await Partitioner.getUserGroupAsync(userId); if (!groupId) throw new Meteor.Error(403, ErrMsg.groupErr); - Partitioner._currentGroup.set(groupId); + Partitioner._currentGroup._set(groupId); } return groupId; } // No allow/deny for find so we make our own checks -function findHook(userId, selector, options) { +async function findHookAsync(userId, selector, options) { if ( // Don't scope for direct operations Partitioner._directOps.get() === true @@ -82,7 +82,7 @@ function findHook(userId, selector, options) { ) return true; - const groupId = hookGetSetGroup(userId); + const groupId = await hookGetSetGroupAsync(userId); // force the selector to scope for the _groupId if (selector == null) { @@ -110,52 +110,52 @@ function findHook(userId, selector, options) { return true; }; -function insertHook(multipleGroups, userId, doc) { +async function insertHookAsync(multipleGroups, userId, doc) { // Don't add group for direct inserts if (Partitioner._directOps.get() === true) return true; - const groupId = hookGetSetGroup(userId); + const groupId = await hookGetSetGroupAsync(userId); doc._groupId = multipleGroups ? [groupId] : groupId; return true; }; -function userInsertHook(userId, doc) { +async function userInsertHookAsync(userId, doc) { // Don't add group for direct inserts if (Partitioner._directOps.get() === true) return true; - const groupId = hookGetSetGroup(userId); + const groupId = await hookGetSetGroupAsync(userId); doc.group = groupId; return true; }; -function upsertHook(multipleGroups, userId, selector, doc) { +async function upsertHookAsync(multipleGroups, userId, selector, doc) { // Don't add group for direct inserts if (Partitioner._directOps.get() === true) return true; - const groupId = hookGetSetGroup(userId); + const groupId = await hookGetSetGroupAsync(userId); doc._groupId = multipleGroups ? [groupId] : groupId; return true; }; -function userUpsertHook(userId, selector, doc) { +async function userUpsertHookAsync(userId, selector, doc) { // Don't add group for direct inserts if (Partitioner._directOps.get() === true) return true; - const groupId = hookGetSetGroup(userId); + const groupId = await hookGetSetGroupAsync(userId); doc.group = groupId; return true; }; // Attach the find/insert hooks to Meteor.users -Meteor.users._partitionerBefore.find(userFindHook); -Meteor.users._partitionerBefore.findOne(userFindHook); -Meteor.users._partitionerBefore.insert(userInsertHook); -Meteor.users._partitionerBefore.upsert(userUpsertHook); +Meteor.users._partitionerBefore.find(userFindHookAsync); +Meteor.users._partitionerBefore.findOneAsync(userFindHookAsync); +Meteor.users._partitionerBefore.insertAsync(userInsertHookAsync); +Meteor.users._partitionerBefore.upsertAsync(userUpsertHookAsync); function getPartitionedIndex(index) { const defaultIndex = {_groupId: 1}; @@ -174,28 +174,28 @@ Partitioner = { _directOps: new Meteor.EnvironmentVariable(), _searchAllUsers: new Meteor.EnvironmentVariable(), - setUserGroup(userId, groupId) { + async setUserGroupAsync(userId, groupId) { check(userId, String); check(groupId, String); - if (Meteor.users._partitionerDirect.findOne(userId, {fields: {group: 1}}).group) { + if ((await Meteor.users._partitionerDirect.findOneAsync(userId, {fields: {group: 1}})).group) { throw new Meteor.Error(403, "User is already in a group"); } - return Meteor.users._partitionerDirect.update(userId, {$set: {group: groupId}}); + return Meteor.users._partitionerDirect.updateAsync(userId, {$set: {group: groupId}}); }, - getUserGroup(userId) { + async getUserGroupAsync(userId) { check(userId, String); - return (Meteor.users._partitionerDirect.findOne(userId, {fields: {group: 1}}) || {}).group; + return ((await Meteor.users._partitionerDirect.findOneAsync(userId, {fields: {group: 1}})) || {}).group; }, - clearUserGroup(userId) { + async clearUserGroupAsync(userId) { check(userId, String); - return Meteor.users._partitionerDirect.update(userId, {$unset: {group: 1}}); + return Meteor.users._partitionerDirect.updateAsync(userId, {$unset: {group: 1}}); }, - group() { + async groupAsync() { const groupId = this._currentGroup.get(); if (groupId) return groupId; @@ -204,15 +204,15 @@ Partitioner = { userId = Meteor.userId(); } catch (error) {} - return userId && this.getUserGroup(userId); + return userId && await this.getUserGroupAsync(userId); }, bindGroup(groupId, func) { return this._currentGroup.withValue(groupId, func); }, - bindUserGroup(userId, func) { - const groupId = Partitioner.getUserGroup(userId); + async bindUserGroupAsync(userId, func) { + const groupId = await Partitioner.getUserGroupAsync(userId); if (!groupId) { Meteor._debug(`Dropping operation because ${userId} is not in a group`); @@ -226,16 +226,16 @@ Partitioner = { return this._directOps.withValue(true, func); }, - _isAdmin(_id) { - return !!Meteor.users._partitionerDirect.findOne({_id, admin: true}, {fields: {_id: 1}}); + async _isAdmin(_id) { + return !!(await Meteor.users._partitionerDirect.findOneAsync({_id, admin: true}, {fields: {_id: 1}})); }, - addToGroup(collection, entityId, groupId) { + async addToGroupAsync(collection, entityId, groupId) { if (!multipleGroupCollections[collection._name]) { throw new Meteor.Error(403, ErrMsg.multiGroupErr); } - let currentGroupIds = collection._partitionerDirect.findOne(entityId, {fields: {_groupId: 1}})?._groupId; + let currentGroupIds = (await collection._partitionerDirect.findOneAsync(entityId, {fields: {_groupId: 1}}))?._groupId; if (!currentGroupIds) { currentGroupIds = [groupId]; } else if (typeof currentGroupIds == 'string') { @@ -244,17 +244,18 @@ Partitioner = { if (currentGroupIds.indexOf(groupId) == -1) { currentGroupIds.push(groupId); - collection._partitionerDirect.update(entityId, {$set: {_groupId: currentGroupIds}}); + await collection._partitionerDirect.updateAsync(entityId, {$set: {_groupId: currentGroupIds}}); } return currentGroupIds; }, - removeFromGroup(collection, entityId, groupId) { + + async removeFromGroupAsync(collection, entityId, groupId) { if (!multipleGroupCollections[collection._name]) { throw new Meteor.Error(403, ErrMsg.multiGroupErr); } - let currentGroupIds = collection._partitionerDirect.findOne(entityId, {fields: {_groupId: 1}})?._groupId; + let currentGroupIds = await collection._partitionerDirect.findOneAsync(entityId, {fields: {_groupId: 1}})?._groupId; if (!currentGroupIds) { return []; } @@ -265,7 +266,7 @@ Partitioner = { const index = currentGroupIds.indexOf(groupId); if (index != -1) { currentGroupIds.splice(index, 1); - collection._partitionerDirect.update(entityId, {$set: {_groupId: currentGroupIds}}); + await collection._partitionerDirect.updateAsync(entityId, {$set: {_groupId: currentGroupIds}}); } return currentGroupIds; @@ -274,26 +275,28 @@ Partitioner = { partitionCollection(collection, options = {}) { // Because of the deny below, need to create an allow validator // on an insecure collection if there isn't one already - if (collection._isInsecure()) { - collection.allow({ - insert: () => true, - update: () => true, - remove: () => true, - }); - } - - // Idiot-proof the collection against admin users - collection.deny({ - insert: this._isAdmin, - update: this._isAdmin, - remove: this._isAdmin - }); - collection._partitionerBefore.find(findHook); - collection._partitionerBefore.findOne(findHook); - collection._partitionerBefore.upsert((...args) => upsertHook(options.multipleGroups, ...args)); + // TODO toto treba zobrat a dat do hookov, lebo deny nepodporuje async + // if (collection._isInsecure()) { + // collection.allow({ + // insert: () => true, + // update: () => true, + // remove: () => true, + // }); + // } + // + // // Idiot-proof the collection against admin users + // collection.deny({ + // insert: this._isAdmin, + // update: this._isAdmin, + // remove: this._isAdmin + // }); + + collection._partitionerBefore.find(findHookAsync); // used in findOneAsync, fetchAsync, observeChangesAsync, countAsync + collection._partitionerBefore.findOneAsync(findHookAsync); + collection._partitionerBefore.upsertAsync((...args) => upsertHookAsync(options.multipleGroups, ...args)); // These will hook the _validated methods as well - collection._partitionerBefore.insert((...args) => insertHook(options.multipleGroups, ...args)); + collection._partitionerBefore.insertAsync((...args) => insertHookAsync(options.multipleGroups, ...args)); // No update/remove hook necessary, findHook will be used automatically // store a hash of which collections allow multiple groups @@ -327,11 +330,11 @@ Partitioner = { // Don't wrap createUser with Partitioner.directOperation because want inserted user doc to be // automatically assigned to the group -['createUser', 'findUserByEmail', 'findUserByUsername', '_attemptLogin'].forEach(fn => { +['createUserAsync', 'findUserByEmail', 'findUserByUsername', '_attemptLogin'].forEach(fn => { const orig = Accounts[fn]; if (orig) { - Accounts[fn] = function() { - return Partitioner._searchAllUsers.withValue(true, () => orig.apply(this, arguments)); + Accounts[fn] = async function() { + return Partitioner._searchAllUsers.withValue(true, async () => await orig.apply(this, arguments)); }; } }); diff --git a/hooks.js b/hooks.js index e99307f..f679e4d 100644 --- a/hooks.js +++ b/hooks.js @@ -1,98 +1,167 @@ let publishUserId; if (Meteor.isServer) { - publishUserId = new Meteor.EnvironmentVariable(); - const _publish = Meteor.publish; - Meteor.publish = function(name, handler, options) { - return _publish.call(this, name, function(...args) { - // This function is called repeatedly in publications - return publishUserId.withValue(this && this.userId, () => handler.apply(this, args)); - }, options) - } + publishUserId = new Meteor.EnvironmentVariable(); + const _publish = Meteor.publish; + Meteor.publish = function (name, handler, options) { + return _publish.call( + this, + name, + function (...args) { + // This function is called repeatedly in publications + return publishUserId.withValue(this && this.userId, () => handler.apply(this, args)); + }, + options, + ); + }; } -function getUserId () { - let userId; +function getUserId() { + let userId; - try { - // Will throw an error unless within method call. - // Attempt to recover gracefully by catching: - userId = Meteor.userId && Meteor.userId(); - } catch (e) {} + try { + // Will throw an error unless within method call. + // Attempt to recover gracefully by catching: + userId = Meteor.userId && Meteor.userId(); + } catch (e) {} - if (userId == null && Meteor.isServer) { - // Get the userId if we are in a publish function. - userId = publishUserId.get(); - } + if (userId == null && Meteor.isServer) { + // Get the userId if we are in a publish function. + userId = publishUserId.get(); + } - return userId + return userId; } const proto = Mongo.Collection.prototype; const directEnv = new Meteor.EnvironmentVariable(false); -const methods = ['find', 'findOne', 'insert', 'update', 'remove', 'upsert']; +const selectionMethods = ["find", "findOneAsync", "insertAsync", "updateAsync", "removeAsync", "upsertAsync"]; +const fetchMethods = [ + "fetchAsync", + "observeAsync", + "observeChangesAsync", + "mapAsync", + "countAsync", + "forEachAsync", + Symbol.asyncIterator, +]; // create the collection._partitionerBefore.* methods // have to create it initially using a getter so we can store self=this and create a new group of functions which have access to self -Object.defineProperty(proto, '_partitionerBefore', { - get() { - // console.log('creating before functions', this._name); - const self = this; - const fns = {}; - methods.forEach(method => fns[method] = function(hookFn) { - self['_groupingBefore_'+method] = hookFn; - }); - // replace the .direct prototype with the created object, so we don't have to recreate it every time. - Object.defineProperty(this, '_partitionerBefore', {value: fns}); - return fns; - } +Object.defineProperty(proto, "_partitionerBefore", { + get() { + // console.log('creating before functions', this._name); + const self = this; + const fns = {}; + selectionMethods.forEach( + (method) => + (fns[method] = function (hookFn) { + self[`_groupingBefore_${method}`] = hookFn; + }), + ); + // replace the .direct prototype with the created object, so we don't have to recreate it every time. + Object.defineProperty(this, "_partitionerBefore", { value: fns }); + return fns; + }, }); // create the collection._partitionerDirect.* methods // have to create it initially using a getter so we can store self=this and create a new group of functions which have access to self -Object.defineProperty(proto, '_partitionerDirect', { - get() { - // console.log('creating direct functions', this._name); - const self = this; - const fns = {}; - methods.forEach(method => fns[method] = function(...args) { - return directEnv.withValue(true, () => proto[method].apply(self, args)); - }); - // replace the .direct prototype with the created object, so we don't have to recreate it every time. - Object.defineProperty(this, '_partitionerDirect', {value: fns}); - return fns; - } +Object.defineProperty(proto, "_partitionerDirect", { + get() { + // console.log('creating direct functions', this._name); + const self = this; + const fns = {}; + selectionMethods.forEach( + (method) => + (fns[method] = async function (...args) { + return directEnv.withValue(true, async () => { + return proto[method].apply(self, args); + }); + }), + ); + // replace the .direct prototype with the created object, so we don't have to recreate it every time. + Object.defineProperty(this, "_partitionerDirect", { value: fns }); + return fns; + }, }); global.hookLogging = false; // if (Meteor.isServer) global.hookLogging = true; -methods.forEach(method => { - const _orig = proto[method]; - proto[method] = function(...args) { - if (directEnv.get()) return _orig.apply(this, args); - // give the hooks a private context so that they can modify this.args without putting this.args onto the prototype - const context = {args}; - const userId = getUserId(); - global.hookLogging && typeof args[0]!='string' && console.log('hook', '\n\n'); - - // if the method is update or remove, automatically apply the find hooks to limit the update/remove to the user's group - if ((method=='update' || method=='remove') && this._groupingBefore_find) { - global.hookLogging && typeof args[0]!='string' && console.log('hook', 'b4i', this._name+"."+method, JSON.stringify(args[0]), JSON.stringify(args[1])); - // don't send args[1] for update or remove. - // need to send empty object instead to prevent args[1] being modified - this._groupingBefore_find.call(context, userId, args[0], {}); - global.hookLogging && typeof args[0]!='string' && console.log('hook', 'afi', this._name+"."+method, JSON.stringify(args[0]), JSON.stringify(args[1])); - } - - // run the hook - if (this['_groupingBefore_'+method]) { - global.hookLogging && typeof args[0]!='string' && console.log('hook', 'b4', this._name+"."+method, JSON.stringify(args[0]), JSON.stringify(args[1])); - this['_groupingBefore_'+method].call(context, userId, args[0], args[1], args[2]); - global.hookLogging && typeof args[0]!='string' && console.log('hook', 'af', this._name+"."+method, JSON.stringify(args[0]), JSON.stringify(args[1])); - } - - // run the original method - return _orig.apply(this, args); - } -}); +global.uuu = false; + +selectionMethods.forEach(method => { + const _orig = proto[method]; + // if the method is find, we do not replace the original method + // because it is sync and we replace the childfetch methods instead + if (method == 'find') { + proto[method] = function(...args) { + const self = this; + const cursor = _orig.apply(this, args); // we need to get cursor to get fetch methods + const userId = getUserId(); + // Method _observeChanges is used by Meteor publications + // Store original method to prevent infinite loop + if(!cursor._mongo._observeChangesOrig) { + cursor._mongo._observeChangesOrig = cursor._mongo._observeChanges; + } + // Modify cursor and then call original method + cursor._mongo._observeChanges = async function(...args) { + if(userId && self._groupingBefore_find) { + const selector = cursor._cursorDescription.selector; + await self._groupingBefore_find.call({args}, userId, selector, {}); + cursor._cursorDescription.selector = selector; + } + return cursor._mongo._observeChangesOrig.apply(this, args); + }; + + // Now modify all cursor methods + // ...methods after find (fetchAsync, countAsync...) + fetchMethods.forEach(fetchMethod => { + const _orig = cursor[fetchMethod]; + cursor[fetchMethod] = async function(...args) { + // modify the selector in the cursor before calling the fetch method + if(self._groupingBefore_find) { + const selector = cursor._cursorDescription.selector; + await self._groupingBefore_find.call({args}, userId, selector, {}); + cursor._cursorDescription.selector = selector; + } + // run the original fetch method + return _orig.apply(this, args); + }; + }); + return cursor; + }; + return; + } + + // Now modify data manipulation async methods (insertAsync, updateAsync...) + // this replaces all async original methods + // except find, which is sync and needs to be handled differently + proto[method] = async function(...args) { + if (directEnv.get()) return _orig.apply(this, args); + // give the hooks a private context so that they can modify this.args without putting this.args onto the prototype + const context = {args}; + const userId = getUserId(); + global.hookLogging && typeof args[0]!='string' && console.log('hook', '\n\n'); + + // if the method is update or remove, automatically apply the find hooks to limit the update/remove to the user's group + if ((method=='updateAsync' || method=='removeAsync') && this._groupingBefore_find) { + global.hookLogging && typeof args[0]!='string' && console.log('hook', 'b4i', this._name+"."+method, JSON.stringify(args[0]), JSON.stringify(args[1])); + // don't send args[1] for update or remove. + // need to send empty object instead to prevent args[1] being modified + await this._groupingBefore_find.call(context, userId, args[0], {}); + global.hookLogging && typeof args[0]!='string' && console.log('hook', 'afi', this._name+"."+method, JSON.stringify(args[0]), JSON.stringify(args[1])); + } + + // run the hook + if (this['_groupingBefore_'+method]) { + global.hookLogging && typeof args[0]!='string' && console.log('hook', 'b4', this._name+"."+method, JSON.stringify(args[0]), JSON.stringify(args[1])); + await this['_groupingBefore_'+method].call(context, userId, args[0], args[1], args[2]); + global.hookLogging && typeof args[0]!='string' && console.log('hook', 'af', this._name+"."+method, JSON.stringify(args[0]), JSON.stringify(args[1])); + } + + // run the original method + return _orig.apply(this, args); + } +}); \ No newline at end of file diff --git a/hooks_client.js b/hooks_client.js new file mode 100644 index 0000000..e99307f --- /dev/null +++ b/hooks_client.js @@ -0,0 +1,98 @@ +let publishUserId; + +if (Meteor.isServer) { + publishUserId = new Meteor.EnvironmentVariable(); + const _publish = Meteor.publish; + Meteor.publish = function(name, handler, options) { + return _publish.call(this, name, function(...args) { + // This function is called repeatedly in publications + return publishUserId.withValue(this && this.userId, () => handler.apply(this, args)); + }, options) + } +} + +function getUserId () { + let userId; + + try { + // Will throw an error unless within method call. + // Attempt to recover gracefully by catching: + userId = Meteor.userId && Meteor.userId(); + } catch (e) {} + + if (userId == null && Meteor.isServer) { + // Get the userId if we are in a publish function. + userId = publishUserId.get(); + } + + return userId +} + +const proto = Mongo.Collection.prototype; +const directEnv = new Meteor.EnvironmentVariable(false); +const methods = ['find', 'findOne', 'insert', 'update', 'remove', 'upsert']; + +// create the collection._partitionerBefore.* methods +// have to create it initially using a getter so we can store self=this and create a new group of functions which have access to self +Object.defineProperty(proto, '_partitionerBefore', { + get() { + // console.log('creating before functions', this._name); + const self = this; + const fns = {}; + methods.forEach(method => fns[method] = function(hookFn) { + self['_groupingBefore_'+method] = hookFn; + }); + // replace the .direct prototype with the created object, so we don't have to recreate it every time. + Object.defineProperty(this, '_partitionerBefore', {value: fns}); + return fns; + } +}); + +// create the collection._partitionerDirect.* methods +// have to create it initially using a getter so we can store self=this and create a new group of functions which have access to self +Object.defineProperty(proto, '_partitionerDirect', { + get() { + // console.log('creating direct functions', this._name); + const self = this; + const fns = {}; + methods.forEach(method => fns[method] = function(...args) { + return directEnv.withValue(true, () => proto[method].apply(self, args)); + }); + // replace the .direct prototype with the created object, so we don't have to recreate it every time. + Object.defineProperty(this, '_partitionerDirect', {value: fns}); + return fns; + } +}); + +global.hookLogging = false; +// if (Meteor.isServer) global.hookLogging = true; + +methods.forEach(method => { + const _orig = proto[method]; + proto[method] = function(...args) { + if (directEnv.get()) return _orig.apply(this, args); + // give the hooks a private context so that they can modify this.args without putting this.args onto the prototype + const context = {args}; + const userId = getUserId(); + global.hookLogging && typeof args[0]!='string' && console.log('hook', '\n\n'); + + // if the method is update or remove, automatically apply the find hooks to limit the update/remove to the user's group + if ((method=='update' || method=='remove') && this._groupingBefore_find) { + global.hookLogging && typeof args[0]!='string' && console.log('hook', 'b4i', this._name+"."+method, JSON.stringify(args[0]), JSON.stringify(args[1])); + // don't send args[1] for update or remove. + // need to send empty object instead to prevent args[1] being modified + this._groupingBefore_find.call(context, userId, args[0], {}); + global.hookLogging && typeof args[0]!='string' && console.log('hook', 'afi', this._name+"."+method, JSON.stringify(args[0]), JSON.stringify(args[1])); + } + + // run the hook + if (this['_groupingBefore_'+method]) { + global.hookLogging && typeof args[0]!='string' && console.log('hook', 'b4', this._name+"."+method, JSON.stringify(args[0]), JSON.stringify(args[1])); + this['_groupingBefore_'+method].call(context, userId, args[0], args[1], args[2]); + global.hookLogging && typeof args[0]!='string' && console.log('hook', 'af', this._name+"."+method, JSON.stringify(args[0]), JSON.stringify(args[1])); + } + + // run the original method + return _orig.apply(this, args); + } +}); diff --git a/package.js b/package.js index 6e5d10b..ca2ef22 100644 --- a/package.js +++ b/package.js @@ -1,12 +1,12 @@ Package.describe({ name: "wildhart:partitioner", summary: "Transparently divide a meteor app into different instances shared between groups of users.", - version: "3.0.2", + version: "3.1.0", git: "https://github.com/wildhart/meteor-partitioner" }); Package.onUse(function (api) { - api.versionsFrom(["1.6.1", "2.3"]); + api.versionsFrom(["3.0"]); // Client & Server deps api.use([ 'accounts-base', @@ -16,10 +16,9 @@ Package.onUse(function (api) { 'mongo' // Mongo.Collection available ]); - api.use("wildhart:env-var-set@0.0.1"); - - api.addFiles('hooks.js'); api.addFiles('common.js'); + api.addFiles('hooks.js', 'server'); + api.addFiles('hooks_client.js', 'client'); api.addFiles('grouping.js', 'server'); api.addFiles('grouping_client.js', 'client');