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})"
/>
+ setReadersCanCopy({value, ack})"
+ />
getUser({value, ack})"
/>
@@ -126,6 +137,7 @@