From 358ae4662710c1bededfd714ae1b0b3368812573 Mon Sep 17 00:00:00 2001 From: Stefan Zermatten Date: Thu, 3 Nov 2022 19:08:44 +0200 Subject: [PATCH 1/5] Began work on copy to for library nodes --- .../api/library/methods/copyLibraryNodeTo.js | 100 ++++++++++++++++++ .../library/methods/duplicateLibraryNode.js | 2 +- 2 files changed, 101 insertions(+), 1 deletion(-) create mode 100644 app/imports/api/library/methods/copyLibraryNodeTo.js diff --git a/app/imports/api/library/methods/copyLibraryNodeTo.js b/app/imports/api/library/methods/copyLibraryNodeTo.js new file mode 100644 index 00000000..e87eb5e2 --- /dev/null +++ b/app/imports/api/library/methods/copyLibraryNodeTo.js @@ -0,0 +1,100 @@ +import { ValidatedMethod } from 'meteor/mdg:validated-method'; +import SimpleSchema from 'simpl-schema'; +i +import { RateLimiterMixin } from 'ddp-rate-limiter-mixin'; +import { RefSchema } from '/imports/api/parenting/ChildSchema.js'; +import LibraryNodes from '/imports/api/library/LibraryNodes.js'; +import { assertDocEditPermission } from '/imports/api/sharing/sharingPermissions.js'; +import { + setLineageOfDocs, + renewDocIds +} from '/imports/api/parenting/parenting.js'; +import { reorderDocs } from '/imports/api/parenting/order.js'; +import fetchDocByRef from '/imports/api/parenting/fetchDocByRef.js'; + +var snackbar; +if (Meteor.isClient) { + snackbar = require( + '/imports/ui/components/snackbars/SnackbarQueue.js' + ).snackbar +} + +const DUPLICATE_CHILDREN_LIMIT = 100; + +const copyLibraryNodeTo = new ValidatedMethod({ + name: 'libraryNodes.copyTo', + validate: new SimpleSchema({ + _id: { + type: String, + regEx: SimpleSchema.RegEx.Id, + }, + parent: { + type: RefSchema, + }, + }).validator(), + mixins: [RateLimiterMixin], + rateLimit: { + numRequests: 1, + timeInterval: 5000, + }, + run({ _id, parent }) { + if (parent.collection !== 'libraryNodes' && parent.collection !== 'libraries') { + throw new Meteor.Error('Invalid destination', + 'Library documents can only be copied to destinations inside other libraries' + ); + } + const libraryNode = LibraryNodes.findOne(_id); + const parentDoc = fetchDocByRef(parent); + assertDocEditPermission(libraryNode, this.userId); + + let randomSrc = DDP.randomStream('copyLibraryNodeTo'); + let libraryNodeId = randomSrc.id(); + libraryNode._id = libraryNodeId; + + let decendants = LibraryNodes.find({ + 'ancestors.id': _id, + removed: { $ne: true }, + }, { + limit: DUPLICATE_CHILDREN_LIMIT + 1, + sort: { order: 1 }, + }).fetch(); + + if (decendants.length > DUPLICATE_CHILDREN_LIMIT) { + decendants.pop(); + if (Meteor.isClient) { + snackbar({ + text: `Only the first ${DUPLICATE_CHILDREN_LIMIT} children were duplicated`, + }); + } + } + + const nodes = [libraryNode, decendants]; + + const newAncestry = parentDoc.ancestors || []; + newAncestry.push(parent); + // re-map all the ancestors + setLineageOfDocs({ + docArray: nodes, + newAncestry, + oldParent: libraryNode.parent, + }); + + // Give the docs new IDs without breaking internal references + renewDocIds({ docArray: nodes }); + + // Order the root node + libraryNode.order = (parentDoc.order || 0) + 0.5; + + LibraryNodes.batchInsert(nodes); + + // Tree structure changed by inserts, reorder the tree + reorderDocs({ + collection: LibraryNodes, + ancestorId: parent.collection === 'libraries' ? parent.id : parentDoc.ancestors[0].id, + }); + + return libraryNodeId; + }, +}); + +export default duplicateLibraryNode; diff --git a/app/imports/api/library/methods/duplicateLibraryNode.js b/app/imports/api/library/methods/duplicateLibraryNode.js index 107f3c6b..59b54c8a 100644 --- a/app/imports/api/library/methods/duplicateLibraryNode.js +++ b/app/imports/api/library/methods/duplicateLibraryNode.js @@ -16,7 +16,7 @@ if (Meteor.isClient) { ).snackbar } -const DUPLICATE_CHILDREN_LIMIT = 50; +const DUPLICATE_CHILDREN_LIMIT = 100; const duplicateLibraryNode = new ValidatedMethod({ name: 'libraryNodes.duplicate', From 8f56a60fb142a9583525c6a2e9df2aa48f49854b Mon Sep 17 00:00:00 2001 From: Stefan Zermatten Date: Thu, 3 Nov 2022 20:18:59 +0200 Subject: [PATCH 2/5] Added copy-to and related sharing permissions --- .../api/library/methods/copyLibraryNodeTo.js | 23 +++-- .../library/methods/duplicateLibraryNode.js | 4 +- app/imports/api/library/methods/index.js | 1 + app/imports/api/sharing/SharingSchema.js | 4 + app/imports/api/sharing/sharing.js | 22 ++++- app/imports/api/sharing/sharingPermissions.js | 88 ++++++++++++++----- app/imports/ui/components/ColorPicker.vue | 4 + app/imports/ui/components/propertyToolbar.vue | 20 +++++ app/imports/ui/library/LibraryNodeDialog.vue | 47 +++++++++- .../ui/library/MoveLibraryNodeDialog.vue | 8 +- app/imports/ui/sharing/ShareDialog.vue | 20 +++++ 11 files changed, 202 insertions(+), 39 deletions(-) diff --git a/app/imports/api/library/methods/copyLibraryNodeTo.js b/app/imports/api/library/methods/copyLibraryNodeTo.js index e87eb5e2..f99b184f 100644 --- a/app/imports/api/library/methods/copyLibraryNodeTo.js +++ b/app/imports/api/library/methods/copyLibraryNodeTo.js @@ -1,10 +1,12 @@ import { ValidatedMethod } from 'meteor/mdg:validated-method'; import SimpleSchema from 'simpl-schema'; -i import { RateLimiterMixin } from 'ddp-rate-limiter-mixin'; import { RefSchema } from '/imports/api/parenting/ChildSchema.js'; import LibraryNodes from '/imports/api/library/LibraryNodes.js'; -import { assertDocEditPermission } from '/imports/api/sharing/sharingPermissions.js'; +import { + assertDocCopyPermission, + assertDocEditPermission +} from '/imports/api/sharing/sharingPermissions.js'; import { setLineageOfDocs, renewDocIds @@ -19,7 +21,7 @@ if (Meteor.isClient) { ).snackbar } -const DUPLICATE_CHILDREN_LIMIT = 100; +const DUPLICATE_CHILDREN_LIMIT = 500; const copyLibraryNodeTo = new ValidatedMethod({ name: 'libraryNodes.copyTo', @@ -35,7 +37,7 @@ const copyLibraryNodeTo = new ValidatedMethod({ mixins: [RateLimiterMixin], rateLimit: { numRequests: 1, - timeInterval: 5000, + timeInterval: 10000, }, run({ _id, parent }) { if (parent.collection !== 'libraryNodes' && parent.collection !== 'libraries') { @@ -45,11 +47,8 @@ const copyLibraryNodeTo = new ValidatedMethod({ } const libraryNode = LibraryNodes.findOne(_id); const parentDoc = fetchDocByRef(parent); - assertDocEditPermission(libraryNode, this.userId); - - let randomSrc = DDP.randomStream('copyLibraryNodeTo'); - let libraryNodeId = randomSrc.id(); - libraryNode._id = libraryNodeId; + assertDocCopyPermission(libraryNode, this.userId); + assertDocEditPermission(parentDoc, this.userId); let decendants = LibraryNodes.find({ 'ancestors.id': _id, @@ -68,7 +67,7 @@ const copyLibraryNodeTo = new ValidatedMethod({ } } - const nodes = [libraryNode, decendants]; + const nodes = [libraryNode, ...decendants]; const newAncestry = parentDoc.ancestors || []; newAncestry.push(parent); @@ -92,9 +91,7 @@ const copyLibraryNodeTo = new ValidatedMethod({ collection: LibraryNodes, ancestorId: parent.collection === 'libraries' ? parent.id : parentDoc.ancestors[0].id, }); - - return libraryNodeId; }, }); -export default duplicateLibraryNode; +export default copyLibraryNodeTo; diff --git a/app/imports/api/library/methods/duplicateLibraryNode.js b/app/imports/api/library/methods/duplicateLibraryNode.js index 59b54c8a..ff35b8fb 100644 --- a/app/imports/api/library/methods/duplicateLibraryNode.js +++ b/app/imports/api/library/methods/duplicateLibraryNode.js @@ -16,7 +16,7 @@ if (Meteor.isClient) { ).snackbar } -const DUPLICATE_CHILDREN_LIMIT = 100; +const DUPLICATE_CHILDREN_LIMIT = 500; const duplicateLibraryNode = new ValidatedMethod({ name: 'libraryNodes.duplicate', @@ -28,7 +28,7 @@ const duplicateLibraryNode = new ValidatedMethod({ }).validator(), mixins: [RateLimiterMixin], rateLimit: { - numRequests: 5, + numRequests: 1, timeInterval: 5000, }, run({ _id }) { diff --git a/app/imports/api/library/methods/index.js b/app/imports/api/library/methods/index.js index 1b566bc5..e67eec8a 100644 --- a/app/imports/api/library/methods/index.js +++ b/app/imports/api/library/methods/index.js @@ -1,2 +1,3 @@ +import '/imports/api/library/methods/copyLibraryNodeTo.js'; import '/imports/api/library/methods/duplicateLibraryNode.js'; import '/imports/api/library/methods/updateReferenceNode.js'; diff --git a/app/imports/api/sharing/SharingSchema.js b/app/imports/api/sharing/SharingSchema.js index 2a4f87c3..629f90bb 100644 --- a/app/imports/api/sharing/SharingSchema.js +++ b/app/imports/api/sharing/SharingSchema.js @@ -33,6 +33,10 @@ let SharingSchema = new SimpleSchema({ defaultValue: false, index: 1, }, + readersCanCopy: { + type: Boolean, + optional: true, + }, }); export default SharingSchema; diff --git a/app/imports/api/sharing/sharing.js b/app/imports/api/sharing/sharing.js index f6c8d8b7..7062ff40 100644 --- a/app/imports/api/sharing/sharing.js +++ b/app/imports/api/sharing/sharing.js @@ -27,6 +27,26 @@ const setPublic = new ValidatedMethod({ }, }); +const setReadersCanCopy = new ValidatedMethod({ + name: 'sharing.setReadersCanCopy', + validate: new SimpleSchema({ + docRef: RefSchema, + readersCanCopy: { type: Boolean }, + }).validator(), + mixins: [RateLimiterMixin], + rateLimit: { + numRequests: 5, + timeInterval: 5000, + }, + run({ docRef, readersCanCopy }) { + let doc = fetchDocByRef(docRef); + assertOwnership(doc, this.userId); + return getCollectionByName(docRef.collection).update(docRef.id, { + $set: { readersCanCopy }, + }); + }, +}); + const updateUserSharePermissions = new ValidatedMethod({ name: 'sharing.updateUserSharePermissions', validate: new SimpleSchema({ @@ -129,4 +149,4 @@ const transferOwnership = new ValidatedMethod({ }, }); -export { setPublic, updateUserSharePermissions, transferOwnership }; +export { setPublic, setReadersCanCopy, updateUserSharePermissions, transferOwnership }; diff --git a/app/imports/api/sharing/sharingPermissions.js b/app/imports/api/sharing/sharingPermissions.js index 58629c62..5591c59c 100644 --- a/app/imports/api/sharing/sharingPermissions.js +++ b/app/imports/api/sharing/sharingPermissions.js @@ -1,24 +1,25 @@ import { _ } from 'meteor/underscore'; import fetchDocByRef from '/imports/api/parenting/fetchDocByRef.js'; -function assertIdValid(userId){ - if (!userId || typeof userId !== 'string'){ +function assertIdValid(userId) { + if (!userId || typeof userId !== 'string') { throw new Meteor.Error('Permission denied', 'No user ID. Are you logged in?'); } } -function assertdocExists(doc){ - if (!doc){ +function assertdocExists(doc) { + if (!doc) { throw new Meteor.Error('Permission denied', 'Permission denied: No such document exists'); } } -export function assertOwnership(doc, userId){ +export function assertOwnership(doc, userId) { assertIdValid(userId); assertdocExists(doc); - if (doc.owner === userId ){ + + if (doc.owner === userId) { return true; } else { throw new Meteor.Error('Permission denied', @@ -37,13 +38,12 @@ export function assertEditPermission(doc, userId) { assertdocExists(doc); const user = Meteor.users.findOne(userId, { fields: { - 'services.patreon': 1, 'roles': 1, } }); // Admin override - if (user.roles && user.roles.includes('admin')){ + if (user.roles && user.roles.includes('admin')) { return true; } @@ -51,7 +51,7 @@ export function assertEditPermission(doc, userId) { if ( doc.owner === userId || _.contains(doc.writers, userId) - ){ + ) { return true; } else { throw new Meteor.Error('Edit permission denied', @@ -59,9 +59,46 @@ export function assertEditPermission(doc, userId) { } } -function getRoot(doc){ +/** + * Assert that the user can edit the root document which manages its own sharing + * permissions. + * + * Warning: the doc and userId must be set by a trusted source + */ +export function assertCopyPermission(doc, userId) { + assertIdValid(userId); assertdocExists(doc); - if (doc.ancestors && doc.ancestors.length && doc.ancestors[0]){ + const user = Meteor.users.findOne(userId, { + fields: { + 'roles': 1, + } + }); + + // Admin override + if (user.roles && user.roles.includes('admin')) { + return true; + } + + // Ensure the user is authorized for this specific document + if ( + doc.owner === userId || + _.contains(doc.writers, userId) + ) { + return true; + } else if ( + (_.contains(doc.readers, userId) || doc.public) && + doc.readersCanCopy + ) { + return true; + } else { + throw new Meteor.Error('Copy permission denied', + 'You do not have permission to copy this document'); + } +} + +function getRoot(doc) { + assertdocExists(doc); + if (doc.ancestors && doc.ancestors.length && doc.ancestors[0]) { return fetchDocByRef(doc.ancestors[0]); } else { return doc; @@ -74,11 +111,22 @@ function getRoot(doc){ * * Warning: the doc and userId must be set by a trusted source */ -export function assertDocEditPermission(doc, userId){ +export function assertDocEditPermission(doc, userId) { let root = getRoot(doc); assertEditPermission(root, userId); } +/** + * Assert that the user can copy a descendant document whose root ancestor + * implements sharing permissions. + * + * Warning: the doc and userId must be set by a trusted source + */ +export function assertDocCopyPermission(doc, userId) { + let root = getRoot(doc); + assertCopyPermission(root, userId); +} + export function assertViewPermission(doc, userId) { assertdocExists(doc); if (doc.public) return true; @@ -88,17 +136,17 @@ export function assertViewPermission(doc, userId) { doc.owner === userId || _.contains(doc.readers, userId) || _.contains(doc.writers, userId) - ){ + ) { return true; } else { - + // Admin override const user = Meteor.users.findOne(userId, { fields: { 'roles': 1, } }); - if (user.roles && user.roles.includes('admin')){ + if (user.roles && user.roles.includes('admin')) { return true; } @@ -113,20 +161,20 @@ export function assertViewPermission(doc, userId) { * * Warning: the doc and userId must be set by a trusted source */ -export function assertDocViewPermission(doc, userId){ +export function assertDocViewPermission(doc, userId) { let root = getRoot(doc); assertViewPermission(root, userId); } -export function assertAdmin(userId){ +export function assertAdmin(userId) { assertIdValid(userId); - let user = Meteor.users.findOne(userId, {fields: {roles: 1}}); - if (!user){ + let user = Meteor.users.findOne(userId, { fields: { roles: 1 } }); + if (!user) { throw new Meteor.Error('Permission denied', 'UserId does not match any existing user'); } let isAdmin = user.roles && user.roles.includes('admin') - if (!isAdmin){ + if (!isAdmin) { throw new Meteor.Error('Permission denied', 'User does not have the admin role'); } diff --git a/app/imports/ui/components/ColorPicker.vue b/app/imports/ui/components/ColorPicker.vue index bb1115f6..cc1423bd 100644 --- a/app/imports/ui/components/ColorPicker.vue +++ b/app/imports/ui/components/ColorPicker.vue @@ -10,6 +10,7 @@ :outlined="!!label" :icon="!label" :min-width="label && 108" + :disabled="context.editPermission === false" v-on="on" > {{ label }} @@ -124,6 +125,9 @@ } export default { + inject: { + context: { default: {} } + }, props: { //hex string value: { diff --git a/app/imports/ui/components/propertyToolbar.vue b/app/imports/ui/components/propertyToolbar.vue index 28b454d7..af6ba016 100644 --- a/app/imports/ui/components/propertyToolbar.vue +++ b/app/imports/ui/components/propertyToolbar.vue @@ -69,6 +69,7 @@ @@ -80,8 +81,23 @@ mdi-content-copy + + + + Copy To + + + + mdi-content-duplicate + + @@ -95,6 +111,7 @@ @@ -157,6 +174,9 @@ export default { PropertyIcon, ColorPicker, }, + inject: { + context: { default: {} } + }, props: { model: { type: Object, diff --git a/app/imports/ui/library/LibraryNodeDialog.vue b/app/imports/ui/library/LibraryNodeDialog.vue index e67c0cc2..977f3906 100644 --- a/app/imports/ui/library/LibraryNodeDialog.vue +++ b/app/imports/ui/library/LibraryNodeDialog.vue @@ -8,6 +8,7 @@ :embedded="embedded" @duplicate="duplicate" @move="move" + @copy="copy" @remove="remove" @toggle-editing="editing = !editing" @color-changed="value => change({path: ['color'], value})" @@ -95,10 +96,13 @@ import propertyFormIndex from '/imports/ui/properties/forms/shared/propertyFormIndex.js'; import propertyViewerIndex from '/imports/ui/properties/viewers/shared/propertyViewerIndex.js'; import { get } from 'lodash'; - import { assertDocEditPermission } from '/imports/api/sharing/sharingPermissions.js'; + import { + assertDocEditPermission, assertDocCopyPermission + } from '/imports/api/sharing/sharingPermissions.js'; import { organizeDoc } from '/imports/api/parenting/organizeMethods.js'; import { snackbar } from '/imports/ui/components/snackbars/SnackbarQueue.js'; import getPropertyTitle from '/imports/ui/properties/shared/getPropertyTitle.js'; + import copyLibraryNodeTo from '/imports/api/library/methods/copyLibraryNodeTo.js'; let formIndex = {}; for (let key in propertyFormIndex){ @@ -126,7 +130,7 @@ }, reactiveProvide: { name: 'context', - include: ['editPermission', 'isLibraryForm'], + include: ['editPermission', 'copyPermission', 'isLibraryForm'], }, data(){return { editing: !!this.startInEditTab, @@ -162,6 +166,14 @@ return false; } }, + copyPermission(){ + try { + assertDocCopyPermission(this.model, Meteor.userId()); + return true; + } catch (e) { + return false; + } + }, }, methods: { getPropertyName, @@ -200,6 +212,37 @@ } }); }, + copy(){ + const thisId = this._id; + this.$store.commit('pushDialogStack', { + component: 'move-library-node-dialog', + elementId: 'property-toolbar-menu-button', + data: { + action: 'Copy', + }, + callback(parentId){ + if (!parentId) return; + copyLibraryNodeTo.call({ + _id: thisId, + parent: { + collection: 'libraryNodes', + id: parentId + }, + }, (error) => { + if (error) { + console.error(error); + snackbar({ + text: error.reason || error.message || error.toString(), + }); + } else { + snackbar({ + text: 'Copied successfully', + }); + } + }); + } + }); + }, change({path, value, ack}){ updateLibraryNode.call({_id: this.currentId, path, value}, (error) =>{ if (ack){ diff --git a/app/imports/ui/library/MoveLibraryNodeDialog.vue b/app/imports/ui/library/MoveLibraryNodeDialog.vue index ce412813..f0d22101 100644 --- a/app/imports/ui/library/MoveLibraryNodeDialog.vue +++ b/app/imports/ui/library/MoveLibraryNodeDialog.vue @@ -16,7 +16,7 @@ color="primary" @click="$store.dispatch('popDialogStack', node._id)" > - Move + {{ action || 'Move' }} @@ -30,6 +30,12 @@ export default { DialogBase, LibraryAndNode, }, + props: { + action: { + type: String, + default: undefined, + }, + }, data() { return { node: undefined, diff --git a/app/imports/ui/sharing/ShareDialog.vue b/app/imports/ui/sharing/ShareDialog.vue index 8cd82873..27d8318f 100644 --- a/app/imports/ui/sharing/ShareDialog.vue +++ b/app/imports/ui/sharing/ShareDialog.vue @@ -13,6 +13,16 @@ :value="!!model.public + ''" @change="(value, ack) => setSheetPublic({value, ack})" /> + @@ -126,6 +137,7 @@