From 87ce042c63b403780bcd5c5a50967925d433a1a1 Mon Sep 17 00:00:00 2001 From: Drew Fisher Date: Mon, 11 Jul 2016 15:20:10 -0700 Subject: [PATCH 1/8] Add SANDSTORM=1 to the environment under Sandstorm We add the SANDSTORM=1 environment variable to sandstorm-pkgdef.capnp so that accounts-sandstorm will ensure that it's the only loaded accounts package when running under Sandstorm. --- sandstorm-pkgdef.capnp | 1 + 1 file changed, 1 insertion(+) diff --git a/sandstorm-pkgdef.capnp b/sandstorm-pkgdef.capnp index d9e7da3d7..17b27a8a9 100644 --- a/sandstorm-pkgdef.capnp +++ b/sandstorm-pkgdef.capnp @@ -184,6 +184,7 @@ const myCommand :Spk.Manifest.Command = ( environ = [ # Note that this defines the *entire* environment seen by your app. (key = "PATH", value = "/usr/local/bin:/usr/bin:/bin"), + (key = "SANDSTORM", value = "1"), (key = "METEOR_SETTINGS", value = "{\"public\": {\"sandstorm\": true}}") ] ); From 0f62fe0c6e5a151be0aa7baf60405ef2e878a0d8 Mon Sep 17 00:00:00 2001 From: Drew Fisher Date: Mon, 11 Jul 2016 15:21:19 -0700 Subject: [PATCH 2/8] Fix login on Sandstorm by not creating welcome boards This one is a pretty strange chain of events: * fetching /.sandstorm-login via accounts-sandstorm's rendezvous protocol causes a user to be created in the users collection * models/users.js has hooks to create a board and lists when a user is created * models/activities.js has a hook to create activity entries when a list is created * this hook does not handle not having no boardId, which results in attempting to run the hook with boardId: 'false'. 'false' does not have a title attribute, which causes the whole method call to throw an exception. * This makes the initial login fail. While there may be other bugs, the simple fix is to not create the board and lists when running under Sandstorm, where you only have one board anyway. --- models/users.js | 36 +++++++++++++++++++++--------------- 1 file changed, 21 insertions(+), 15 deletions(-) diff --git a/models/users.js b/models/users.js index 790ee0a1f..bdc5ddfe8 100644 --- a/models/users.js +++ b/models/users.js @@ -1,3 +1,7 @@ +// Sandstorm context is detected using the METEOR_SETTINGS environment variable +// in the package definition. +const isSandstorm = Meteor.settings && Meteor.settings.public && + Meteor.settings.public.sandstorm; Users = Meteor.users; Users.attachSchema(new SimpleSchema({ @@ -394,24 +398,26 @@ if (Meteor.isServer) { return fakeUserId.get() || getUserId(); }; - Users.after.insert((userId, doc) => { - const fakeUser = { - extendAutoValueContext: { - userId: doc._id, - }, - }; + if (!isSandstorm) { + Users.after.insert((userId, doc) => { + const fakeUser = { + extendAutoValueContext: { + userId: doc._id, + }, + }; - fakeUserId.withValue(doc._id, () => { - // Insert the Welcome Board - Boards.insert({ - title: TAPi18n.__('welcome-board'), - permission: 'private', - }, fakeUser, (err, boardId) => { + fakeUserId.withValue(doc._id, () => { + // Insert the Welcome Board + Boards.insert({ + title: TAPi18n.__('welcome-board'), + permission: 'private', + }, fakeUser, (err, boardId) => { - ['welcome-list1', 'welcome-list2'].forEach((title) => { - Lists.insert({ title: TAPi18n.__(title), boardId }, fakeUser); + ['welcome-list1', 'welcome-list2'].forEach((title) => { + Lists.insert({ title: TAPi18n.__(title), boardId }, fakeUser); + }); }); }); }); - }); + } } From 9f4300d38b248a147ac0fdf39bb607ffed5bae7e Mon Sep 17 00:00:00 2001 From: David Renshaw Date: Wed, 21 Sep 2016 11:16:07 -0400 Subject: [PATCH 3/8] Fix updating of permissions and profile. --- sandstorm.js | 46 +++++++++++++++++++++++----------------------- 1 file changed, 23 insertions(+), 23 deletions(-) diff --git a/sandstorm.js b/sandstorm.js index e7a67f767..d1b7b300e 100644 --- a/sandstorm.js +++ b/sandstorm.js @@ -58,29 +58,6 @@ if (isSandstorm && Meteor.isServer) { Location: base + boardPath, }); res.end(); - - // `accounts-sandstorm` populate the Users collection when new users - // accesses the document, but in case a already known user comes back, we - // need to update his associated document to match the request HTTP headers - // informations. - // XXX We need to update this document even if the initial route is not `/`. - // Unfortuanlty I wasn't able to make the Webapp.rawConnectHandlers solution - // work. - const user = Users.findOne({ - 'services.sandstorm.id': req.headers['x-sandstorm-user-id'], - }); - if (user) { - // XXX At this point the user.services.sandstorm credentials haven't been - // updated, which mean that the user will have to restart the application - // a second time to see its updated name and avatar. - Users.update(user._id, { - $set: { - 'profile.fullname': user.services.sandstorm.name, - 'profile.avatarUrl': user.services.sandstorm.picture, - }, - }); - updateUserPermissions(user._id, user.services.sandstorm.permissions); - } }); // On the first launch of the instance a user is automatically created thanks @@ -126,6 +103,29 @@ if (isSandstorm && Meteor.isServer) { updateUserPermissions(doc._id, doc.services.sandstorm.permissions); }); + Meteor.startup(() => { + Users.find().observeChanges({ + changed(userId, fields) { + const sandstormData = (fields.services || {}).sandstorm || {}; + if (sandstormData.name) { + Users.update(userId, { + $set: { 'profile.fullname': sandstormData.name }, + }); + } + + if (sandstormData.picture) { + Users.update(userId, { + $set: { 'profile.avatarUrl': sandstormData.picture }, + }); + } + + if (sandstormData.permissions) { + updateUserPermissions(userId, sandstormData.permissions); + } + }, + }); + }); + // Wekan v0.8 didn’t implement the Sandstorm sharing model and instead kept // the visibility setting (“public” or “private”) in the UI as does the main // Meteor application. We need to enforce “public” visibility as the sharing From 1ad44de8c6774a7caee43eeecdafe1893234e890 Mon Sep 17 00:00:00 2001 From: David Renshaw Date: Mon, 26 Sep 2016 11:40:43 -0400 Subject: [PATCH 4/8] powerbox identity requests --- client/components/sidebar/sidebar.jade | 10 +- client/components/sidebar/sidebar.js | 3 + sandstorm.js | 144 +++++++++++++++++++++++++ 3 files changed, 153 insertions(+), 4 deletions(-) diff --git a/client/components/sidebar/sidebar.jade b/client/components/sidebar/sidebar.jade index 4f5586cbd..51600acfd 100644 --- a/client/components/sidebar/sidebar.jade +++ b/client/components/sidebar/sidebar.jade @@ -30,10 +30,12 @@ template(name="membersWidget") .board-widget-content each currentBoard.activeMembers +userAvatar(userId=this.userId showStatus=true) - unless isSandstorm - if currentUser.isBoardAdmin - a.member.add-member.js-manage-board-members - i.fa.fa-plus + if isSandstorm + a.member.add-member.sandstorm-powerbox-request-identity + i.fa.fa-plus + else if currentUser.isBoardAdmin + a.member.add-member.js-manage-board-members + i.fa.fa-plus .clearfix if isInvited hr diff --git a/client/components/sidebar/sidebar.js b/client/components/sidebar/sidebar.js index 0af32f8fe..f32a27c54 100644 --- a/client/components/sidebar/sidebar.js +++ b/client/components/sidebar/sidebar.js @@ -163,6 +163,9 @@ Template.membersWidget.helpers({ Template.membersWidget.events({ 'click .js-member': Popup.open('member'), 'click .js-manage-board-members': Popup.open('addMember'), + 'click .sandstorm-powerbox-request-identity'() { + window.sandstormRequestIdentity(); + }, 'click .js-member-invite-accept'() { const boardId = Session.get('currentBoard'); Meteor.user().removeInvite(boardId); diff --git a/sandstorm.js b/sandstorm.js index d1b7b300e..40a80dc65 100644 --- a/sandstorm.js +++ b/sandstorm.js @@ -21,6 +21,79 @@ const sandstormBoard = { }; if (isSandstorm && Meteor.isServer) { + const Capnp = require('capnp'); + const Powerbox = Capnp.importSystem('sandstorm/powerbox.capnp'); + const Identity = Capnp.importSystem('sandstorm/identity.capnp'); + const SandstormHttpBridge = + Capnp.importSystem('sandstorm/sandstorm-http-bridge.capnp').SandstormHttpBridge; + + let httpBridge = null; + + function getHttpBridge() { + if (!httpBridge) { + const capnpConnection = Capnp.connect('unix:/tmp/sandstorm-api'); + httpBridge = capnpConnection.restore(null, SandstormHttpBridge); + } + return httpBridge; + } + + Meteor.methods({ + sandstormClaimIdentityRequest(token, descriptor) { + check(token, String); + check(descriptor, String); + + const parsedDescriptor = Capnp.parse( + Powerbox.PowerboxDescriptor, + new Buffer(descriptor, 'base64'), + { packed: true }); + + const tag = Capnp.parse(Identity.Identity.PowerboxTag, parsedDescriptor.tags[0].value); + const permissions = []; + if (tag.permissions[1]) { + permissions.push('configure'); + } + + if (tag.permissions[0]) { + permissions.push('participate'); + } + + const sessionId = this.connection.sandstormSessionId(); + const httpBridge = getHttpBridge(); + const session = httpBridge.getSessionContext(sessionId).context; + const api = httpBridge.getSandstormApi(sessionId).api; + + Meteor.wrapAsync((done) => { + session.claimRequest(token).then((response) => { + const identity = response.cap.castAs(Identity.Identity); + const promises = [api.getIdentityId(identity), identity.getProfile()]; + return Promise.all(promises).then((responses) => { + const identityId = responses[0].id.toString('hex').slice(0, 32); + const profile = responses[1].profile; + return profile.picture.getUrl().then((response) => { + const sandstormInfo = { + id: identityId, + name: profile.displayName.defaultText, + permissions, + picture: `${response.protocol}://${response.hostPath}`, + preferredHandle: profile.preferredHandle, + pronouns: profile.pronouns, + }; + + const login = Accounts.updateOrCreateUserFromExternalService( + 'sandstorm', sandstormInfo, + { profile: { name: sandstormInfo.name, fullname: sandstormInfo.name } }); + + updateUserPermissions(login.userId, permissions); + done(); + }); + }); + }).catch((e) => { + done(e, null); + }); + })(); + }, + }); + function updateUserPermissions(userId, permissions) { const isActive = permissions.indexOf('participate') > -1; const isAdmin = permissions.indexOf('configure') > -1; @@ -137,6 +210,77 @@ if (isSandstorm && Meteor.isServer) { } if (isSandstorm && Meteor.isClient) { + let rpcCounter = 0; + const rpcs = {}; + + window.addEventListener('message', (event) => { + if (event.source === window) { + // Meteor likes to postmessage itself. + return; + } + + if ((event.source !== window.parent) || + typeof event.data !== 'object' || + typeof event.data.rpcId !== 'number') { + throw new Error(`got unexpected postMessage: ${event}`); + } + + const handler = rpcs[event.data.rpcId]; + if (!handler) { + throw new Error(`no such rpc ID for event ${event}`); + } + + delete rpcs[event.data.rpcId]; + handler(event.data); + }); + + function sendRpc(name, message) { + const id = rpcCounter++; + message.rpcId = id; + const obj = {}; + obj[name] = message; + window.parent.postMessage(obj, '*'); + return new Promise((resolve, reject) => { + rpcs[id] = (response) => { + if (response.error) { + reject(new Error(response.error)); + } else { + resolve(response); + } + }; + }); + } + + const powerboxDescriptors = { + identity: 'EAhQAQEAABEBF1EEAQH_GN1RqXqYhMAAQAERAREBAQ', + // Generated using the following code: + // + // Capnp.serializePacked( + // Powerbox.PowerboxDescriptor, + // { tags: [ { + // id: "13872380404802116888", + // value: Capnp.serialize(Identity.PowerboxTag, { permissions: [true, false] }) + // }]}).toString('base64') + // .replace(/\//g, "_") + // .replace(/\+/g, "-"); + }; + + function doRequest(serializedPowerboxDescriptor, onSuccess) { + return sendRpc('powerboxRequest', { + query: [serializedPowerboxDescriptor], + }).then((response) => { + if (!response.canceled) { + onSuccess(response); + } + }); + } + + window.sandstormRequestIdentity = function () { + doRequest(powerboxDescriptors.identity, (response) => { + Meteor.call('sandstormClaimIdentityRequest', response.token, response.descriptor); + }); + }; + // Since the Sandstorm grain is displayed in an iframe of the Sandstorm shell, // we need to explicitly expose meta data like the page title or the URL path // so that they could appear in the browser window. From 95680ef43abafd8d6cce9d879519bc8ba5091c8a Mon Sep 17 00:00:00 2001 From: David Renshaw Date: Tue, 27 Sep 2016 10:49:02 -0400 Subject: [PATCH 5/8] only show 'add member' button to users who can edit the board --- client/components/sidebar/sidebar.jade | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/client/components/sidebar/sidebar.jade b/client/components/sidebar/sidebar.jade index 51600acfd..f3fdd1bce 100644 --- a/client/components/sidebar/sidebar.jade +++ b/client/components/sidebar/sidebar.jade @@ -31,8 +31,9 @@ template(name="membersWidget") each currentBoard.activeMembers +userAvatar(userId=this.userId showStatus=true) if isSandstorm - a.member.add-member.sandstorm-powerbox-request-identity - i.fa.fa-plus + if currentUser.isBoardMember + a.member.add-member.sandstorm-powerbox-request-identity + i.fa.fa-plus else if currentUser.isBoardAdmin a.member.add-member.js-manage-board-members i.fa.fa-plus From 4d6ab3094c842f42ef12b8eb1595e0f947b3f5a8 Mon Sep 17 00:00:00 2001 From: David Renshaw Date: Wed, 28 Sep 2016 13:40:54 -0400 Subject: [PATCH 6/8] Prevent the capnproto connection from getting garbage collected. --- sandstorm.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/sandstorm.js b/sandstorm.js index 40a80dc65..b511155c5 100644 --- a/sandstorm.js +++ b/sandstorm.js @@ -28,10 +28,11 @@ if (isSandstorm && Meteor.isServer) { Capnp.importSystem('sandstorm/sandstorm-http-bridge.capnp').SandstormHttpBridge; let httpBridge = null; + let capnpConnection = null; function getHttpBridge() { if (!httpBridge) { - const capnpConnection = Capnp.connect('unix:/tmp/sandstorm-api'); + capnpConnection = Capnp.connect('unix:/tmp/sandstorm-api'); httpBridge = capnpConnection.restore(null, SandstormHttpBridge); } return httpBridge; From 349cdd4bf9b08f07a00d0576c78a3e71a18c1eb3 Mon Sep 17 00:00:00 2001 From: David Renshaw Date: Thu, 6 Oct 2016 13:55:58 -0400 Subject: [PATCH 7/8] Integration with Sandstorm events/notifications. --- sandstorm-pkgdef.capnp | 44 +++++++++++++++++- sandstorm.js | 103 ++++++++++++++++++++++++++++++++++++++++- 2 files changed, 144 insertions(+), 3 deletions(-) diff --git a/sandstorm-pkgdef.capnp b/sandstorm-pkgdef.capnp index 17b27a8a9..2db5b6b6f 100644 --- a/sandstorm-pkgdef.capnp +++ b/sandstorm-pkgdef.capnp @@ -173,8 +173,48 @@ const pkgdef :Spk.PackageDefinition = ( # # XXX Administrators configuration options aren’t implemented yet, so this # role is currently useless. - )] - ) + )], + + eventTypes = [( + name = "addBoardMember", + verbPhrase = (defaultText = "added to board"), + ), ( + name = "createList", + verbPhrase = (defaultText = "created new list"), + ), ( + name = "archivedList", + verbPhrase = (defaultText = "archived list"), + ), ( + name = "restoredList", + verbPhrase = (defaultText = "restored list"), + ), ( + name = "createCard", + verbPhrase = (defaultText = "created new card"), + ), ( + name = "moveCard", + verbPhrase = (defaultText = "moved card"), + ), ( + name = "archivedCard", + verbPhrase = (defaultText = "archived card"), + ), ( + name = "restoredCard", + verbPhrase = (defaultText = "restored card"), + ), ( + name = "addComment", + verbPhrase = (defaultText = "added comment"), + ), ( + name = "addAttachement", + verbPhrase = (defaultText = "added attachment"), + ), ( + name = "joinMember", + verbPhrase = (defaultText = "added to card"), + ), ( + name = "unjoinMember", + verbPhrase = (defaultText = "removed from card"), + ), ], + ), + + saveIdentityCaps = true, ), ); diff --git a/sandstorm.js b/sandstorm.js index b511155c5..dc4a0796b 100644 --- a/sandstorm.js +++ b/sandstorm.js @@ -21,7 +21,9 @@ const sandstormBoard = { }; if (isSandstorm && Meteor.isServer) { + const fs = require('fs'); const Capnp = require('capnp'); + const Package = Capnp.importSystem('sandstorm/package.capnp'); const Powerbox = Capnp.importSystem('sandstorm/powerbox.capnp'); const Identity = Capnp.importSystem('sandstorm/identity.capnp'); const SandstormHttpBridge = @@ -30,6 +32,10 @@ if (isSandstorm && Meteor.isServer) { let httpBridge = null; let capnpConnection = null; + const bridgeConfig = Capnp.parse( + Package.BridgeConfig, + fs.readFileSync('/sandstorm-http-bridge-config')); + function getHttpBridge() { if (!httpBridge) { capnpConnection = Capnp.connect('unix:/tmp/sandstorm-api'); @@ -66,7 +72,8 @@ if (isSandstorm && Meteor.isServer) { Meteor.wrapAsync((done) => { session.claimRequest(token).then((response) => { const identity = response.cap.castAs(Identity.Identity); - const promises = [api.getIdentityId(identity), identity.getProfile()]; + const promises = [api.getIdentityId(identity), identity.getProfile(), + httpBridge.saveIdentity(identity)]; return Promise.all(promises).then((responses) => { const identityId = responses[0].id.toString('hex').slice(0, 32); const profile = responses[1].profile; @@ -95,6 +102,100 @@ if (isSandstorm && Meteor.isServer) { }, }); + function reportActivity(sessionId, path, type, users, caption) { + const httpBridge = getHttpBridge(); + const session = httpBridge.getSessionContext(sessionId).context; + Meteor.wrapAsync((done) => { + return Promise.all(users.map((user) => { + return httpBridge.getSavedIdentity(user.id).then((response) => { + return { identity: response.identity, + mentioned: !!user.mentioned, + subscribed: !!user.subscribed, + }; + }).catch(() => { + // Ignore identities that fail to restore. Probably they have lost access to the board. + }); + })).then((maybeUsers) => { + const users = maybeUsers.filter((u) => !!u); + const event = { path, type, users }; + if (caption) { + event.notification = { caption }; + } + + return session.activity(event); + }).then(() => done(), + (e) => done(e)); + })(); + } + + Meteor.startup(() => { + Activities.after.insert((userId, doc) => { + // HACK: We need the connection that's making the request in order to read the + // Sandstorm session ID. + const invocation = DDP._CurrentInvocation.get(); // eslint-disable-line no-undef + if (invocation) { + const sessionId = invocation.connection.sandstormSessionId(); + + const eventTypes = bridgeConfig.viewInfo.eventTypes; + + const defIdx = eventTypes.findIndex((def) => def.name === doc.activityType ); + if (defIdx >= 0) { + const users = {}; + function ensureUserListed(userId) { + if (!users[userId]) { + const user = Meteor.users.findOne(userId); + if (user) { + users[userId] = { id: user.services.sandstorm.id }; + } else { + return false; + } + } + return true; + } + + function mentionedUser(userId) { + if (ensureUserListed(userId)) { + users[userId].mentioned = true; + } + } + + function subscribedUser(userId) { + if (ensureUserListed(userId)) { + users[userId].subscribed = true; + } + } + + let path = ''; + let caption = null; + + if (doc.cardId) { + path = `b/sandstorm/libreboard/${doc.cardId}`; + Cards.findOne(doc.cardId).members.map(subscribedUser); + } + + if (doc.memberId) { + mentionedUser(doc.memberId); + } + + if (doc.activityType === 'addComment') { + const comment = CardComments.findOne(doc.commentId); + caption = { defaultText: comment.text }; + const activeMembers = + _.pluck(Boards.findOne(sandstormBoard._id).activeMembers(), 'userId'); + (comment.text.match(/\B@(\w*)/g) || []).forEach((username) => { + const user = Meteor.users.findOne({ username: username.slice(1)}); + if (user && activeMembers.indexOf(user._id) !== -1) { + mentionedUser(user._id); + } + }); + } + + reportActivity(sessionId, path, defIdx, _.values(users), caption); + } + } + }); + }); + function updateUserPermissions(userId, permissions) { const isActive = permissions.indexOf('participate') > -1; const isAdmin = permissions.indexOf('configure') > -1; From 43c180c247a82ed20b2bc9e2de493305f7f5a43b Mon Sep 17 00:00:00 2001 From: David Renshaw Date: Sun, 9 Oct 2016 18:55:51 -0400 Subject: [PATCH 8/8] Make sure identities resolve before sending them to activity(). --- sandstorm.js | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/sandstorm.js b/sandstorm.js index dc4a0796b..5a800b242 100644 --- a/sandstorm.js +++ b/sandstorm.js @@ -108,12 +108,17 @@ if (isSandstorm && Meteor.isServer) { Meteor.wrapAsync((done) => { return Promise.all(users.map((user) => { return httpBridge.getSavedIdentity(user.id).then((response) => { - return { identity: response.identity, - mentioned: !!user.mentioned, - subscribed: !!user.subscribed, - }; - }).catch(() => { - // Ignore identities that fail to restore. Probably they have lost access to the board. + // Call getProfile() to make sure that the identity successfully resolves. + // (In C++ we would instead call whenResolved() here.) + const identity = response.identity; + return identity.getProfile().then(() => { + return { identity, + mentioned: !!user.mentioned, + subscribed: !!user.subscribed, + }; + }).catch(() => { + // Ignore identities that fail to resolve. Probably they have lost access to the board. + }); }); })).then((maybeUsers) => { const users = maybeUsers.filter((u) => !!u);