diff options
-rw-r--r-- | .meteor/packages | 1 | ||||
-rw-r--r-- | .meteor/versions | 1 | ||||
-rw-r--r-- | client/components/boards/boardHeader.jade | 1 | ||||
-rw-r--r-- | client/components/boards/boardHeader.js | 12 | ||||
-rw-r--r-- | i18n/en.i18n.json | 1 | ||||
-rw-r--r-- | models/boards.js | 27 | ||||
-rw-r--r-- | models/export.js | 102 | ||||
-rw-r--r-- | server/lib/utils.js | 21 |
8 files changed, 166 insertions, 0 deletions
diff --git a/.meteor/packages b/.meteor/packages index 93965383..ad6ddf0b 100644 --- a/.meteor/packages +++ b/.meteor/packages @@ -73,3 +73,4 @@ perak:markdown seriousm:emoji-continued templates:tabs verron:autosize +simple:json-routes diff --git a/.meteor/versions b/.meteor/versions index cf64a5d2..65c43d86 100644 --- a/.meteor/versions +++ b/.meteor/versions @@ -125,6 +125,7 @@ seriousm:emoji-continued@1.4.0 service-configuration@1.0.5 session@1.1.1 sha@1.0.4 +simple:json-routes@1.0.4 softwarerero:accounts-t9n@1.1.7 spacebars@1.0.7 spacebars-compiler@1.0.7 diff --git a/client/components/boards/boardHeader.jade b/client/components/boards/boardHeader.jade index 103b1c37..fe6b56e6 100644 --- a/client/components/boards/boardHeader.jade +++ b/client/components/boards/boardHeader.jade @@ -72,6 +72,7 @@ template(name="boardMenuPopup") if currentUser.isBoardAdmin hr ul.pop-over-list + li: a(href="{{exportUrl}}", download="{{exportFilename}}") {{_ 'export-board'}} li: a.js-archive-board {{_ 'archive-board'}} template(name="boardVisibilityList") diff --git a/client/components/boards/boardHeader.js b/client/components/boards/boardHeader.js index 3b05b4ef..f8ccfba5 100644 --- a/client/components/boards/boardHeader.js +++ b/client/components/boards/boardHeader.js @@ -15,6 +15,18 @@ Template.boardMenuPopup.events({ }), }); +Template.boardMenuPopup.helpers({ + exportUrl() { + const boardId = Session.get('currentBoard'); + const loginToken = Accounts._storedLoginToken(); + return Meteor.absoluteUrl(`api/boards/${boardId}?authToken=${loginToken}`); + }, + exportFilename() { + const boardId = Session.get('currentBoard'); + return `wekan-export-board-${boardId}.json`; + }, +}); + Template.boardChangeTitlePopup.events({ submit(evt, tpl) { const newTitle = tpl.$('.js-board-name').val().trim(); diff --git a/i18n/en.i18n.json b/i18n/en.i18n.json index 74c27843..6cc43f03 100644 --- a/i18n/en.i18n.json +++ b/i18n/en.i18n.json @@ -147,6 +147,7 @@ "error-user-doesNotExist": "This user does not exist", "error-user-notAllowSelf": "This action on self is not allowed", "error-user-notCreated": "This user is not created", + "export-board": "Export board", "filter": "Filter", "filter-cards": "Filter Cards", "filter-clear": "Clear filter", diff --git a/models/boards.js b/models/boards.js index 9c792674..e20ca8ce 100644 --- a/models/boards.js +++ b/models/boards.js @@ -79,6 +79,33 @@ Boards.attachSchema(new SimpleSchema({ Boards.helpers({ + /** + * Is supplied user authorized to view this board? + */ + isVisibleBy(user) { + if(this.isPublic()) { + // public boards are visible to everyone + return true; + } else { + // otherwise you have to be logged-in and active member + return user && this.isActiveMember(user._id); + } + }, + + /** + * Is the user one of the active members of the board? + * + * @param userId + * @returns {boolean} the member that matches, or undefined/false + */ + isActiveMember(userId) { + if(userId) { + return this.members.find((member) => (member.userId === userId && member.isActive)); + } else { + return false; + } + }, + isPublic() { return this.permission === 'public'; }, diff --git a/models/export.js b/models/export.js new file mode 100644 index 00000000..3d8ee99e --- /dev/null +++ b/models/export.js @@ -0,0 +1,102 @@ +/* global JsonRoutes */ +if(Meteor.isServer) { + // todo XXX once we have a real API in place, move that route there + // todo XXX also share the route definition between the client and the server + // so that we could use something like ApiRoutes.path('boards/export', boardId) + // on the client instead of copy/pasting the route path manually between the client and the server. + /* + * This route is used to export the board FROM THE APPLICATION. + * If user is already logged-in, pass loginToken as param "authToken": + * '/api/boards/:boardId?authToken=:token' + * + * See https://blog.kayla.com.au/server-side-route-authentication-in-meteor/ + * for detailed explanations + */ + JsonRoutes.add('get', '/api/boards/:boardId', function (req, res) { + const boardId = req.params.boardId; + let user = null; + // todo XXX for real API, first look for token in Authentication: header + // then fallback to parameter + const loginToken = req.query.authToken; + if (loginToken) { + const hashToken = Accounts._hashLoginToken(loginToken); + user = Meteor.users.findOne({ + 'services.resume.loginTokens.hashedToken': hashToken, + }); + } + + const exporter = new Exporter(boardId); + if(exporter.canExport(user)) { + JsonRoutes.sendResult(res, 200, exporter.build()); + } else { + // we could send an explicit error message, but on the other hand the only way to + // get there is by hacking the UI so let's keep it raw. + JsonRoutes.sendResult(res, 403); + } + }); +} + +class Exporter { + constructor(boardId) { + this._boardId = boardId; + } + + build() { + const byBoard = {boardId: this._boardId}; + // we do not want to retrieve boardId in related elements + const noBoardId = {fields: {boardId: 0}}; + const result = { + _format: 'wekan-board-1.0.0', + }; + _.extend(result, Boards.findOne(this._boardId, {fields: {stars: 0}})); + result.lists = Lists.find(byBoard, noBoardId).fetch(); + result.cards = Cards.find(byBoard, noBoardId).fetch(); + result.comments = CardComments.find(byBoard, noBoardId).fetch(); + result.activities = Activities.find(byBoard, noBoardId).fetch(); + // for attachments we only export IDs and absolute url to original doc + result.attachments = Attachments.find(byBoard).fetch().map((attachment) => { return { + _id: attachment._id, + cardId: attachment.cardId, + url: Meteor.absoluteUrl(Utils.stripLeadingSlash(attachment.url())), + };}); + + // we also have to export some user data - as the other elements only include id + // but we have to be careful: + // 1- only exports users that are linked somehow to that board + // 2- do not export any sensitive information + const users = {}; + result.members.forEach((member) => {users[member.userId] = true;}); + result.lists.forEach((list) => {users[list.userId] = true;}); + result.cards.forEach((card) => { + users[card.userId] = true; + if (card.members) { + card.members.forEach((memberId) => {users[memberId] = true;}); + } + }); + result.comments.forEach((comment) => {users[comment.userId] = true;}); + result.activities.forEach((activity) => {users[activity.userId] = true;}); + const byUserIds = {_id: {$in: Object.getOwnPropertyNames(users)}}; + // we use whitelist to be sure we do not expose inadvertently + // some secret fields that gets added to User later. + const userFields = {fields: { + _id: 1, + username: 1, + 'profile.fullname': 1, + 'profile.initials': 1, + 'profile.avatarUrl': 1, + }}; + result.users = Users.find(byUserIds, userFields).fetch().map((user) => { + // user avatar is stored as a relative url, we export absolute + if(user.profile.avatarUrl) { + user.profile.avatarUrl = Meteor.absoluteUrl(Utils.stripLeadingSlash(user.profile.avatarUrl)); + } + return user; + }); + return result; + } + + canExport(user) { + const board = Boards.findOne(this._boardId); + return board && board.isVisibleBy(user); + } +} diff --git a/server/lib/utils.js b/server/lib/utils.js index b59671fb..a6a84f90 100644 --- a/server/lib/utils.js +++ b/server/lib/utils.js @@ -5,3 +5,24 @@ allowIsBoardAdmin = function(userId, board) { allowIsBoardMember = function(userId, board) { return board && board.hasMember(userId); }; + +// todo XXX not really server-specific, +// so move it to a common (client+server) lib? +Utils = { + /** + * If text starts with a / will remove it. + * @param text + */ + stripLeadingSlash(text) { + // we need an actual text string + if (!text) { + return text; + } + // if starting with slash + if (text[0] === '/') { + return text.slice(1); + } + // otherwise leave untouched + return text; + }, +}; |