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
+