|
@@ -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.
|