Unfurling of Slack links (#487)

* First pass: Unfurling of Slack links

* Add authentication in db

* Call associate on Event correctly

* Add SLACK_APP_ID, remove SLACK_REDIRECT_URI, tidy env sample

* PR feedback

* Comment clarify
This commit is contained in:
Tom Moor
2017-12-18 19:59:29 -08:00
committed by GitHub
parent 938bb3fc31
commit 32ba98bb1a
19 changed files with 219 additions and 48 deletions

View File

@@ -2,7 +2,7 @@
import Router from 'koa-router';
import auth from './middlewares/authentication';
import { presentUser, presentTeam } from '../presenters';
import { User, Team } from '../models';
import { Authentication, User, Team } from '../models';
import * as Slack from '../slack';
const router = new Router();
@@ -81,11 +81,21 @@ router.post('auth.slack', async ctx => {
};
});
router.post('auth.slackCommands', async ctx => {
router.post('auth.slackCommands', auth(), async ctx => {
const { code } = ctx.body;
ctx.assertPresent(code, 'code is required');
await Slack.oauthAccess(code, `${process.env.URL || ''}/auth/slack/commands`);
const user = ctx.state.user;
const endpoint = `${process.env.URL || ''}/auth/slack/commands`;
const data = await Slack.oauthAccess(code, endpoint);
await Authentication.create({
serviceId: 'slack',
userId: user.id,
teamId: user.teamId,
token: data.access_token,
scopes: data.scope.split(','),
});
});
export default router;

View File

@@ -1,10 +1,48 @@
// @flow
import Router from 'koa-router';
import httpErrors from 'http-errors';
import { Document, User } from '../models';
import { Authentication, Document, User } from '../models';
import * as Slack from '../slack';
const router = new Router();
router.post('hooks.unfurl', async ctx => {
const { challenge, token, event } = ctx.body;
if (challenge) return (ctx.body = ctx.body.challenge);
if (token !== process.env.SLACK_VERIFICATION_TOKEN)
throw httpErrors.BadRequest('Invalid token');
// TODO: Everything from here onwards will get moved to an async job
const user = await User.find({ where: { slackId: event.user } });
if (!user) return;
const auth = await Authentication.find({
where: { serviceId: 'slack', teamId: user.teamId },
});
if (!auth) return;
// get content for unfurled links
let unfurls = {};
for (let link of event.links) {
const id = link.url.substr(link.url.lastIndexOf('/') + 1);
const doc = await Document.findById(id);
if (!doc || doc.teamId !== user.teamId) continue;
unfurls[link.url] = {
title: doc.title,
text: doc.getSummary(),
color: doc.collection.color,
};
}
await Slack.post('chat.unfurl', {
token: auth.token,
channel: event.channel,
ts: event.message_ts,
unfurls,
});
});
router.post('hooks.slack', async ctx => {
const { token, user_id, text } = ctx.body;
ctx.assertPresent(token, 'token is required');

View File

@@ -10,11 +10,13 @@ export default function apiWrapper() {
const ok = ctx.status < 400;
// $FlowFixMe
ctx.body = {
...ctx.body,
status: ctx.status,
ok,
};
if (typeof ctx.body !== 'string') {
// $FlowFixMe
ctx.body = {
...ctx.body,
status: ctx.status,
ok,
};
}
};
}

View File

@@ -0,0 +1,49 @@
module.exports = {
up: async (queryInterface, Sequelize) => {
await queryInterface.createTable('authentications', {
id: {
type: Sequelize.UUID,
allowNull: false,
primaryKey: true,
},
userId: {
type: Sequelize.UUID,
allowNull: true,
references: {
model: 'users',
},
},
teamId: {
type: Sequelize.UUID,
allowNull: true,
references: {
model: 'teams',
},
},
serviceId: {
type: Sequelize.STRING,
allowNull: false,
},
token: {
type: Sequelize.BLOB,
allowNull: true,
},
scopes: {
type: Sequelize.ARRAY(Sequelize.STRING),
allowNull: true,
},
createdAt: {
type: Sequelize.DATE,
allowNull: false,
},
updatedAt: {
type: Sequelize.DATE,
allowNull: false,
},
});
},
down: async (queryInterface, Sequelize) => {
await queryInterface.dropTable('authentications');
},
};

View File

@@ -0,0 +1,26 @@
// @flow
import { DataTypes, sequelize, encryptedFields } from '../sequelize';
const Authentication = sequelize.define('authentication', {
id: {
type: DataTypes.UUID,
defaultValue: DataTypes.UUIDV4,
primaryKey: true,
},
serviceId: DataTypes.STRING,
scopes: DataTypes.ARRAY(DataTypes.STRING),
token: encryptedFields.vault('token'),
});
Authentication.associate = models => {
Authentication.belongsTo(models.User, {
as: 'user',
foreignKey: 'userId',
});
Authentication.belongsTo(models.Team, {
as: 'team',
foreignKey: 'teamId',
});
};
export default Authentication;

View File

@@ -2,12 +2,15 @@
import slug from 'slug';
import _ from 'lodash';
import randomstring from 'randomstring';
import MarkdownSerializer from 'slate-md-serializer';
import Plain from 'slate-plain-serializer';
import isUUID from 'validator/lib/isUUID';
import { DataTypes, sequelize } from '../sequelize';
import parseTitle from '../../shared/utils/parseTitle';
import Revision from './Revision';
const Markdown = new MarkdownSerializer();
const URL_REGEX = /^[a-zA-Z0-9-]*-([a-zA-Z0-9]{10,15})$/;
// $FlowIssue invalid flow-typed
@@ -203,6 +206,13 @@ Document.searchForUser = async (
// Instance methods
Document.prototype.getSummary = function() {
const value = Markdown.deserialize(this.text);
const plain = Plain.serialize(value);
const lines = _.compact(plain.split('\n'));
return lines.length >= 1 ? lines[1] : '';
};
Document.prototype.getUrl = function() {
const slugifiedTitle = slugify(this.title);
return `/doc/${slugifiedTitle}-${this.urlId}`;

View File

@@ -9,30 +9,6 @@ const Event = sequelize.define('event', {
},
name: DataTypes.STRING,
data: DataTypes.JSONB,
userId: {
type: 'UUID',
allowNull: true,
references: {
model: 'users',
},
},
collectionId: {
type: 'UUID',
allowNull: true,
references: {
model: 'collections',
},
},
teamId: {
type: 'UUID',
allowNull: true,
references: {
model: 'teams',
},
},
});
Event.associate = models => {

View File

@@ -2,12 +2,11 @@
import crypto from 'crypto';
import bcrypt from 'bcrypt';
import uuid from 'uuid';
import JWT from 'jsonwebtoken';
import { DataTypes, sequelize, encryptedFields } from '../sequelize';
import { uploadToS3FromUrl } from '../utils/s3';
import mailer from '../mailer';
import JWT from 'jsonwebtoken';
const BCRYPT_COST = process.env.NODE_ENV !== 'production' ? 4 : 12;
const User = sequelize.define(

View File

@@ -1,4 +1,6 @@
// @flow
import Authentication from './Authentication';
import Event from './Event';
import User from './User';
import Team from './Team';
import Collection from './Collection';
@@ -9,6 +11,8 @@ import View from './View';
import Star from './Star';
const models = {
Authentication,
Event,
User,
Team,
Collection,
@@ -26,4 +30,15 @@ Object.keys(models).forEach(modelName => {
}
});
export { User, Team, Collection, Document, Revision, ApiKey, View, Star };
export {
Authentication,
Event,
User,
Team,
Collection,
Document,
Revision,
ApiKey,
View,
Star,
};

View File

@@ -0,0 +1,7 @@
// @flow
const Slack = {
id: 'slack',
name: 'Slack',
};
export default Slack;

View File

@@ -5,6 +5,27 @@ import { httpErrors } from './errors';
const SLACK_API_URL = 'https://slack.com/api';
export async function post(endpoint: string, body: Object) {
let data;
try {
const token = body.token;
const response = await fetch(`${SLACK_API_URL}/${endpoint}`, {
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(body),
});
data = await response.json();
} catch (e) {
throw httpErrors.BadRequest();
}
if (!data.ok) throw httpErrors.BadRequest(data.error);
return data;
}
export async function request(endpoint: string, body: Object) {
let data;
try {

View File

@@ -3,6 +3,7 @@
<head>
<title>Outline</title>
<meta name="slack-app-id" content="<%= SLACK_APP_ID %>" />
<style>
body,
html {
@@ -28,4 +29,4 @@
<script src="//d2wy8f7a9ursnm.cloudfront.net/bugsnag-3.min.js" data-apikey="<%= BUGSNAG_KEY %>"></script>
</body>
</html>
</html>