Browse Source

Merge pull request #1 from dwrensha/wefork-sandstorm-update

Wefork sandstorm update
Lauri Ojansivu 8 years ago
parent
commit
4d41e70e12
5 changed files with 349 additions and 45 deletions
  1. 6 3
      client/components/sidebar/sidebar.jade
  2. 3 0
      client/components/sidebar/sidebar.js
  3. 23 17
      models/users.js
  4. 43 2
      sandstorm-pkgdef.capnp
  5. 274 23
      sandstorm.js

+ 6 - 3
client/components/sidebar/sidebar.jade

@@ -30,10 +30,13 @@ 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
+      if isSandstorm
+        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
       .clearfix
   if isInvited
     hr

+ 3 - 0
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);

+ 23 - 17
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,
-      },
-    };
-
-    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);
+  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) => {
+
+          ['welcome-list1', 'welcome-list2'].forEach((title) => {
+            Lists.insert({ title: TAPi18n.__(title), boardId }, fakeUser);
+          });
         });
       });
     });
-  });
+  }
 }

+ 43 - 2
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,
   ),
 );
 
@@ -184,6 +224,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}}")
   ]
 );

+ 274 - 23
sandstorm.js

@@ -21,6 +21,186 @@ 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 =
+    Capnp.importSystem('sandstorm/sandstorm-http-bridge.capnp').SandstormHttpBridge;
+
+  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');
+      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(),
+                            httpBridge.saveIdentity(identity)];
+          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 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) => {
+          // 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);
+        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;
@@ -58,29 +238,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 +283,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
@@ -137,6 +317,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.