diff --git a/.env.sample b/.env.sample index a140297a..fdef2cc4 100644 --- a/.env.sample +++ b/.env.sample @@ -1,4 +1,6 @@ -# Copy this file to .env, remove this comment and change the keys +# Copy this file to .env, remove this comment and change the keys. For development +# with docker this should mostly work out of the box other than setting the Slack +# keys (for auth) and the SECRET_KEY. # # Please use `openssl rand -hex 32` to create SECRET_KEY @@ -7,19 +9,28 @@ DATABASE_URL_TEST=postgres://user:pass@localhost:5432/outline-test SECRET_KEY=F0E5AD933D7F6FD8F4DBB3E038C501C052DC0593C686D21ACB30AE205D2F634B PORT=3000 REDIS_URL=redis://redis:6379 -SLACK_KEY=71315967491.XXXXXXXXXX -SLACK_SECRET=d2dc414f9953226bad0a356c794XXXXX URL=http://localhost:3000 DEPLOYMENT=hosted ENABLE_UPDATES=true -GOOGLE_ANALYTICS_ID= +# Third party credentials (required) +SLACK_KEY=71315967491.XXXXXXXXXX +SLACK_SECRET=d2dc414f9953226bad0a356cXXXXYYYY + +# Third party credentials (optional) +SLACK_VERIFICATION_TOKEN=PLxk6OlXXXXXVj3YYYY +SLACK_APP_ID=A0XXXXXXX +GOOGLE_ANALYTICS_ID= +BUGSNAG_KEY= + +# AWS credentials (optional in dev) AWS_ACCESS_KEY_ID=notcheckedindev AWS_SECRET_ACCESS_KEY=notcheckedindev AWS_S3_UPLOAD_BUCKET_URL=http://s3:4569 AWS_S3_UPLOAD_BUCKET_NAME=outline-dev AWS_S3_UPLOAD_MAX_SIZE=26214400 +# Emails configuration (optional) SMTP_HOST= SMTP_PORT= SMTP_USERNAME= diff --git a/.eslintrc b/.eslintrc index 845c418d..99b2760d 100644 --- a/.eslintrc +++ b/.eslintrc @@ -54,7 +54,6 @@ "globals": { "__DEV__": true, "SLACK_KEY": true, - "SLACK_REDIRECT_URI": true, "DEPLOYMENT": true, "BASE_URL": true, "BUGSNAG_KEY": true, diff --git a/app/scenes/Settings/Slack.js b/app/scenes/Settings/Slack.js index 3af70f4f..894bc2d9 100644 --- a/app/scenes/Settings/Slack.js +++ b/app/scenes/Settings/Slack.js @@ -17,11 +17,11 @@ class Slack extends Component {

Slack

Connect Outline to your Slack team to instantly search for documents - using the /outline command. + using the /outline command and preview Outline links. diff --git a/flow-typed/globals.js b/flow-typed/globals.js index b5fbc176..50879f18 100644 --- a/flow-typed/globals.js +++ b/flow-typed/globals.js @@ -1,7 +1,7 @@ // @flow declare var __DEV__: string; -declare var SLACK_REDIRECT_URI: string; declare var SLACK_KEY: string; +declare var SLACK_APP_ID: string; declare var BASE_URL: string; declare var BUGSNAG_KEY: ?string; declare var DEPLOYMENT: string; diff --git a/package.json b/package.json index f4b705a1..4f1e5b06 100644 --- a/package.json +++ b/package.json @@ -169,6 +169,7 @@ "slate-edit-list": "^0.10.1", "slate-md-serializer": "^1.0.6", "slate-paste-linkify": "^0.5.0", + "slate-plain-serializer": "^0.4.15", "slate-prism": "^0.4.0", "slate-react": "^0.10.19", "slate-trailing-block": "^0.4.0", diff --git a/server/api/auth.js b/server/api/auth.js index 223f4d2e..6916739c 100644 --- a/server/api/auth.js +++ b/server/api/auth.js @@ -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; diff --git a/server/api/hooks.js b/server/api/hooks.js index 26269a00..25051313 100644 --- a/server/api/hooks.js +++ b/server/api/hooks.js @@ -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'); diff --git a/server/api/middlewares/apiWrapper.js b/server/api/middlewares/apiWrapper.js index 96f44276..d4f8ba33 100644 --- a/server/api/middlewares/apiWrapper.js +++ b/server/api/middlewares/apiWrapper.js @@ -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, + }; + } }; } diff --git a/server/migrations/20171218043717-add-authentications.js b/server/migrations/20171218043717-add-authentications.js new file mode 100644 index 00000000..8cb2ab1a --- /dev/null +++ b/server/migrations/20171218043717-add-authentications.js @@ -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'); + }, +}; diff --git a/server/models/Authentication.js b/server/models/Authentication.js new file mode 100644 index 00000000..85a07abe --- /dev/null +++ b/server/models/Authentication.js @@ -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; diff --git a/server/models/Document.js b/server/models/Document.js index 1f4e2892..e618b403 100644 --- a/server/models/Document.js +++ b/server/models/Document.js @@ -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}`; diff --git a/server/models/Event.js b/server/models/Event.js index 5d7cb0f0..c962a419 100644 --- a/server/models/Event.js +++ b/server/models/Event.js @@ -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 => { diff --git a/server/models/User.js b/server/models/User.js index 67413951..2a7afc7d 100644 --- a/server/models/User.js +++ b/server/models/User.js @@ -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( diff --git a/server/models/index.js b/server/models/index.js index 57e2ee37..11c6f237 100644 --- a/server/models/index.js +++ b/server/models/index.js @@ -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, +}; diff --git a/server/services/slack/index.js b/server/services/slack/index.js new file mode 100644 index 00000000..acbe3a41 --- /dev/null +++ b/server/services/slack/index.js @@ -0,0 +1,7 @@ +// @flow +const Slack = { + id: 'slack', + name: 'Slack', +}; + +export default Slack; diff --git a/server/slack.js b/server/slack.js index 82d1a07f..7ca4b78c 100644 --- a/server/slack.js +++ b/server/slack.js @@ -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 { diff --git a/server/static/index.html b/server/static/index.html index bde321e5..b0f533c7 100644 --- a/server/static/index.html +++ b/server/static/index.html @@ -3,6 +3,7 @@ Outline +