From 0d0f1954a9defcc86a62ca0427a603569cae16d0 Mon Sep 17 00:00:00 2001 From: Berislav Date: Tue, 24 Oct 2023 21:17:01 +0200 Subject: [PATCH 1/3] feature: subscription post filter --- lib/exposure/exposure.config.schema.js | 3 +- lib/namedQuery/expose/extension.js | 7 + lib/namedQuery/expose/schema.js | 3 +- .../testing/bootstrap/queries/index.js | 2 + .../queries/projectsListExposureSecurity.js | 87 +++++++++++ lib/namedQuery/testing/client.test.js | 135 +++++++++++++++++- lib/query/lib/recursiveCompose.js | 94 +++++++++++- lib/query/testing/bootstrap/fixtures.js | 6 +- 8 files changed, 331 insertions(+), 6 deletions(-) create mode 100644 lib/namedQuery/testing/bootstrap/queries/projectsListExposureSecurity.js diff --git a/lib/exposure/exposure.config.schema.js b/lib/exposure/exposure.config.schema.js index 70e908ce..913b8cfa 100644 --- a/lib/exposure/exposure.config.schema.js +++ b/lib/exposure/exposure.config.schema.js @@ -19,8 +19,9 @@ export const ExposureSchema = { body: Match.Maybe(Match.OneOf(Object, Function)), restrictedFields: Match.Maybe([String]), restrictLinks: Match.Maybe( - Match.OneOf(Function, [String]) + Match.OneOf(Function, [String]), ), + subscriptionFilter: Match.Maybe(Function) }; export function validateBody(collection, body) { diff --git a/lib/namedQuery/expose/extension.js b/lib/namedQuery/expose/extension.js index 5c8fd8a5..11fc1fc0 100755 --- a/lib/namedQuery/expose/extension.js +++ b/lib/namedQuery/expose/extension.js @@ -9,6 +9,9 @@ import intersectDeep from '../../query/lib/intersectDeep'; import genCountEndpoint from '../../query/counts/genEndpoint.server'; import { check } from 'meteor/check'; +// import { enableDebugLogging } from 'meteor/reywood:publish-composite'; +// enableDebugLogging(); + _.extend(NamedQuery.prototype, { /** * @param config @@ -152,6 +155,7 @@ _.extend(NamedQuery.prototype, { */ _initPublication() { const self = this; + const config = this.exposeConfig; Meteor.publishComposite(this.name, function(params = {}) { const isScoped = !!self.options.scoped; @@ -177,6 +181,9 @@ _.extend(NamedQuery.prototype, { return recursiveCompose(rootNode, undefined, { scoped: isScoped, blocking: self.exposeConfig.blocking, + subscriptionFilter: config.subscriptionFilter, + publication: this, + params, }); }); }, diff --git a/lib/namedQuery/expose/schema.js b/lib/namedQuery/expose/schema.js index f28e43e8..c9efd396 100755 --- a/lib/namedQuery/expose/schema.js +++ b/lib/namedQuery/expose/schema.js @@ -18,5 +18,6 @@ export const ExposeSchema = { ), validateParams: Match.Maybe( Match.OneOf(Object, Function) - ) + ), + subscriptionFilter: Match.Maybe(Function) }; diff --git a/lib/namedQuery/testing/bootstrap/queries/index.js b/lib/namedQuery/testing/bootstrap/queries/index.js index ad35e81d..ce1950db 100755 --- a/lib/namedQuery/testing/bootstrap/queries/index.js +++ b/lib/namedQuery/testing/bootstrap/queries/index.js @@ -2,6 +2,7 @@ import postList from './postList'; import postListCached from './postListCached'; import postListExposure from './postListExposure'; import postListExposureScoped from './postListExposureScoped'; +import projectsListExposureSecurity from './projectsListExposureSecurity'; import postListParamsCheck from './postListParamsCheck'; import postListParamsCheckServer from './postListParamsCheckServer'; import postListResolver from './postListResolver'; @@ -13,6 +14,7 @@ export { postListCached, postListExposure, postListExposureScoped, + projectsListExposureSecurity, postListParamsCheck, postListParamsCheckServer, postListResolver, diff --git a/lib/namedQuery/testing/bootstrap/queries/projectsListExposureSecurity.js b/lib/namedQuery/testing/bootstrap/queries/projectsListExposureSecurity.js new file mode 100644 index 00000000..4862a4e6 --- /dev/null +++ b/lib/namedQuery/testing/bootstrap/queries/projectsListExposureSecurity.js @@ -0,0 +1,87 @@ +import { createQuery } from 'meteor/cultofcoders:grapher'; +import { Projects } from '../../../../query/testing/bootstrap/projects/collection'; + +const projectsListExposureSecurity = createQuery('projectsListExposureSecurity', { + projects: { + name: 1, + private: 1, + projectValue: 1, + files: { + filename: 1, + public: 1, + }, + } +}, { + scoped: true, +}); + +if (Meteor.isServer) { + Meteor.methods({ + updateProject({projectId, ...updates}) { + Projects.update(projectId, {$set: updates}); + }, + resetProjects() { + Projects.update({name: 'Project 1'}, {$set: {private: false}}); + Projects.update({name: 'Project 2'}, {$set: {private: true}}); + } + }) + + projectsListExposureSecurity.expose({ + firewall(userId, params) { + }, + embody: { + $filter({filters, params}) { + filters.title = params.title + } + }, + subscriptionFilter(type, doc, oldDoc, options) { + const {node, parents} = options; + const path = []; + let n = node; + while (n) { + if (n.linkName) { + path.push(n.linkName); + } + n = n.parent; + } + + // this is how to fetch full doc, not only changed fields when observe-changes triggers + // if (type === 'observe-changes') { + // const collectionName = node.collection._name; + // const wholeDoc = options.publication._session.collectionViews.get(collectionName).documents.get(doc._id).getFields(); + // } + + const dottedPath = path.reverse().join('.'); + if (dottedPath === '') { + if (doc.private) { + doc = _.clone(doc); + if (type === 'added') { + delete doc.projectValue; + } + else if (type === 'observe-changes') { + doc.projectValue = undefined; + } + return doc; + } + else if (type === 'observe-changes') { + if (!doc.private) { + doc = _.clone(doc); + // db query required here, collectionView does not include projectValue + const {projectValue} = Projects.findOne(doc._id, {fields: {projectValue: 1}}); + doc.projectValue = projectValue; + return doc; + } + } + } + else if (dottedPath === 'files') { + const [project] = parents; + if (project.private && !doc.public) { + return undefined; + } + } + return doc; + } + }); +} + +export default projectsListExposureSecurity; diff --git a/lib/namedQuery/testing/client.test.js b/lib/namedQuery/testing/client.test.js index d40b266f..2ea669f3 100755 --- a/lib/namedQuery/testing/client.test.js +++ b/lib/namedQuery/testing/client.test.js @@ -1,6 +1,7 @@ -import { assert } from 'chai'; +import { assert, expect } from 'chai'; import postListExposure, {postListFilteredWithDate} from './bootstrap/queries/postListExposure.js'; import postListExposureScoped from './bootstrap/queries/postListExposureScoped'; +import projectsListExposureSecurity from './bootstrap/queries/projectsListExposureSecurity'; import userListScoped from './bootstrap/queries/userListScoped'; import { createQuery } from 'meteor/cultofcoders:grapher'; import Posts from '../../query/testing/bootstrap/posts/collection'; @@ -262,4 +263,136 @@ describe('Named Query', function () { } }); }); + + describe('Protected reactive queries', () => { + beforeEach(done => { + Meteor.call('resetProjects', {}, done); + }); + afterEach(done => { + Meteor.call('resetProjects', {}, done); + Tracker.flush(); + }); + + it('Should work with protected reactive queries', done => { + const query = projectsListExposureSecurity.clone({}); + const handle = query.subscribe(); + + Tracker.autorun(c => { + if (handle.ready()) { + c.stop(); + + const projects = query.fetch(); + handle.stop(); + + // console.log(JSON.stringify(projects, null, 2)); + + expect(projects).to.have.length(2); + + const [project1, project2] = projects; + expect(project1.projectValue).to.be.equal(10000); + expect(project2.projectValue).to.be.undefined; + + done(); + } + }); + }); + + it('Should work with protected reactive queries after the change', function(done) { + const query = projectsListExposureSecurity.clone({log: true}); + const handle = query.subscribe(); + + let firstCall = true; + Tracker.autorun(c => { + if (handle.ready()) { + if (firstCall) { + const projects = query.fetch() + + expect(projects).to.have.length(2); + + const [project1, project2] = projects; + expect(project1.projectValue).to.be.equal(10000); + expect(project2.projectValue).to.be.undefined; + expect(project1.files).to.have.length(2); + // expect(project2.files).to.have.length(1); + + firstCall = false; + Meteor.call('updateProject', {projectId: project1._id, private: true}, (err, res) => { + + }); + } + else { + const projects = query.fetch(); + + const [project1, project2] = projects; + + // console.log(project1.name, project1.private, project1.projectValue); + // console.log(project2.name, project2.private, project2.projectValue); + + expect(project1.projectValue).to.be.undefined; + expect(project2.projectValue).to.be.undefined; + + // if (project1.files.length !== 1) { + // console.log('waiting for potentially one more callback', project1.files); + // return; + // } + + c.stop(); + handle.stop(); + + expect(project1.files).to.have.length(1); + expect(project1.files[0].filename).to.be.equal('invoice.pdf'); + // expect(project2.filesMany).to.have.length(1); + + done(); + } + } + }); + }); + + it('Should bring back field after the change', done => { + const query = projectsListExposureSecurity.clone({}); + const handle = query.subscribe(); + + let firstCall = true; + Tracker.autorun(c => { + if (handle.ready()) { + if (firstCall) { + const projects = query.fetch(); + + expect(projects).to.have.length(2); + + const [project1, project2] = projects; + expect(project1.projectValue).to.be.equal(10000); + expect(project2.projectValue).to.be.undefined; + expect(project2.files).to.have.length(0); + + firstCall = false; + Meteor.call('updateProject', {projectId: project2._id, private: false}, (err, res) => { + + }); + } + else { + const projects = query.fetch(); + const [project1, project2] = projects; + + if (project2.projectValue === undefined) { + return; + } + + c.stop(); + handle.stop(); + + // console.log(project1.name, project1.private, project1.projectValue); + // console.log(project2.name, project2.private, project2.projectValue); + + expect(project1.projectValue).to.be.equal(10000); + expect(project2.projectValue).to.be.equal(20000); + // expect(project2.files).to.have.length(1); + + done(); + } + } + }); + }); + }); }); diff --git a/lib/query/lib/recursiveCompose.js b/lib/query/lib/recursiveCompose.js index 58d66aa2..8564bba4 100755 --- a/lib/query/lib/recursiveCompose.js +++ b/lib/query/lib/recursiveCompose.js @@ -23,9 +23,75 @@ function patchCursor(cursor, ns) { }; } +function patchCursorForFiltering(options) { + const {cursor, filter} = options; + + // if (options.params.log) { + // const sessions = Meteor.server.sessions; + // console.log('sessions', sessions.keys()); + + // if (cursor._cursorDescription.collectionName === 'files') { + // sessions.forEach(session => { + // const view = session.collectionViews; + // const filesView = view.get('files'); + // if (!filesView) { + // console.log('filesView undefined'); + // } + // if (filesView) { + // const files = filesView.documents; + // files.forEach(file => { + // console.log(file.getFields()); + // }); + // } + // }); + // } + // } + + const originalObserve = cursor.observe; + + cursor.observe = function (callbacks) { + const newCallbacks = Object.assign({}, callbacks); + if (callbacks.added) { + newCallbacks.added = doc => { + const filteredDoc = filter('added', doc, null, options); + if (_.isObject(filteredDoc)) { + callbacks.added(filteredDoc); + } + }; + } + if (callbacks.changed) { + newCallbacks.changed = function (newDoc, oldDoc) { + const filteredDoc = filter('changed', newDoc, oldDoc, options); + callbacks.changed(filteredDoc, oldDoc); + }; + } + + // this probably does not make a lot of sense + // if (callbacks.removed) { + // newCallbacks.removed = doc => { + // callbacks.removed(doc); + // }; + // } + return originalObserve.call(cursor, newCallbacks); + }; + + const originalObserveChanges = cursor.observeChanges; + cursor.observeChanges = function (callbacks) { + const newCallbacks = Object.assign({}, callbacks); + if (callbacks.changed) { + newCallbacks.changed = function (id, fields) { + const filteredFields = filter('observe-changes', {_id: id, ...fields}, null, options); + callbacks.changed(id, filteredFields); + }; + } + return originalObserveChanges.call(cursor, newCallbacks); + }; +} + function compose(node, userId, config) { return { - find(parent) { + find(...parents) { + const [parent] = parents; if (parent) { if (!config.blocking) { this.unblock(); @@ -51,6 +117,19 @@ function compose(node, userId, config) { if (config.scoped) { patchCursor(cursor, getNodeNamespace(node)); } + if (config.subscriptionFilter) { + patchCursorForFiltering({ + cursor, + filter: config.subscriptionFilter, + parents, + linker, + node, + filters, + options, + publication: config.publication, + params: config.params, + }); + } return cursor; } }, @@ -72,6 +151,19 @@ export default (node, userId, config = {bypassFirewalls: false, scoped: false}) if (config.scoped) { patchCursor(cursor, getNodeNamespace(node)); } + if (config.subscriptionFilter) { + patchCursorForFiltering({ + cursor, + filter: config.subscriptionFilter, + parents: [], + linker: null, + node, + filters, + options, + publication: config.publication, + params: config.params, + }); + } return cursor; }, diff --git a/lib/query/testing/bootstrap/fixtures.js b/lib/query/testing/bootstrap/fixtures.js index ee118e88..08c1ec32 100755 --- a/lib/query/testing/bootstrap/fixtures.js +++ b/lib/query/testing/bootstrap/fixtures.js @@ -100,11 +100,12 @@ _.range(USERS).forEach(idx => { friendIds.push(id); }); -const project1 = Projects.insert({name: 'Project 1'}); -const project2 = Projects.insert({name: 'Project 2'}); +const project1 = Projects.insert({name: 'Project 1', projectValue: 10000}); +const project2 = Projects.insert({name: 'Project 2', projectValue: 20000, private: true}); Files.insert({ filename: 'test.txt', + public: false, metas: [{ type: 'text', projectId: project1, @@ -120,6 +121,7 @@ Files.insert({ Files.insert({ filename: 'invoice.pdf', + public: true, metas: [{ type: 'pdf', projectId: project2, From dbf6ee1d6b80b66f5c7ed7cb7c21205137a3f376 Mon Sep 17 00:00:00 2001 From: Berislav Date: Thu, 26 Oct 2023 09:13:21 +0200 Subject: [PATCH 2/3] Docs update - subscriptionFilter --- docs/named_queries.md | 53 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 53 insertions(+) diff --git a/docs/named_queries.md b/docs/named_queries.md index dc23e86b..bdbb0124 100755 --- a/docs/named_queries.md +++ b/docs/named_queries.md @@ -320,6 +320,29 @@ query.expose({ // This deep extends your graph's body before processing it. // For example, you want a hidden $filter() functionality, or anything else. embody: {}, // Accepts Object or Function(body, params) + + // This function enables you to modify the documents before they are sent to the client. + // You might want to check some field in the object and strip some values from it. + // If you have "private" field in a collection and some document has value private: true, perhaps you want + // to hide some things from unauthorized users. + // You should use query param "scoped: true" param if subscriptionFilter is used. + subscriptionFilter( + type, // 'added', 'changed', 'observe-changes' + newDoc, // for 'added' and 'changed' the whole doc, for 'observe-changes' only the changed fields with _id + oldDoc, // for 'added' and 'observe-changes' is null, for changed the old doc + + // options passed to the publication, such as: + // cursor - the cursor that is being published + // filter - actual subscriptionFilter + // parents - documents passed in from reywood:publish-composite + // linker - linker or null + // node - current node (field, reducer or collection) + // filters - actual filter being applied to this node + // options - MongoDB query options (sort, limit, skip) + // publication - reywood:publish-composite instance or undefined + // params - publication params or undefined + options, + ) {}, }) ``` @@ -367,6 +390,36 @@ query.expose({ Careful here, if you use the special `$body` parameter, embodyment will be performed on it. You have to handle manually these use-cases, but usually, you will use `embody` for filtering top level elements, so in most cases it will be simple and with no hassle. +### Subscription filters + +Use this if you need to perform additional modifications or even filter out documents that are being sent to the client. +You would use this when there are some fields in the document that warrant changes (i.e. remove particular information) +for particular a user or in general. + +```js +Projects.createQuery('getProjects', { + clientName: 1, + private: 1, + projectValue: 1, +}) + +query.expose({ + // For each private project (private: true), remove projectValue field + subscriptionFilter(type, doc, oldDoc, options) { + if (type === 'added') { + // NOTE: you can also return undefined when type is 'added' which means this doc won't be sent + // to the client at all + if (doc.private) { + // We have to clone it not to interfere with possibly other subscriptions + doc = _.clone(doc); + delete doc.projectValue; + return doc; + } + } + } +}) +``` + ## Resolvers Named Queries have the ability to morph themselves into a function that executes on the server. From 02f786122cfb28194549c96eadc1706f6aae1fe4 Mon Sep 17 00:00:00 2001 From: Berislav Date: Thu, 23 Nov 2023 12:23:39 +0100 Subject: [PATCH 3/3] Fixed MR error --- lib/namedQuery/testing/client.test.js | 3 +++ 1 file changed, 3 insertions(+) diff --git a/lib/namedQuery/testing/client.test.js b/lib/namedQuery/testing/client.test.js index f7aa3e93..c8885fa0 100755 --- a/lib/namedQuery/testing/client.test.js +++ b/lib/namedQuery/testing/client.test.js @@ -394,6 +394,9 @@ describe('Named Query', function () { } } }); + }); + }); + it('Should work with reactive queries containing link with foreignIdentityField', function (done) { const query = productsList.clone({ filters: {