diff options
Diffstat (limited to 'collections')
-rw-r--r-- | collections/activities.js | 51 | ||||
-rw-r--r-- | collections/attachments.js | 79 | ||||
-rw-r--r-- | collections/boards.js | 251 | ||||
-rw-r--r-- | collections/cards.js | 287 | ||||
-rw-r--r-- | collections/lists.js | 94 | ||||
-rw-r--r-- | collections/users.js | 106 |
6 files changed, 868 insertions, 0 deletions
diff --git a/collections/activities.js b/collections/activities.js new file mode 100644 index 00000000..1e24cf7c --- /dev/null +++ b/collections/activities.js @@ -0,0 +1,51 @@ +// Activities don't need a schema because they are always set from the a trusted +// environment - the server - and there is no risk that a user change the logic +// we use with this collection. Moreover using a schema for this collection +// would be difficult (different activities have different fields) and wouldn't +// bring any direct advantage. +// +// XXX The activities API is not so nice and need some functionalities. For +// instance if a user archive a card, and un-archive it a few seconds later we +// should remove both activities assuming it was an error the user decided to +// revert. +Activities = new Mongo.Collection('activities'); + +Activities.helpers({ + board: function() { + return Boards.findOne(this.boardId); + }, + user: function() { + return Users.findOne(this.userId); + }, + member: function() { + return Users.findOne(this.memberId); + }, + list: function() { + return Lists.findOne(this.listId); + }, + oldList: function() { + return Lists.findOne(this.oldListId); + }, + card: function() { + return Cards.findOne(this.cardId); + }, + comment: function() { + return CardComments.findOne(this.commentId); + }, + attachment: function() { + return Attachments.findOne(this.attachmentId); + } +}); + +Activities.before.insert(function(userId, doc) { + doc.createdAt = new Date(); +}); + +// For efficiency create an index on the date of creation. +if (Meteor.isServer) { + Meteor.startup(function() { + Activities._collection._ensureIndex({ + createdAt: -1 + }); + }); +} diff --git a/collections/attachments.js b/collections/attachments.js new file mode 100644 index 00000000..c8fe6b18 --- /dev/null +++ b/collections/attachments.js @@ -0,0 +1,79 @@ +Attachments = new FS.Collection('attachments', { + stores: [ + + // XXX Add a new store for cover thumbnails so we don't load big images in + // the general board view + new FS.Store.GridFS('attachments') + ] +}); + +if (Meteor.isServer) { + Attachments.allow({ + insert: function(userId, doc) { + return allowIsBoardMember(userId, Boards.findOne(doc.boardId)); + }, + update: function(userId, doc) { + return allowIsBoardMember(userId, Boards.findOne(doc.boardId)); + }, + remove: function(userId, doc) { + return allowIsBoardMember(userId, Boards.findOne(doc.boardId)); + }, + // We authorize the attachment download either: + // - if the board is public, everyone (even unconnected) can download it + // - if the board is private, only board members can download it + // + // XXX We have a bug with the `userId` verification: + // + // https://github.com/CollectionFS/Meteor-CollectionFS/issues/449 + // + download: function(userId, doc) { + var query = { + $or: [ + { 'members.userId': userId }, + { permission: 'public' } + ] + }; + return !! Boards.findOne(doc.boardId, query); + }, + + fetch: ['boardId'] + }); +} + +// XXX Enforce a schema for the Attachments CollectionFS + +Attachments.files.before.insert(function(userId, doc) { + var file = new FS.File(doc); + doc.userId = userId; + + // If the uploaded document is not an image we need to enforce browser + // download instead of execution. This is particularly important for HTML + // files that the browser will just execute if we don't serve them with the + // appropriate `application/octet-stream` MIME header which can lead to user + // data leaks. I imagine other formats (like PDF) can also be attack vectors. + // See https://github.com/libreboard/libreboard/issues/99 + // XXX Should we use `beforeWrite` option of CollectionFS instead of + // collection-hooks? + if (! file.isImage()) { + file.original.type = 'application/octet-stream'; + } +}); + +if (Meteor.isServer) { + Attachments.files.after.insert(function(userId, doc) { + Activities.insert({ + type: 'card', + activityType: 'addAttachment', + attachmentId: doc._id, + boardId: doc.boardId, + cardId: doc.cardId, + userId: userId + }); + }); + + Attachments.files.after.remove(function(userId, doc) { + Activities.remove({ + attachmentId: doc._id + }); + }); +} diff --git a/collections/boards.js b/collections/boards.js new file mode 100644 index 00000000..e406b10c --- /dev/null +++ b/collections/boards.js @@ -0,0 +1,251 @@ +Boards = new Mongo.Collection('boards'); + +Boards.attachSchema(new SimpleSchema({ + title: { + type: String + }, + slug: { + type: String + }, + archived: { + type: Boolean + }, + createdAt: { + type: Date, + denyUpdate: true + }, + // XXX Inconsistent field naming + modifiedAt: { + type: Date, + denyInsert: true, + optional: true + }, + // De-normalized number of users that have starred this board + stars: { + type: Number + }, + // De-normalized label system + 'labels.$._id': { + // We don't specify that this field must be unique in the board because that + // will cause performance penalties and is not necessary since this field is + // always set on the server. + // XXX Actually if we create a new label, the `_id` is set on the client + // without being overwritten by the server, could it be a problem? + type: String + }, + 'labels.$.name': { + type: String, + optional: true + }, + 'labels.$.color': { + type: String, + allowedValues: [ + 'green', 'yellow', 'orange', 'red', 'purple', + 'blue', 'sky', 'lime', 'pink', 'black' + ] + }, + // XXX We might want to maintain more informations under the member sub- + // documents like de-normalized meta-data (the date the member joined the + // board, the number of contributions, etc.). + 'members.$.userId': { + type: String + }, + 'members.$.isAdmin': { + type: Boolean + }, + 'members.$.isActive': { + type: Boolean + }, + permission: { + type: String, + allowedValues: ['public', 'private'] + }, + color: { + type: String, + allowedValues: ['nephritis', 'pomegranate', 'belize', + 'wisteria', 'midnight', 'pumpkin'] + } +})); + +if (Meteor.isServer) { + Boards.allow({ + insert: Meteor.userId, + update: allowIsBoardAdmin, + remove: allowIsBoardAdmin, + fetch: ['members'] + }); + + // The number of users that have starred this board is managed by trusted code + // and the user is not allowed to update it + Boards.deny({ + update: function(userId, board, fieldNames) { + return _.contains(fieldNames, 'stars'); + }, + fetch: [] + }); + + // We can't remove a member if it is the last administrator + Boards.deny({ + update: function(userId, doc, fieldNames, modifier) { + if (! _.contains(fieldNames, 'members')) + return false; + + // We only care in case of a $pull operation, ie remove a member + if (! _.isObject(modifier.$pull && modifier.$pull.members)) + return false; + + // If there is more than one admin, it's ok to remove anyone + var nbAdmins = _.filter(doc.members, function(member) { + return member.isAdmin; + }).length; + if (nbAdmins > 1) + return false; + + // If all the previous conditions where verified, we can't remove + // a user if it's an admin + var removedMemberId = modifier.$pull.members.userId; + return !! _.findWhere(doc.members, { + userId: removedMemberId, + isAdmin: true + }); + }, + fetch: ['members'] + }); +} + +Boards.helpers({ + isPublic: function() { + return this.permission === 'public'; + }, + lists: function() { + return Lists.find({ boardId: this._id, archived: false }, + { sort: { sort: 1 }}); + }, + activities: function() { + return Activities.find({ boardId: this._id }, { sort: { createdAt: -1 }}); + }, + absoluteUrl: function() { + return Router.path('Board', { boardId: this._id, slug: this.slug }); + }, + colorClass: function() { + return 'board-color-' + this.color; + } +}); + +Boards.before.insert(function(userId, doc) { + // XXX We need to improve slug management. Only the id should be necessary + // to identify a board in the code. + // XXX If the board title is updated, the slug should also be updated. + // In some cases (Chinese and Japanese for instance) the `getSlug` function + // return an empty string. This is causes bugs in our application so we set + // a default slug in this case. + doc.slug = getSlug(doc.title) || 'board'; + doc.createdAt = new Date(); + doc.archived = false; + doc.members = [{ + userId: userId, + isAdmin: true, + isActive: true + }]; + doc.stars = 0; + doc.color = Boards.simpleSchema()._schema.color.allowedValues[0]; + + // Handle labels + var colors = Boards.simpleSchema()._schema['labels.$.color'].allowedValues; + var defaultLabelsColors = _.clone(colors).splice(0, 6); + doc.labels = []; + _.each(defaultLabelsColors, function(val) { + doc.labels.push({ + _id: Random.id(6), + name: '', + color: val + }); + }); + + // We randomly chose one of the default background colors for the board + if (Meteor.isClient) { + doc.background = { + type: 'color', + color: Random.choice(Boards.simpleSchema()._schema.color.allowedValues) + }; + } +}); + +Boards.before.update(function(userId, doc, fieldNames, modifier) { + modifier.$set = modifier.$set || {}; + modifier.$set.modifiedAt = new Date(); +}); + +if (Meteor.isServer) { + // Let MongoDB ensure that a member is not included twice in the same board + Meteor.startup(function() { + Boards._collection._ensureIndex({ + _id: 1, + 'members.userId': 1 + }, { unique: true }); + }); + + // Genesis: the first activity of the newly created board + Boards.after.insert(function(userId, doc) { + Activities.insert({ + type: 'board', + activityTypeId: doc._id, + activityType: 'createBoard', + boardId: doc._id, + userId: userId + }); + }); + + // If the user remove one label from a board, we cant to remove reference of + // this label in any card of this board. + Boards.after.update(function(userId, doc, fieldNames, modifier) { + if (! _.contains(fieldNames, 'labels') || + ! modifier.$pull || + ! modifier.$pull.labels || + ! modifier.$pull.labels._id) + return; + + var removedLabelId = modifier.$pull.labels._id; + Cards.update( + { boardId: doc._id }, + { + $pull: { + labels: removedLabelId + } + }, + { multi: true } + ); + }); + + // Add a new activity if we add or remove a member to the board + Boards.after.update(function(userId, doc, fieldNames, modifier) { + if (! _.contains(fieldNames, 'members')) + return; + + var memberId; + + // Say hello to the new member + if (modifier.$push && modifier.$push.members) { + memberId = modifier.$push.members.userId; + Activities.insert({ + type: 'member', + activityType: 'addBoardMember', + boardId: doc._id, + userId: userId, + memberId: memberId + }); + } + + // Say goodbye to the former member + if (modifier.$pull && modifier.$pull.members) { + memberId = modifier.$pull.members.userId; + Activities.insert({ + type: 'member', + activityType: 'removeBoardMember', + boardId: doc._id, + userId: userId, + memberId: memberId + }); + } + }); +} diff --git a/collections/cards.js b/collections/cards.js new file mode 100644 index 00000000..538b6af4 --- /dev/null +++ b/collections/cards.js @@ -0,0 +1,287 @@ +Cards = new Mongo.Collection('cards'); +CardComments = new Mongo.Collection('card_comments'); + +// XXX To improve pub/sub performances a card document should include a +// de-normalized number of comments so we don't have to publish the whole list +// of comments just to display the number of them in the board view. +Cards.attachSchema(new SimpleSchema({ + title: { + type: String + }, + archived: { + type: Boolean + }, + listId: { + type: String + }, + // The system could work without this `boardId` information (we could deduce + // the board identifier from the card), but it would make the system more + // difficult to manage and less efficient. + boardId: { + type: String + }, + coverId: { + type: String, + optional: true + }, + createdAt: { + type: Date, + denyUpdate: true + }, + dateLastActivity: { + type: Date + }, + description: { + type: String, + optional: true + }, + labelIds: { + type: [String], + optional: true + }, + members: { + type: [String], + optional: true + }, + // XXX Should probably be called `authorId`. Is it even needed since we have + // the `members` field? + userId: { + type: String + }, + sort: { + type: Number, + decimal: true + } +})); + +CardComments.attachSchema(new SimpleSchema({ + boardId: { + type: String + }, + cardId: { + type: String + }, + // XXX Rename in `content`? `text` is a bit vague... + text: { + type: String + }, + // XXX We probably don't need this information here, since we already have it + // in the associated comment creation activity + createdAt: { + type: Date, + denyUpdate: false + }, + // XXX Should probably be called `authorId` + userId: { + type: String + } +})); + +if (Meteor.isServer) { + Cards.allow({ + insert: function(userId, doc) { + return allowIsBoardMember(userId, Boards.findOne(doc.boardId)); + }, + update: function(userId, doc) { + return allowIsBoardMember(userId, Boards.findOne(doc.boardId)); + }, + remove: function(userId, doc) { + return allowIsBoardMember(userId, Boards.findOne(doc.boardId)); + }, + fetch: ['boardId'] + }); + + CardComments.allow({ + insert: function(userId, doc) { + return allowIsBoardMember(userId, Boards.findOne(doc.boardId)); + }, + update: function(userId, doc) { + return userId === doc.userId; + }, + remove: function(userId, doc) { + return userId === doc.userId; + }, + fetch: ['userId', 'boardId'] + }); +} + +Cards.helpers({ + list: function() { + return Lists.findOne(this.listId); + }, + board: function() { + return Boards.findOne(this.boardId); + }, + labels: function() { + var self = this; + var boardLabels = self.board().labels; + var cardLabels = _.filter(boardLabels, function(label) { + return _.contains(self.labelIds, label._id); + }); + return cardLabels; + }, + user: function() { + return Users.findOne(this.userId); + }, + activities: function() { + return Activities.find({ type: 'card', cardId: this._id }, + { sort: { createdAt: -1 }}); + }, + comments: function() { + return CardComments.find({ cardId: this._id }, { sort: { createdAt: -1 }}); + }, + attachments: function() { + return Attachments.find({ cardId: this._id }, { sort: { uploadedAt: -1 }}); + }, + cover: function() { + return Attachments.findOne(this.coverId); + }, + absoluteUrl: function() { + var board = this.board(); + return Router.path('Card', { + boardId: board._id, + slug: board.slug, + cardId: this._id + }); + }, + rootUrl: function() { + return Meteor.absoluteUrl(this.absoluteUrl().replace('/', '')); + } +}); + +CardComments.helpers({ + user: function() { + return Users.findOne(this.userId); + } +}); + +CardComments.hookOptions.after.update = { fetchPrevious: false }; +Cards.before.insert(function(userId, doc) { + doc.createdAt = new Date(); + doc.dateLastActivity = new Date(); + + // defaults + doc.archived = false; + + // userId native set. + if (! doc.userId) + doc.userId = userId; +}); + +CardComments.before.insert(function(userId, doc) { + doc.createdAt = new Date(); + doc.userId = userId; +}); + +if (Meteor.isServer) { + Cards.after.insert(function(userId, doc) { + Activities.insert({ + type: 'card', + activityType: 'createCard', + boardId: doc.boardId, + listId: doc.listId, + cardId: doc._id, + userId: userId + }); + }); + + // New activity for card (un)archivage + Cards.after.update(function(userId, doc, fieldNames) { + if (_.contains(fieldNames, 'archived')) { + if (doc.archived) { + Activities.insert({ + type: 'card', + activityType: 'archivedCard', + boardId: doc.boardId, + listId: doc.listId, + cardId: doc._id, + userId: userId + }); + } else { + Activities.insert({ + type: 'card', + activityType: 'restoredCard', + boardId: doc.boardId, + listId: doc.listId, + cardId: doc._id, + userId: userId + }); + } + } + }); + + // New activity for card moves + Cards.after.update(function(userId, doc, fieldNames) { + var oldListId = this.previous.listId; + if (_.contains(fieldNames, 'listId') && doc.listId !== oldListId) { + Activities.insert({ + type: 'card', + activityType: 'moveCard', + listId: doc.listId, + oldListId: oldListId, + boardId: doc.boardId, + cardId: doc._id, + userId: userId + }); + } + }); + + // Add a new activity if we add or remove a member to the card + Cards.before.update(function(userId, doc, fieldNames, modifier) { + if (! _.contains(fieldNames, 'members')) + return; + var memberId; + // Say hello to the new member + if (modifier.$addToSet && modifier.$addToSet.members) { + memberId = modifier.$addToSet.members; + if (! _.contains(doc.members, memberId)) { + Activities.insert({ + type: 'card', + activityType: 'joinMember', + boardId: doc.boardId, + cardId: doc._id, + userId: userId, + memberId: memberId + }); + } + } + + // Say goodbye to the former member + if (modifier.$pull && modifier.$pull.members) { + memberId = modifier.$pull.members; + Activities.insert({ + type: 'card', + activityType: 'unjoinMember', + boardId: doc.boardId, + cardId: doc._id, + userId: userId, + memberId: memberId + }); + } + }); + + // Remove all activities associated with a card if we remove the card + Cards.after.remove(function(userId, doc) { + Activities.remove({ + cardId: doc._id + }); + }); + + CardComments.after.insert(function(userId, doc) { + Activities.insert({ + type: 'comment', + activityType: 'addComment', + boardId: doc.boardId, + cardId: doc.cardId, + commentId: doc._id, + userId: userId + }); + }); + + CardComments.after.remove(function(userId, doc) { + var activity = Activities.findOne({ commentId: doc._id }); + if (activity) { + Activities.remove(activity._id); + } + }); +} diff --git a/collections/lists.js b/collections/lists.js new file mode 100644 index 00000000..196477ec --- /dev/null +++ b/collections/lists.js @@ -0,0 +1,94 @@ +Lists = new Mongo.Collection('lists'); + +Lists.attachSchema(new SimpleSchema({ + title: { + type: String + }, + archived: { + type: Boolean + }, + boardId: { + type: String + }, + createdAt: { + type: Date, + denyUpdate: true + }, + sort: { + type: Number, + decimal: true, + // XXX We should probably provide a default + optional: true + }, + updatedAt: { + type: Date, + denyInsert: true, + optional: true + } +})); + +if (Meteor.isServer) { + Lists.allow({ + insert: function(userId, doc) { + return allowIsBoardMember(userId, Boards.findOne(doc.boardId)); + }, + update: function(userId, doc) { + return allowIsBoardMember(userId, Boards.findOne(doc.boardId)); + }, + remove: function(userId, doc) { + return allowIsBoardMember(userId, Boards.findOne(doc.boardId)); + }, + fetch: ['boardId'] + }); +} + +Lists.helpers({ + cards: function() { + return Cards.find(_.extend(Filter.getMongoSelector(), { + listId: this._id, + archived: false + }), { sort: ['sort'] }); + }, + board: function() { + return Boards.findOne(this.boardId); + } +}); + +// HOOKS +Lists.hookOptions.after.update = { fetchPrevious: false }; + +Lists.before.insert(function(userId, doc) { + doc.createdAt = new Date(); + doc.archived = false; + if (! doc.userId) + doc.userId = userId; +}); + +Lists.before.update(function(userId, doc, fieldNames, modifier) { + modifier.$set = modifier.$set || {}; + modifier.$set.modifiedAt = new Date(); +}); + +if (Meteor.isServer) { + Lists.after.insert(function(userId, doc) { + Activities.insert({ + type: 'list', + activityType: 'createList', + boardId: doc.boardId, + listId: doc._id, + userId: userId + }); + }); + + Lists.after.update(function(userId, doc) { + if (doc.archived) { + Activities.insert({ + type: 'list', + activityType: 'archivedList', + listId: doc._id, + boardId: doc.boardId, + userId: userId + }); + } + }); +} diff --git a/collections/users.js b/collections/users.js new file mode 100644 index 00000000..1dcccf12 --- /dev/null +++ b/collections/users.js @@ -0,0 +1,106 @@ +Users = Meteor.users; + +// Search a user in the complete server database by its name or username. This +// is used for instance to add a new user to a board. +var searchInFields = ['username', 'profile.name']; +Users.initEasySearch(searchInFields, { + use: 'mongo-db', + returnFields: searchInFields +}); + +Users.helpers({ + boards: function() { + return Boards.find({ userId: this._id }); + }, + starredBoards: function() { + var starredBoardIds = this.profile.starredBoards || []; + return Boards.find({_id: {$in: starredBoardIds}}); + }, + hasStarred: function(boardId) { + var starredBoardIds = this.profile.starredBoards || []; + return _.contains(starredBoardIds, boardId); + }, + isBoardMember: function() { + var board = Boards.findOne(Session.get('currentBoard')); + return board && _.contains(_.pluck(board.members, 'userId'), this._id) && + _.where(board.members, {userId: this._id})[0].isActive; + }, + isBoardAdmin: function() { + var board = Boards.findOne(Session.get('currentBoard')); + if (this.isBoardMember(board)) + return _.where(board.members, {userId: this._id})[0].isAdmin; + } +}); + +Users.before.insert(function(userId, doc) { + doc.profile = {}; + + // connect profile.status default + doc.profile.status = 'offline'; + + // slugify to username + //doc.username = getSlug(doc.profile.name, ''); +}); + +if (Meteor.isServer) { + // Each board document contains the de-normalized number of users that have + // starred it. If the user star or unstar a board, we need to update this + // counter. + // We need to run this code on the server only, otherwise the incrementation + // will be done twice. + Users.after.update(function(userId, user, fieldNames) { + // The `starredBoards` list is hosted on the `profile` field. If this + // field hasn't been modificated we don't need to run this hook. + if (! _.contains(fieldNames, 'profile')) + return; + + // To calculate a diff of board starred ids, we get both the previous + // and the newly board ids list + var getStarredBoardsIds = function(doc) { + return doc.profile && doc.profile.starredBoards; + }; + var oldIds = getStarredBoardsIds(this.previous); + var newIds = getStarredBoardsIds(user); + + // The _.difference(a, b) method returns the values from a that are not in + // b. We use it to find deleted and newly inserted ids by using it in one + // direction and then in the other. + var incrementBoards = function(boardsIds, inc) { + _.forEach(boardsIds, function(boardId) { + Boards.update(boardId, {$inc: {stars: inc}}); + }); + }; + incrementBoards(_.difference(oldIds, newIds), -1); + incrementBoards(_.difference(newIds, oldIds), +1); + }); + + // XXX i18n + Users.after.insert(function(userId, doc) { + var ExampleBoard = { + title: 'Welcome Board', + userId: doc._id, + permission: 'private' + }; + + // Insert the Welcome Board + Boards.insert(ExampleBoard, function(err, boardId) { + + _.forEach(['Basics', 'Advanced'], function(title) { + var list = { + title: title, + boardId: boardId, + userId: ExampleBoard.userId, + + // XXX Not certain this is a bug, but we except these fields get + // inserted by the Lists.before.insert collection-hook. Since this + // hook is not called in this case, we have to dublicate the logic and + // set them here. + archived: false, + createdAt: new Date() + }; + + Lists.insert(list); + }); + }); + }); +} |